clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / scripts / blossom-upload.test.mjs
blossom-upload.test.mjs 7.9K
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";78const 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});2021test("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});4748test("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});6768test("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");8687 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});9495test("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"]);109110 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});114115test("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});133134test("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});149150test("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});