| 1 | import assert from "node:assert/strict" ; |
| 2 | import {webcrypto} from "node:crypto" ; |
| 3 | import {createServer} from "node:http" ; |
| 4 | import {readFile} from "node:fs/promises" ; |
| 5 | import test from "node:test" ; |
| 6 | import vm from "node:vm" ; |
| 7 | |
| 8 | const source = await readFile(new URL("../tinyclient/blossom-upload.js" , import.meta.url), "utf8" ); |
| 9 | const shared = await readFile(new URL("../tinyclient/tiny.js" , import.meta.url), "utf8" ); |
| 10 | const sandbox = {crypto: webcrypto, Blob, ArrayBuffer, Uint8Array, AbortController, Map, setTimeout, clearTimeout, URL}; |
| 11 | sandbox.globalThis = sandbox; |
| 12 | vm.runInNewContext(shared + source, sandbox); |
| 13 | const upload = sandbox.tiny.blossom.upload.upload; |
| 14 | const digest = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 15 | const response = (status, headers = {}, body = null) => ({ |
| 16 | status, ok: status >= 200 && status < 300, |
| 17 | headers: {get(name) { return headers[name.toLowerCase()] ?? null; }}, |
| 18 | async json() { if (body === null) throw Error("no body" ); return body; } |
| 19 | }); |
| 20 | |
| 21 | test("uses BUD-14 PATCH chunks and signs each chunk payload" , async () => { |
| 22 | const bytes = new TextEncoder().encode("abcdefghij" ); |
| 23 | const hash = await digest(bytes); |
| 24 | const calls = []; |
| 25 | const result = await upload(bytes, { |
| 26 | url: "https://blossom.example/upload" , |
| 27 | hash, |
| 28 | type: "text/plain" , |
| 29 | chunkSize: 4, |
| 30 | probeCache: false, |
| 31 | fetch: async (url, init) => { |
| 32 | calls.push({url, init}); |
| 33 | return calls.length === 1 ? response(204, {allow: "GET, PUT, PATCH" }) : |
| 34 | calls.length === 4 ? response(201, {}, {sha256: hash, size: bytes.length}) : response(204); |
| 35 | }, |
| 36 | authorize: async (url, method, body) => "Nostr " + await digest(body) |
| 37 | }); |
| 38 | assert.equal(result.hash, hash); |
| 39 | assert.equal(calls.length, 4); |
| 40 | assert.equal(calls[1].init.method, "PATCH" ); |
| 41 | assert.equal(calls[1].init.headers["upload-offset" ], "0" ); |
| 42 | assert.equal(calls[2].init.headers["upload-offset" ], "4" ); |
| 43 | assert.equal(calls[3].init.headers["content-length" ], undefined); |
| 44 | assert.equal(calls[1].init.headers["content-type" ], "application/octet-stream" ); |
| 45 | assert.match(calls[1].init.headers.authorization, new RegExp((await digest(bytes.slice(0, 4))))); |
| 46 | }); |
| 47 | |
| 48 | test("falls back to BUD-13 PUT when PATCH is not advertised" , async () => { |
| 49 | const bytes = new TextEncoder().encode("fallback" ); |
| 50 | const hash = await digest(bytes); |
| 51 | const calls = []; |
| 52 | const result = await upload(bytes, { |
| 53 | url: "https://blossom.example/upload" , |
| 54 | hash, |
| 55 | type: "text/plain" , |
| 56 | probeCache: false, |
| 57 | fetch: async (url, init) => { |
| 58 | calls.push({url, init}); |
| 59 | return calls.length === 1 ? response(204, {allow: "GET, PUT" }) : response(201, {}, {sha256: hash, size: bytes.length}); |
| 60 | } |
| 61 | }); |
| 62 | assert.equal(result.descriptor.sha256, hash); |
| 63 | assert.equal(calls[1].init.method, "PUT" ); |
| 64 | assert.deepEqual([...calls[1].init.body], [...bytes]); |
| 65 | assert.equal(calls[1].init.headers["content-type" ], "text/plain" ); |
| 66 | }); |
| 67 | |
| 68 | test("retries transient chunks, reports local state, and can be canceled" , async () => { |
| 69 | const bytes = new Uint8Array([1, 2, 3, 4]); |
| 70 | const hash = await digest(bytes); |
| 71 | let attempts = 0; |
| 72 | const states = []; |
| 73 | const task = upload(bytes, { |
| 74 | url: "https://blossom.example/upload" , hash, chunkSize: 2, retries: 1, probeCache: false, |
| 75 | fetch: async (url, init) => { |
| 76 | if (init.method === "OPTIONS" ) return response(204, {allow: "PATCH" }); |
| 77 | attempts++; |
| 78 | if (attempts === 1) return response(503); |
| 79 | return response(204); |
| 80 | }, |
| 81 | onState: state => states.push(state) |
| 82 | }); |
| 83 | await assert.rejects(task, /without completion/); |
| 84 | assert.equal(attempts, 5); |
| 85 | assert.equal(states.at(-1).status, "uploaded" ); |
| 86 | |
| 87 | const canceled = upload(bytes, {url: "https://blossom.example/upload" , hash, fetch: async (url, init) => { |
| 88 | if (init.signal.aborted) throw Object.assign(new Error("aborted" ), {name: "AbortError" }); |
| 89 | return new Promise((resolve, reject) => init.signal.addEventListener("abort" , () => reject(Object.assign(new Error("aborted" ), {name: "AbortError" })))); |
| 90 | }}); |
| 91 | canceled.cancel(); |
| 92 | await assert.rejects(canceled, error => error.name === "AbortError" ); |
| 93 | }); |
| 94 | |
| 95 | test("resumes recent local chunks, rejects stale state, and checks the descriptor" , async () => { |
| 96 | const bytes = new Uint8Array([7, 8, 9, 10]); |
| 97 | const hash = await digest(bytes); |
| 98 | const state = new Map([[0, {offset: 0, length: 2, status: 204}]]); |
| 99 | state.meta = {hash, size: bytes.length, chunkSize: 2, url: "https://blossom.example/tenant/" + hash, type: "application/octet-stream" , lastAcceptedAt: Date.now()}; |
| 100 | const offsets = []; |
| 101 | await upload(bytes, {url: "https://blossom.example/tenant/" + hash, hash, chunkSize: 2, state, |
| 102 | fetch: async (url, init) => { |
| 103 | if (init.method === "OPTIONS" ) return response(204, {allow: "PATCH" }); |
| 104 | offsets.push(init.headers["upload-offset" ]); |
| 105 | return offsets.length === 1 ? response(201, {}, {sha256: hash, size: bytes.length}) : response(204); |
| 106 | } |
| 107 | }); |
| 108 | assert.deepEqual(offsets, ["2" ]); |
| 109 | |
| 110 | await assert.rejects(upload(bytes, {url: "https://blossom.example" , hash, probe: false, |
| 111 | fetch: async () => response(201, {}, {sha256: "0" .repeat(64), size: bytes.length}) |
| 112 | }), /invalid Blossom upload descriptor/); |
| 113 | }); |
| 114 | |
| 115 | test("caches endpoint capability and appends upload metadata" , async () => { |
| 116 | const bytes = new Uint8Array([1, 2]); |
| 117 | const hash = await digest(bytes); |
| 118 | const calls = []; |
| 119 | const fetcher = async (url, init) => { |
| 120 | calls.push({url, init}); |
| 121 | return init.method === "OPTIONS" ? response(204, {allow: "PATCH" }) : response(201, {}, {sha256: hash, size: bytes.length}); |
| 122 | }; |
| 123 | await upload(bytes, {url: "https://metadata.example/upload" , hash, chunkSize: bytes.length, fetch: fetcher, |
| 124 | metadata: {access: "members" , purpose: "file" , filename: "secret.bin" }}); |
| 125 | await upload(bytes, {url: "https://metadata.example/upload" , hash, chunkSize: bytes.length, fetch: fetcher, |
| 126 | metadata: {access: "members" , purpose: "chunk" , filename: "secret.bin" }}); |
| 127 | assert.equal(calls.filter(call => call.init.method === "OPTIONS" ).length, 1); |
| 128 | const patch = calls.find(call => call.init.method === "PATCH" ); |
| 129 | assert.match(patch.url, /access=members/); |
| 130 | assert.match(patch.url, /purpose=file/); |
| 131 | assert.match(patch.url, /filename=secret.bin/); |
| 132 | }); |
| 133 | |
| 134 | test("keeps the response body readable after headers arrive" , async () => { |
| 135 | const bytes = new Uint8Array([11, 12]); |
| 136 | const hash = await digest(bytes); |
| 137 | const server = createServer((request, reply) => { |
| 138 | if (request.method === "OPTIONS" ) { reply.writeHead(204, {Allow: "PATCH" }); return reply.end(); } |
| 139 | reply.writeHead(201, {"content-type" : "application/json" }); |
| 140 | setTimeout(() => reply.end(JSON.stringify({sha256: hash, size: bytes.length})), 20); |
| 141 | }); |
| 142 | await new Promise(resolve => server.listen(0, "127.0.0.1" , resolve)); |
| 143 | try { |
| 144 | const address = server.address(); |
| 145 | const result = await upload(bytes, {url: `http://127.0.0.1:${address.port}` , hash, chunkSize: bytes.length, timeoutMs: 1000, fetch}); |
| 146 | assert.equal(result.descriptor.sha256, hash); |
| 147 | } finally { await new Promise(resolve => server.close(resolve)); } |
| 148 | }); |
| 149 | |
| 150 | test("plan lists signed chunk requests without sending them" , async () => { |
| 151 | const bytes = new Uint8Array(12); |
| 152 | const calls = []; |
| 153 | const planned = await sandbox.tiny.blossom.upload.plan(bytes, {url: "https://blossom.example/" , chunkSize: 5, authorize: async (url, method, body) => { calls.push([method, body.byteLength]); return "Nostr " + url; }}); |
| 154 | assert.equal(planned.size, 12); |
| 155 | assert.equal(planned.requests.length, 3); |
| 156 | assert.equal(planned.requests.map(request => request.headers["upload-offset" ]).join("," ), "0,5,10" ); |
| 157 | assert.equal(planned.requests.map(request => request.body.byteLength).join("," ), "5,5,2" ); |
| 158 | assert.equal(planned.requests[0].headers["upload-length" ], "12" ); |
| 159 | assert.equal(planned.requests[0].url, "https://blossom.example/" + planned.hash); |
| 160 | assert.equal(JSON.stringify(calls), JSON.stringify([["PATCH" , 5], ["PATCH" , 5], ["PATCH" , 2]])); |
| 161 | assert.equal(planned.requests[2].headers.authorization, "Nostr https://blossom.example/" + planned.hash); |
| 162 | }); |