clone

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

main
tinyrelay / scripts / file-workspace.test.mjs
file-workspace.test.mjs 27.4K
1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import { webcrypto } from "node:crypto";67class Node {8 constructor(tag = "") { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = {}; this.listeners = {}; this.dataset = {}; this.textContent = ""; }9 append(...nodes) { nodes.forEach(node => { if (node) { this.children.push(node); node.parentNode = this; } }); }10 replaceChildren(...nodes) { this.children = []; this.append(...nodes); }11 click() {}12 remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); }13 setAttribute(name, value) { this.attributes[name] = String(value); }14 getAttribute(name) { return this.attributes[name] ?? null; }15 hasAttribute(name) { return name in this.attributes; }16 addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); }17 set innerHTML(value) { this.children = []; parse(value, this); }18 get childNodes() { return this.children; }19 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }20 querySelectorAll(selector) {21 const result = [];22 const match = node => {23 if (selector === "output") return node.tagName === "OUTPUT";24 if (selector === "form:not([data-share])") return node.tagName === "FORM" && !node.hasAttribute("data-share");25 if (selector === "input[type=file]") return node.tagName === "INPUT" && node.getAttribute("type") === "file";26 const attr = selector.match(/^([^[]+)?\[([^=\]]+)(?:=\"([^\"]+)\")?\]$/);27 if (attr) return (!attr[1] || node.tagName === attr[1].toUpperCase()) && node.hasAttribute(attr[2]) && (attr[3] === undefined || node.getAttribute(attr[2]) === attr[3]);28 return node.tagName === selector.toUpperCase();29 };30 const walk = node => { node.children.forEach(child => { if (match(child)) result.push(child); walk(child); }); };31 walk(this); return result;32 }33}34class Form extends Node {35 constructor() { super("form"); this.elements = { namedItem: name => this.querySelectorAll("input").concat(this.querySelectorAll("select")).find(node => node.getAttribute("name") === name) }; }36}37const parse = (html, parent) => {38 const stack = [parent];39 for (const token of html.match(/<[^>]+>|[^<]+/g) || []) {40 if (!token.startsWith("<")) continue;41 if (/^<\/(option|input|br|meta|img)>/.test(token)) continue;42 if (/^<\//.test(token)) { stack.pop(); continue; }43 const match = token.match(/^<([\w-]+)([^>]*)>/); if (!match) continue;44 const node = match[1] === "form" ? new Form() : new Node(match[1]);45 for (const [, name, quote, value] of match[2].matchAll(/([\w-]+)(?:=(['"])(.*?)\2)?/g)) node.setAttribute(name, value ?? "");46 if (node.tagName === "INPUT" || node.tagName === "SELECT") node.value = node.getAttribute("value") || (node.tagName === "SELECT" ? "random" : "");47 stack.at(-1).append(node);48 if (!/^(input|br|meta|option|img)$/.test(match[1])) stack.push(node);49 }50};51const load = (workspace = false) => {52 const document = new Node("document"); document.documentElement = new Node("html"); document.body = new Node("body"); document.createElement = name => name === "form" ? new Form() : new Node(name); document.addEventListener = () => {}; document.dispatchEvent = () => {};53 const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};54 const window = {document, customElements, tiny: {blossom: {}, files: {}}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};55 class HTMLElement extends Node {}56 Object.assign(globalThis, {window, document, customElements, HTMLElement, location: window.location, Blob, URL, CustomEvent: class {}});57 globalThis.tiny = window.tiny;58 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable"); }}}});59 globalThis.btoa = value => Buffer.from(value, "binary").toString("base64"); globalThis.atob = value => Buffer.from(value, "base64").toString("binary");60 vm.runInThisContext(fs.readFileSync("tinyclient/tiny.js", "utf8"));61 if (workspace) { vm.runInThisContext(fs.readFileSync("tinyclient/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("tinyclient/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }62 vm.runInThisContext(fs.readFileSync("tinyclient/components.js", "utf8"));63 return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"], FileUpload: customElements.registry["file-upload"]};64};6566const workspaceServer = t => {67 const loaded = load(true);68 const {window} = loaded;69 const blobs = new Map(), pending = new Map(), requests = [];70 const originalFetch = globalThis.fetch;71 window.tiny.localPath = path => "/r/test" + path;72 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");73 window.tiny.authorization = async (url, method, body) => {74 assert.equal(new URL(url).hash, "");75 return "Nostr " + await window.tiny.sha256hex(body);76 };77 globalThis.fetch = async (target, init = {}) => {78 const url = new URL(target, window.location.href);79 requests.push({url, init});80 assert.equal(url.hash, "");81 assert.equal(url.searchParams.has("key"), false);82 if (init.method === "OPTIONS") return new Response(null, {status: 204, headers: {Allow: "PATCH"}});83 if (init.method === "PATCH") {84 const hash = url.pathname.split("/").at(-1);85 assert.match(url.pathname, /^\/r\/test\/[0-9a-f]{64}$/);86 const length = Number(init.headers["upload-length"]), offset = Number(init.headers["upload-offset"]);87 if (!pending.has(hash)) pending.set(hash, new Uint8Array(length));88 pending.get(hash).set(init.body, offset);89 if (offset + init.body.length < length) return new Response(null, {status: 204});90 const bytes = pending.get(hash);91 assert.equal(hash, await window.tiny.sha256hex(bytes));92 blobs.set(hash, bytes); pending.delete(hash);93 return Response.json({sha256: hash, size: bytes.length}, {status: 201});94 }95 const hash = url.searchParams.get("hash");96 return blobs.has(hash) ? new Response(blobs.get(hash)) : new Response(null, {status: 404});97 };98 t.after(() => { globalThis.fetch = originalFetch; });99 const open = async url => {100 const value = new URL(url);101 Object.assign(window.location, {href: value.href, pathname: value.pathname, hash: value.hash});102 const root = new customElements.registry["file-workspace-root"]();103 await root.load();104 t.after(() => root.disconnectedCallback());105 return root;106 };107 const store = async files => {108 const upload = new customElements.registry["file-upload"]();109 upload.connectedCallback();110 const form = upload.querySelector("form");111 form.elements.namedItem("file").files = files;112 form.elements.namedItem("encrypt").checked = true;113 return upload.run(form);114 };115 return {...loaded, blobs, requests, store, open, originalFetch};116};117// sealedUpload stores one file through the upload control with encrypt checked118// and the plain signed PUT path, returning the share link and ciphertext.119const sealedUpload = async (window, FileUpload, file) => {120 let uploaded;121 delete window.tiny.blossom.upload;122 window.tiny.localPath = path => path;123 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");124 window.tiny.signedFetch = async (_path, _method, body) => { if (_path === "/files/metadata") return {ok: true}; uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };125 const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();126 const form = upload.querySelector("form");127 form.elements.namedItem("file").files = [file];128 form.elements.namedItem("encrypt").checked = true;129 const link = await upload.run(form);130 return {upload, link, uploaded: () => uploaded};131};132const folderFile = (contents, path) => {133 const file = new File([contents], path.split("/").at(-1), {type: "text/plain"});134 Object.defineProperty(file, "webkitRelativePath", {value: path});135 return file;136};137138test("nested folder components upload and decrypt large files without exposing keys", async t => {139 const server = workspaceServer(t);140 const large = new Uint8Array(2 * 1024 * 1024 + 19); large[2] = 91; large[large.length - 1] = 73;141 const url = await server.store([142 folderFile(large, "root/__proto__/f"), folderFile("héllo", "root/é.txt"),143 folderFile("emoji", "root/😀.txt"), folderFile("constructor", "root/constructor")144 ]);145 const root = await server.open(url);146 assert.deepEqual(root.entries.map(entry => entry.n), ["__proto__", "constructor", "é.txt", "😀.txt"]);147 const child = await server.open(root.querySelector("a").href);148 assert.equal(child.entries[0].n, "f");149 assert.deepEqual(await child.download(child.entries[0]), large);150 const key = new URLSearchParams(new URL(url).hash.slice(1)).get("key");151 for (const {url: target, init} of server.requests) {152 assert.equal(target.href.includes(key), false);153 assert.equal(target.searchParams.has("filename"), false);154 assert.equal(target.searchParams.has("path"), false);155 if (init.body) assert.equal(new TextDecoder().decode(init.body).includes("héllo"), false);156 }157 assert.ok(server.blobs.size >= 8);158});159160test("plain uploads retain names and folder paths within the current library folder", async () => {161 const {window, FileUpload} = load(true);162 window.location.href = "https://relay.test/files?path=Projects";163 const requests = [];164 window.tiny.signedFetch = async (target, method, bytes) => requests.push({target, method, bytes});165 const upload = new FileUpload();166 upload.connectedCallback();167 upload.controller = new AbortController();168 await upload.storePlain([folderFile("image", "Trip/photos/snow & sun.jpg"), new File(["notes"], "notes.txt")]);169 const first = new URL(requests[0].target, window.location.href);170 assert.equal(first.pathname, "/upload");171 assert.equal(first.searchParams.get("filename"), "snow & sun.jpg");172 assert.equal(first.searchParams.get("path"), "Projects/Trip/photos/snow & sun.jpg");173 assert.equal(new URL(requests[1].target, window.location.href).searchParams.get("path"), "Projects/notes.txt");174 assert.equal(new TextDecoder().decode(requests[0].bytes), "image");175 window.location.href = "https://relay.test/files/sites?path=weather";176 await upload.storePlain([new File(["file"], "new.txt")]);177 assert.equal(new URL(requests[2].target, window.location.href).searchParams.get("path"), "new.txt");178});179180test("directory fanout stays invisible while 175 entries remain browsable", async t => {181 const server = workspaceServer(t);182 const files = Array.from({length: 175}, (_, index) => folderFile("content " + index, "wide/file-" + String(index).padStart(3, "0")));183 const root = await server.open(await server.store(files));184 assert.equal(root.entries.length, 175);185 assert.equal(root.entries[174].n, "file-174");186 assert.equal(new TextDecoder().decode(await root.download(root.entries[174])), "content 174");187});188189test("folder upload rejects unexpected server hashes", async t => {190 const server = workspaceServer(t);191 globalThis.fetch = async (_url, init = {}) => init.method === "OPTIONS" ? new Response(null, {status:204, headers:{Allow:"PATCH"}}) : Response.json({sha256:"0".repeat(64), size: init.body.length}, {status:201});192 await assert.rejects(server.store([folderFile("private", "root/private.txt")]), /descriptor/);193});194195test("encrypted upload never touches the clipboard until Copy is pressed", async () => {196 const {window, FileUpload} = load(true);197 let copies = 0;198 navigator.clipboard.writeText = async () => { copies++; throw Error("denied"); };199 const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext"], "secret.txt", {type: "text/plain"}));200 assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext");201 assert.match(link, /\/file\/[0-9a-f]{64}#key=.*&iv=/);202 assert.equal(upload.querySelector("[data-share-url]").value, link);203 assert.equal(upload.querySelector("[data-share]").hidden, false);204 assert.equal(copies, 0);205 assert.equal(upload.querySelector("[data-open]").href, link);206 await upload.copyLink();207 assert.equal(copies, 1);208 assert.match(upload.out.textContent, /Copy the secret link from the field/);209});210211test("plain uploads store every chosen file and refresh the listing", async () => {212 const {window, FileUpload} = load(true);213 const sent = [];214 let refreshed = 0;215 window.tiny.signedFetch = async (path, method, body, options) => { sent.push({path, method, size: body.byteLength, type: options.contentType}); return {}; };216 window.tiny.navigate = async () => { refreshed++; };217 const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();218 const form = upload.querySelector("form");219 form.elements.namedItem("file").files = [new File(["one"], "a.txt", {type: "text/plain"}), new File(["two"], "b.bin")];220 assert.equal(await upload.run(form), undefined);221 assert.deepEqual(sent, [{path: "/upload?filename=a.txt&path=a.txt", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload?filename=b.bin&path=b.bin", method: "PUT", size: 3, type: "application/octet-stream"}]);222 assert.equal(refreshed, 1);223 assert.equal(upload.out.textContent, "Stored 2 files.");224});225226test("BUD15 key is rejected before mirror request", async () => {227 const {document, window, FileMirror} = load(); const mirror = new FileMirror(); document.append(mirror); mirror.connectedCallback();228 mirror.form.querySelector("input").value = "blossom:" + "a".repeat(64) + "?enc=chk-v1&k=" + "1".repeat(64);229 let sent = false; window.tiny.signedFetch = async () => { sent = true; };230 await assert.rejects(() => mirror.submit(mirror.form), /keep k client side/);231 assert.equal(sent, false);232});233234test("sharing a newly uploaded file uses its current key, hash and ciphertext size", async () => {235 const {document, window, FileTools, FileUpload} = load(true);236 const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["new secret"], "private.txt", {type: "text/plain"}));237 window.location.hash = new URL(link).hash;238 const element = new FileTools();239 element.setAttribute("hash", await window.tiny.sha256hex(uploaded()));240 element.setAttribute("type", "text/plain");241 element.setAttribute("size", String(uploaded().length));242 document.append(element); element.connectedCallback();243 const shareForm = element.querySelector("[data-share]");244 shareForm.querySelector("input").value = "b".repeat(64);245 globalThis.nostr = {signEvent: async value => value};246 let input;247 globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};248 await element.share(shareForm);249 assert.ok(input, element.output.textContent);250 assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded()));251 assert.equal(input.size, uploaded().length);252 assert.equal(input.plaintextHash, await window.tiny.sha256hex(new TextEncoder().encode("new secret")));253 assert.match(input.key, /^[0-9a-f]{64}$/);254 assert.match(input.nonce, /^[0-9a-f]{24}$/);255 const key = await webcrypto.subtle.importKey("raw", Buffer.from(input.key, "hex"), "AES-GCM", false, ["decrypt"]);256 const plain = await webcrypto.subtle.decrypt({name: "AES-GCM", iv: Buffer.from(input.nonce, "hex")}, key, uploaded());257 assert.equal(new TextDecoder().decode(plain), "new secret");258 assert.equal(new URL(input.fileURL).hash, "");259});260261test("HTTP mirror input never submits its secret fragment", async () => {262 const {document, window, FileMirror} = load();263 const mirror = new FileMirror(); document.append(mirror); mirror.connectedCallback();264 mirror.form.querySelector("input").value = "https://files.example/blob#key=secret";265 let body;266 window.tiny.signedFetch = async (_path, _method, value) => { body = value; return {json: async () => ({sha256: "a".repeat(64)})}; };267 await mirror.submit(mirror.form);268 assert.equal(JSON.parse(body).url, "https://files.example/blob");269});270271test("local downloads verify integrity, decrypt and release their temporary URL", async () => {272 const {document, window, FileTools, FileUpload} = load(true);273 const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["round trip"], "round.txt", {type: "text/plain"}));274 window.location.hash = new URL(link).hash;275 const element = new FileTools();276 element.setAttribute("hash", await window.tiny.sha256hex(uploaded()));277 document.append(element);278 const originalFetch = globalThis.fetch;279 try {280 globalThis.fetch = async () => new Response(uploaded());281 element.connectedCallback();282 await element.decryptFromFragment();283 assert.equal(element.output.textContent, "Decrypted locally.");284 assert.equal(await (await originalFetch(element.downloadURL)).text(), "round trip");285 const temporaryURL = element.downloadURL;286 element.disconnectedCallback();287 await assert.rejects(originalFetch(temporaryURL));288 uploaded()[0] ^= 1;289 await element.decryptFromFragment();290 assert.match(element.output.textContent, /ciphertext hash mismatch/);291 } finally { globalThis.fetch = originalFetch; }292});293294test("retry keeps the original ciphertext and key after interrupted encrypted upload", async () => {295 const {document, window, FileUpload} = load(true);296 window.tiny.localPath = path => path;297 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");298 const sent = [];299 globalThis.tiny.blossom.upload = {upload: async (bytes, options) => {300 assert.equal(options.url, "https://relay.test/");301 sent.push(bytes.slice());302 if (sent.length === 1) throw Error("connection interrupted");303 return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}};304 }};305 const upload = new FileUpload(); document.append(upload); upload.connectedCallback();306 const form = upload.querySelector("form");307 form.elements.namedItem("file").files = [new File(["keep the same key"], "private.txt")];308 form.elements.namedItem("encrypt").checked = true;309 await assert.rejects(upload.run(form), /connection interrupted/);310 const originalFragment = upload.pending.fragment;311 assert.equal(upload.querySelector("[data-retry]").disabled, false);312 const link = await upload.run();313 assert.deepEqual(sent[1], sent[0]);314 assert.equal(new URL(link).hash.slice(1), originalFragment);315 assert.equal(upload.pending, null);316});317318test("manifest retry keeps completed chunks and reports readable progress", async t => {319 const server = workspaceServer(t);320 const transport = globalThis.fetch;321 let interrupted = true;322 const attempts = new Map();323 globalThis.fetch = async (url, init = {}) => {324 if (init.method === "PATCH") {325 const hash = new URL(url).pathname.split("/").at(-1);326 attempts.set(hash, (attempts.get(hash) || 0) + 1);327 if (interrupted && attempts.size === 2) return new Response(null, {status: 403});328 }329 return transport(url, init);330 };331 const bytes = new Uint8Array(5 * 1024 * 1024);332 bytes[0] = 1; bytes[2 * 1024 * 1024] = 2; bytes[4 * 1024 * 1024] = 3;333 const upload = new server.FileUpload(); upload.connectedCallback();334 const form = upload.querySelector("form");335 form.elements.namedItem("file").files = [folderFile(bytes, "video/clip.mp4")];336 form.elements.namedItem("encrypt").checked = true;337 await assert.rejects(upload.run(form), /403/);338 const first = attempts.keys().next().value;339 assert.equal(upload.manifestSession.completed.size, 1);340 interrupted = false;341 const link = await upload.run();342 assert.equal(attempts.get(first), 1, "accepted first chunk must not be reuploaded");343 const root = await server.open(link);344 assert.deepEqual(await root.download(root.entries[0]), bytes);345 upload.progress(3 * 1024 * 1024, 5 * 1024 * 1024);346 assert.match(upload.out.textContent, /60%.*3.0 MiB.*5.0 MiB/);347 assert.equal(upload.querySelector("progress").value, 0.6);348 for (const request of server.requests.filter(r => r.init.method === "PATCH")) {349 assert.equal(request.url.searchParams.get("purpose"), "chunk");350 }351});352353test("members upload keeps the MP4 name and type and never creates a secret link", async () => {354 const {window, FileUpload} = load(true);355 const sent = [];356 window.tiny.signedFetch = async (path, method, bytes, options) => { sent.push({path, method, bytes, options}); };357 window.tiny.navigate = async () => {};358 const upload = new FileUpload(); upload.connectedCallback();359 const form = upload.querySelector("form");360 form.elements.namedItem("file").files = [new File(["mp4 bytes"], "holiday.mp4", {type: "video/mp4"})];361 form.elements.namedItem("access").value = "members";362 form.elements.namedItem("encrypt").checked = true;363 await upload.run(form);364 assert.equal(sent.length, 1);365 const url = new URL(sent[0].path, location.href);366 assert.equal(url.searchParams.get("access"), "members");367 assert.equal(url.searchParams.get("filename"), "holiday.mp4");368 assert.equal(sent[0].options.contentType, "video/mp4");369 assert.equal(new TextDecoder().decode(sent[0].bytes), "mp4 bytes");370 assert.equal(upload.querySelector("[data-share]").hidden, true);371});372373test("retry finalizes the same encrypted root without reuploading or exposing its key", async () => {374 const {window, FileUpload} = load(true);375 window.tiny.localPath = path => path;376 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");377 let uploads = 0, records = 0;378 window.tiny.blossom.upload = {upload: async (bytes, options) => {379 uploads++;380 assert.equal(options.metadata.purpose, "chunk");381 return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}};382 }};383 window.tiny.signedFetch = async (path, method, body) => {384 assert.equal(path, "/files/metadata");385 assert.equal(method, "POST");386 const record = JSON.parse(body);387 assert.match(record.name, /^Encrypted file [a-f0-9]{12}$/);388 assert.equal(record.purpose, "file");389 assert.equal(record.size, 7);390 assert.equal(body.includes("secret-name.txt"), false);391 assert.equal("key" in record, false);392 if (++records === 1) throw Error("server restarting");393 return {ok: true};394 };395 const upload = new FileUpload(); upload.connectedCallback();396 const form = upload.querySelector("form");397 form.elements.namedItem("file").files = [new File(["private"], "secret-name.txt")];398 form.elements.namedItem("encrypt").checked = true;399 await assert.rejects(upload.run(form), /server restarting/);400 const saved = upload.querySelector("[data-share-url]").value;401 assert.match(saved, /#key=/);402 assert.equal(upload.querySelector("[data-retry]").disabled, false);403 assert.equal(await upload.run(), saved);404 assert.equal(uploads, 1);405 assert.equal(records, 2);406});407408test("a 100 MiB encrypted MP4 opens a media player after reconstruction", async t => {409 const server = workspaceServer(t);410 const bytes = new Uint8Array(100 * 1024 * 1024);411 for (let index = 0; index < 50; index++) bytes[index * 2 * 1024 * 1024] = index;412 const link = await server.store([new File([bytes], "large.mp4", {type: "video/mp4"})]);413 const root = await server.open(link);414 const video = root.querySelector("video");415 assert.ok(video, "decrypted manifest MP4 must have a player");416 assert.equal(video.controls, true);417 const data = await server.originalFetch(video.src).then(response => response.arrayBuffer());418 assert.deepEqual(new Uint8Array(data), bytes);419 assert.match(root.querySelector("output").textContent, /100.0 MiB/);420});421422test("large uploads hand signed requests to Background Fetch and read the parked descriptor", async () => {423 const {window, FileUpload} = load(true);424 window.tiny.localPath = path => path;425 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");426 window.tiny.authorization = async (url, method, body) => "Nostr " + method + ":" + new URL(url).pathname + ":" + body.byteLength;427 window.tiny.navigate = async () => {};428 const bytes = new Uint8Array(9 * 1024 * 1024);429 bytes[0] = 7;430 const hash = await window.tiny.sha256hex(bytes);431 const descriptor = {sha256: hash, size: bytes.byteLength};432 let started;433 let signalStart;434 const didStart = new Promise(resolve => { signalStart = resolve; });435 const task = {result: "", uploaded: 0, uploadTotal: bytes.byteLength, listeners: [], addEventListener(name, fn) { this.listeners.push(fn); }, removeEventListener() {}};436 const parked = new Map();437 globalThis.caches = {open: async () => ({match: async key => parked.get(key), delete: async key => parked.delete(key), put: async (key, value) => parked.set(key, value)})};438 globalThis.BackgroundFetchManager = class {};439 const store = new Map();440 globalThis.localStorage = {getItem: key => store.get(key) ?? null, setItem: (key, value) => store.set(key, value), removeItem: key => store.delete(key), get length() { return store.size; }};441 Object.defineProperty(globalThis, "localStorage", {configurable: true, value: globalThis.localStorage});442 Object.keys = ((original => target => target === globalThis.localStorage ? [...store.keys()] : original(target)))(Object.keys);443 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("unavailable"); }}, serviceWorker: {controller: {}, ready: Promise.resolve({backgroundFetch: {fetch: async (id, requests, options) => { started = {id, requests, options}; signalStart(); return task; }, get: async () => null}})}}});444 const originalFetch = globalThis.fetch;445 globalThis.fetch = async (_url, init = {}) => new Response(null, {status: 204, headers: {Allow: "PATCH, PUT"}});446 try {447 const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();448 const form = upload.querySelector("form");449 form.elements.namedItem("file").files = [new File([bytes], "big.bin", {type: "application/octet-stream"})];450 form.elements.namedItem("background").checked = true;451 const run = upload.run(form);452 let timeout;453 try {454 await Promise.race([didStart, new Promise((_, reject) => {455 timeout = setTimeout(() => reject(new Error("background fetch was not started")), 5000);456 })]);457 } finally {458 clearTimeout(timeout);459 }460 assert.ok(started, "background fetch was not started");461 assert.equal(started.requests.length, 2);462 assert.equal(started.requests[0].method, "PATCH");463 assert.equal(new URL(started.requests[0].url).searchParams.get("filename"), "big.bin");464 assert.equal(new URL(started.requests[1].url).searchParams.get("path"), "big.bin");465 assert.equal(started.requests[1].headers.get("upload-offset"), String(5 * 1024 * 1024));466 assert.match(started.requests[0].headers.get("authorization"), /^Nostr PATCH:/);467 assert.equal(started.options.uploadTotal, bytes.byteLength);468 assert.equal(store.get("tiny.bg." + started.id) !== undefined, true);469 parked.set("https://relay.test/uploads/" + started.id, Response.json(descriptor));470 task.result = "success"; task.uploaded = bytes.byteLength;471 for (const fn of task.listeners) await fn();472 await run;473 assert.equal(store.has("tiny.bg." + started.id), false);474 assert.equal(parked.size, 0);475 assert.equal(upload.out.textContent, "Stored 1 file.");476 } finally { globalThis.fetch = originalFetch; }477});