tinyrelayfile
tinyrelay/scripts/blossom-upload.test.mjs/

scripts/blossom-upload.test.mjs raw
1import assert from "node:assert/strict";
2import {webcrypto} from "node:crypto";
3import {createServer} from "node:http";
4import {readFile} from "node:fs/promises";
5import test from "node:test";
6import vm from "node:vm";
7
8const source = await readFile(new URL("../tinyclient/blossom-upload.js", import.meta.url), "utf8");
9const shared = await readFile(new URL("../tinyclient/tiny.js", import.meta.url), "utf8");
10const sandbox = {crypto: webcrypto, Blob, ArrayBuffer, Uint8Array, AbortController, Map, setTimeout, clearTimeout, URL};
11sandbox.globalThis = sandbox;
12vm.runInNewContext(shared + source, sandbox);
13const upload = sandbox.tiny.blossom.upload.upload;
14const digest = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");
15const 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
21test("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
48test("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
68test("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
95test("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
115test("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
134test("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
150test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs