|
7 | 7 |
|
8 | 8 | import { describe, it, before, after, beforeEach } from "node:test"; |
9 | 9 | import assert from "node:assert/strict"; |
10 | | -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 10 | +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
11 | 11 | import { join } from "node:path"; |
12 | 12 | import { tmpdir } from "node:os"; |
| 13 | +import { createHash } from "node:crypto"; |
13 | 14 |
|
14 | 15 | const tempDir = mkdtempSync(join(tmpdir(), "run402-argv-")); |
15 | 16 | const API = "https://test-api.run402.com"; |
@@ -325,6 +326,41 @@ describe("contracts wei argv validation", () => { |
325 | 326 |
|
326 | 327 | describe("2026-05 CLI bug backlog argv validation", () => { |
327 | 328 | const invalidCases = [ |
| 329 | + { |
| 330 | + issue: "GH-319", |
| 331 | + name: "blob sign rejects TTL below signed URL minimum", |
| 332 | + module: "./cli/lib/blob.mjs", |
| 333 | + call: (run) => run("sign", ["reports/a.pdf", "--project", "prj_test123", "--ttl", "59"]), |
| 334 | + code: "BAD_FLAG", |
| 335 | + }, |
| 336 | + { |
| 337 | + issue: "GH-318", |
| 338 | + name: "blob get rejects extra positional keys", |
| 339 | + module: "./cli/lib/blob.mjs", |
| 340 | + call: (run) => run("get", ["a.txt", "b.txt", "--output", join(tempDir, "blob-extra.txt"), "--project", "prj_test123"]), |
| 341 | + code: "BAD_USAGE", |
| 342 | + }, |
| 343 | + { |
| 344 | + issue: "GH-318", |
| 345 | + name: "blob rm rejects extra positional keys", |
| 346 | + module: "./cli/lib/blob.mjs", |
| 347 | + call: (run) => run("rm", ["a.txt", "b.txt", "--project", "prj_test123"]), |
| 348 | + code: "BAD_USAGE", |
| 349 | + }, |
| 350 | + { |
| 351 | + issue: "GH-318", |
| 352 | + name: "blob sign rejects extra positional keys", |
| 353 | + module: "./cli/lib/blob.mjs", |
| 354 | + call: (run) => run("sign", ["a.txt", "b.txt", "--project", "prj_test123"]), |
| 355 | + code: "BAD_USAGE", |
| 356 | + }, |
| 357 | + { |
| 358 | + issue: "GH-318", |
| 359 | + name: "blob diagnose rejects extra positional URLs", |
| 360 | + module: "./cli/lib/blob.mjs", |
| 361 | + call: (run) => run("diagnose", ["https://app.run402.com/_blob/a.txt", "https://app.run402.com/_blob/b.txt", "--project", "prj_test123"]), |
| 362 | + code: "BAD_USAGE", |
| 363 | + }, |
328 | 364 | { |
329 | 365 | issue: "GH-306", |
330 | 366 | name: "blob put rejects multi-file uploads with a fixed --key", |
@@ -833,6 +869,250 @@ describe("numeric flag validation", () => { |
833 | 869 | assert.equal(initBody?.key, "assets/logo"); |
834 | 870 | }); |
835 | 871 |
|
| 872 | + it("blob put sends required object and part checksums by default (GH-308, GH-312, GH-314)", async () => { |
| 873 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 874 | + const file = join(tempDir, "checksum-upload.txt"); |
| 875 | + writeFileSync(file, "hello world"); |
| 876 | + let initBody = null; |
| 877 | + let completeBody = null; |
| 878 | + let putHeaders = null; |
| 879 | + const expectedSha = createHash("sha256").update("hello world").digest("hex"); |
| 880 | + const expectedPartChecksum = createHash("sha256").update("hello world").digest("base64"); |
| 881 | + const prevFetch = globalThis.fetch; |
| 882 | + globalThis.fetch = (input, init) => { |
| 883 | + const info = requestInfo(input, init); |
| 884 | + calls.push(info); |
| 885 | + if (info.path === "/storage/v1/uploads" && info.method === "POST") { |
| 886 | + initBody = JSON.parse(String(info.init.body)); |
| 887 | + return Promise.resolve(json({ |
| 888 | + upload_id: "upload_checksum", |
| 889 | + mode: "multipart", |
| 890 | + part_count: 1, |
| 891 | + parts: [{ part_number: 1, url: "https://s3.example.test/upload_checksum/p1", byte_start: 0, byte_end: 10 }], |
| 892 | + }, 201)); |
| 893 | + } |
| 894 | + if (info.url === "https://s3.example.test/upload_checksum/p1" && info.method === "PUT") { |
| 895 | + putHeaders = info.init.headers ?? {}; |
| 896 | + return Promise.resolve(new Response("", { status: 200, headers: { etag: "\"etag-1\"" } })); |
| 897 | + } |
| 898 | + if (info.path === "/storage/v1/uploads/upload_checksum/complete" && info.method === "POST") { |
| 899 | + completeBody = JSON.parse(String(info.init.body)); |
| 900 | + return Promise.resolve(json({ |
| 901 | + key: "checksum-upload.txt", |
| 902 | + size_bytes: 11, |
| 903 | + sha256: expectedSha, |
| 904 | + visibility: "public", |
| 905 | + content_type: "text/plain", |
| 906 | + url: "https://pr-test.run402.com/_blob/checksum-upload.txt", |
| 907 | + immutable_url: null, |
| 908 | + })); |
| 909 | + } |
| 910 | + return mockFetch(input, init); |
| 911 | + }; |
| 912 | + captureStart(); |
| 913 | + try { |
| 914 | + await run("put", [file, "--project", "prj_test123", "--no-resume"]); |
| 915 | + } finally { |
| 916 | + captureStop(); |
| 917 | + globalThis.fetch = prevFetch; |
| 918 | + } |
| 919 | + |
| 920 | + assert.equal(initBody?.sha256, expectedSha); |
| 921 | + assert.equal(initBody?.immutable, false); |
| 922 | + assert.equal(putHeaders?.["x-amz-checksum-sha256"], expectedPartChecksum); |
| 923 | + assert.deepEqual(completeBody?.parts, [ |
| 924 | + { part_number: 1, etag: "\"etag-1\"", sha256: expectedSha }, |
| 925 | + ]); |
| 926 | + }); |
| 927 | + |
| 928 | + it("blob put does not complete multipart uploads until every in-flight part settles (GH-315)", async () => { |
| 929 | + const { run } = await import("./cli/lib/blob.mjs"); |
| 930 | + const file = join(tempDir, "concurrency-upload.txt"); |
| 931 | + writeFileSync(file, "abcdefghi"); |
| 932 | + let part2Resolved = false; |
| 933 | + let completeBeforePart2 = false; |
| 934 | + const prevFetch = globalThis.fetch; |
| 935 | + globalThis.fetch = (input, init) => { |
| 936 | + const info = requestInfo(input, init); |
| 937 | + calls.push(info); |
| 938 | + if (info.path === "/storage/v1/uploads" && info.method === "POST") { |
| 939 | + return Promise.resolve(json({ |
| 940 | + upload_id: "upload_concurrency", |
| 941 | + mode: "multipart", |
| 942 | + part_count: 3, |
| 943 | + parts: [ |
| 944 | + { part_number: 1, url: "https://s3.example.test/upload_concurrency/p1", byte_start: 0, byte_end: 2 }, |
| 945 | + { part_number: 2, url: "https://s3.example.test/upload_concurrency/p2", byte_start: 3, byte_end: 5 }, |
| 946 | + { part_number: 3, url: "https://s3.example.test/upload_concurrency/p3", byte_start: 6, byte_end: 8 }, |
| 947 | + ], |
| 948 | + }, 201)); |
| 949 | + } |
| 950 | + if (info.url === "https://s3.example.test/upload_concurrency/p2" && info.method === "PUT") { |
| 951 | + return new Promise((resolve) => { |
| 952 | + setTimeout(() => { |
| 953 | + part2Resolved = true; |
| 954 | + resolve(new Response("", { status: 200, headers: { etag: "\"etag-2\"" } })); |
| 955 | + }, 100); |
| 956 | + }); |
| 957 | + } |
| 958 | + if (info.url.startsWith("https://s3.example.test/upload_concurrency/p") && info.method === "PUT") { |
| 959 | + const partNumber = info.url.endsWith("/p1") ? "1" : "3"; |
| 960 | + return Promise.resolve(new Response("", { status: 200, headers: { etag: `"etag-${partNumber}"` } })); |
| 961 | + } |
| 962 | + if (info.path === "/storage/v1/uploads/upload_concurrency/complete" && info.method === "POST") { |
| 963 | + completeBeforePart2 = !part2Resolved; |
| 964 | + return Promise.resolve(json({ |
| 965 | + key: "concurrency-upload.txt", |
| 966 | + size_bytes: 9, |
| 967 | + sha256: createHash("sha256").update("abcdefghi").digest("hex"), |
| 968 | + visibility: "public", |
| 969 | + content_type: "text/plain", |
| 970 | + url: "https://pr-test.run402.com/_blob/concurrency-upload.txt", |
| 971 | + immutable_url: null, |
| 972 | + })); |
| 973 | + } |
| 974 | + return mockFetch(input, init); |
| 975 | + }; |
| 976 | + captureStart(); |
| 977 | + try { |
| 978 | + await run("put", [file, "--project", "prj_test123", "--concurrency", "2", "--no-resume"]); |
| 979 | + } finally { |
| 980 | + captureStop(); |
| 981 | + globalThis.fetch = prevFetch; |
| 982 | + } |
| 983 | + |
| 984 | + assert.equal(completeBeforePart2, false, "complete must wait for all in-flight parts"); |
| 985 | + }); |
| 986 | + |
| 987 | + it("blob resumable state is private, checksum-bearing, and does not persist presigned URLs (GH-316, GH-317)", async () => { |
| 988 | + const stateHome = mkdtempSync(join(tmpdir(), "run402-blob-home-")); |
| 989 | + const prevHome = process.env.HOME; |
| 990 | + process.env.HOME = stateHome; |
| 991 | + const { run } = await import("./cli/lib/blob.mjs?state-private"); |
| 992 | + const file = join(tempDir, "state-upload.txt"); |
| 993 | + writeFileSync(file, "hello state"); |
| 994 | + const expectedSha = createHash("sha256").update("hello state").digest("hex"); |
| 995 | + const prevFetch = globalThis.fetch; |
| 996 | + globalThis.fetch = (input, init) => { |
| 997 | + const info = requestInfo(input, init); |
| 998 | + calls.push(info); |
| 999 | + if (info.path === "/storage/v1/uploads" && info.method === "POST") { |
| 1000 | + return Promise.resolve(json({ |
| 1001 | + upload_id: "upload_state", |
| 1002 | + mode: "multipart", |
| 1003 | + part_count: 1, |
| 1004 | + parts: [{ part_number: 1, url: "https://s3.example.test/upload_state/p1", byte_start: 0, byte_end: 10 }], |
| 1005 | + }, 201)); |
| 1006 | + } |
| 1007 | + if (info.url === "https://s3.example.test/upload_state/p1" && info.method === "PUT") { |
| 1008 | + return Promise.resolve(new Response("denied", { status: 403, statusText: "Forbidden" })); |
| 1009 | + } |
| 1010 | + return mockFetch(input, init); |
| 1011 | + }; |
| 1012 | + |
| 1013 | + try { |
| 1014 | + const err = await expectExit1(() => run("put", [file, "--project", "prj_test123"])); |
| 1015 | + assert.match(err.message, /Part 1 PUT failed/); |
| 1016 | + |
| 1017 | + const stateDir = join(stateHome, ".run402", "uploads"); |
| 1018 | + const files = readdirSync(stateDir).filter((name) => name.endsWith(".json")); |
| 1019 | + assert.deepEqual(files, ["upload_state.json"]); |
| 1020 | + assert.equal(statSync(stateDir).mode & 0o777, 0o700); |
| 1021 | + const statePath = join(stateDir, "upload_state.json"); |
| 1022 | + assert.equal(statSync(statePath).mode & 0o777, 0o600); |
| 1023 | + const raw = readFileSync(statePath, "utf8"); |
| 1024 | + assert.equal(raw.includes("https://s3.example.test"), false, "state must not persist presigned URLs"); |
| 1025 | + const state = JSON.parse(raw); |
| 1026 | + assert.equal(state.sha256, expectedSha); |
| 1027 | + assert.equal(state.file_size, 11); |
| 1028 | + assert.equal(typeof state.file_mtime_ms, "number"); |
| 1029 | + } finally { |
| 1030 | + globalThis.fetch = prevFetch; |
| 1031 | + if (prevHome === undefined) delete process.env.HOME; |
| 1032 | + else process.env.HOME = prevHome; |
| 1033 | + rmSync(stateHome, { recursive: true, force: true }); |
| 1034 | + } |
| 1035 | + }); |
| 1036 | + |
| 1037 | + it("blob put discards cached upload sessions when the local file changed (GH-316)", async () => { |
| 1038 | + const stateHome = mkdtempSync(join(tmpdir(), "run402-blob-home-")); |
| 1039 | + const prevHome = process.env.HOME; |
| 1040 | + process.env.HOME = stateHome; |
| 1041 | + const { run } = await import("./cli/lib/blob.mjs?state-fingerprint"); |
| 1042 | + const stateDir = join(stateHome, ".run402", "uploads"); |
| 1043 | + const file = join(tempDir, "state-changed.txt"); |
| 1044 | + writeFileSync(file, "new file"); |
| 1045 | + const absFile = join(tempDir, "state-changed.txt"); |
| 1046 | + const staleState = { |
| 1047 | + upload_id: "upload_stale", |
| 1048 | + project_id: "prj_test123", |
| 1049 | + local_path: absFile, |
| 1050 | + key: "state-changed.txt", |
| 1051 | + mode: "multipart", |
| 1052 | + part_size_bytes: 3, |
| 1053 | + part_count: 1, |
| 1054 | + file_size: 999, |
| 1055 | + file_mtime_ms: 1, |
| 1056 | + parts_done: {}, |
| 1057 | + sha256: "0".repeat(64), |
| 1058 | + }; |
| 1059 | + rmSync(stateDir, { recursive: true, force: true }); |
| 1060 | + writeFileSync(file, "new file"); |
| 1061 | + const prevFetch = globalThis.fetch; |
| 1062 | + let fetchedStale = false; |
| 1063 | + let initUploadId = null; |
| 1064 | + globalThis.fetch = (input, init) => { |
| 1065 | + const info = requestInfo(input, init); |
| 1066 | + calls.push(info); |
| 1067 | + if (info.path === "/storage/v1/uploads/upload_stale" && info.method === "GET") { |
| 1068 | + fetchedStale = true; |
| 1069 | + return Promise.resolve(json({ upload_id: "upload_stale", status: "active" })); |
| 1070 | + } |
| 1071 | + if (info.path === "/storage/v1/uploads" && info.method === "POST") { |
| 1072 | + initUploadId = "upload_fresh"; |
| 1073 | + return Promise.resolve(json({ |
| 1074 | + upload_id: "upload_fresh", |
| 1075 | + mode: "single", |
| 1076 | + part_count: 1, |
| 1077 | + parts: [{ part_number: 1, url: "https://s3.example.test/upload_fresh/p1", byte_start: 0, byte_end: 7 }], |
| 1078 | + }, 201)); |
| 1079 | + } |
| 1080 | + if (info.url === "https://s3.example.test/upload_fresh/p1" && info.method === "PUT") { |
| 1081 | + return Promise.resolve(new Response("", { status: 200, headers: { etag: "\"etag-fresh\"" } })); |
| 1082 | + } |
| 1083 | + if (info.path === "/storage/v1/uploads/upload_fresh/complete" && info.method === "POST") { |
| 1084 | + return Promise.resolve(json({ |
| 1085 | + key: "state-changed.txt", |
| 1086 | + size_bytes: 8, |
| 1087 | + sha256: createHash("sha256").update("new file").digest("hex"), |
| 1088 | + visibility: "public", |
| 1089 | + content_type: "text/plain", |
| 1090 | + url: "https://pr-test.run402.com/_blob/state-changed.txt", |
| 1091 | + immutable_url: null, |
| 1092 | + })); |
| 1093 | + } |
| 1094 | + return mockFetch(input, init); |
| 1095 | + }; |
| 1096 | + |
| 1097 | + try { |
| 1098 | + rmSync(stateDir, { recursive: true, force: true }); |
| 1099 | + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); |
| 1100 | + writeFileSync(join(stateDir, "upload_stale.json"), JSON.stringify(staleState, null, 2), { mode: 0o600 }); |
| 1101 | + captureStart(); |
| 1102 | + await run("put", [file, "--project", "prj_test123"]); |
| 1103 | + captureStop(); |
| 1104 | + assert.equal(fetchedStale, false, "changed-file state should be discarded before polling stale session"); |
| 1105 | + assert.equal(initUploadId, "upload_fresh"); |
| 1106 | + assert.equal(existsSync(join(stateDir, "upload_stale.json")), false); |
| 1107 | + } finally { |
| 1108 | + captureStop(); |
| 1109 | + globalThis.fetch = prevFetch; |
| 1110 | + if (prevHome === undefined) delete process.env.HOME; |
| 1111 | + else process.env.HOME = prevHome; |
| 1112 | + rmSync(stateHome, { recursive: true, force: true }); |
| 1113 | + } |
| 1114 | + }); |
| 1115 | + |
836 | 1116 | it("blob put surfaces upload-init gateway errors as structured JSON (GH-186)", async () => { |
837 | 1117 | const { run } = await import("./cli/lib/blob.mjs"); |
838 | 1118 | const file = join(tempDir, "upload-init-fails.txt"); |
|
0 commit comments