clone

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

feat/standalone-relay
tinyrelay / scripts / chat-files.test.mjs
chat-files.test.mjs 4.1K
1import assert from "node:assert/strict";2import {webcrypto} from "node:crypto";3import {readFile} from "node:fs/promises";4import test from "node:test";5import vm from "node:vm";67const source = await readFile(new URL("../tinyclient/chat-files.js", import.meta.url), "utf8");8const hex = value => Buffer.from(value).toString("hex");9let DirectFile;10const revoked = [];11const sandbox = {12 crypto: webcrypto, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, URL, Blob,13 location: {origin: "https://relay.test"}, fetch: async () => { throw Error("unexpected fetch"); },14 HTMLElement: class {}, customElements: {get: () => undefined, define(_name, ctor) { DirectFile = ctor; }},15 document: {createElement: tag => ({localName: tag, append() {}, replaceChildren() {}, addEventListener() {}})}16};17sandbox.globalThis = sandbox;18sandbox.tiny = {util: {19 bytes: value => value instanceof Uint8Array ? value : new Uint8Array(value),20 hex, sha256: async value => new Uint8Array(await webcrypto.subtle.digest("SHA-256", value))21}};22vm.runInNewContext(source, sandbox);23const api = sandbox.tiny.chat.files;2425const makeFile = async (plain = new TextEncoder().encode("hello")) => {26 const key = webcrypto.getRandomValues(new Uint8Array(32));27 const nonce = webcrypto.getRandomValues(new Uint8Array(12));28 const cryptoKey = await webcrypto.subtle.importKey("raw", key, {name: "AES-GCM"}, false, ["encrypt"]);29 const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({name: "AES-GCM", iv: nonce}, cryptoKey, plain));30 const digest = value => webcrypto.subtle.digest("SHA-256", value).then(hex);31 return {plain, encrypted, key, nonce, hash: await digest(encrypted), plaintextHash: await digest(plain)};32};3334test("parses interoperable NIP-17 kind 15 tags without exposing key in the node", async () => {35 const file = await makeFile();36 const rumor = {kind: 15, content: "https://cdn.example/file.mp4", tags: [37 ["p", "a".repeat(64)], ["file-type", "video/mp4"], ["encryption-algorithm", "aes-gcm"],38 ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash], ["size", String(file.encrypted.length)]39 ]};40 const parsed = api.parse(rumor);41 assert.equal(parsed.mime, "video/mp4");42 assert.equal(parsed.hash, file.hash);43 assert.deepEqual(await api.decrypt(parsed, file.encrypted), file.plain);44 const node = api.render(rumor);45 assert.equal(node.rumor, rumor);46});4748test("rejects credentialed, non-HTTP and malformed attachment URLs", () => {49 const base = {kind: 15, tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", "a".repeat(64)], ["decryption-nonce", "b".repeat(24)], ["x", "c".repeat(64)]]};50 for (const content of ["data:text/plain,secret", "javascript:alert(1)", "https://user:pass@example.test/a"]) {51 assert.throws(() => api.parse({...base, content}), /URL/);52 }53 assert.throws(() => api.parse({...base, content: "https://example.test/a", tags: base.tags.map(tag => tag[0] === "x" ? ["x", "bad"] : tag)}), /hash/);54});5556test("checks ciphertext and plaintext hashes and authenticated decryption", async () => {57 const file = await makeFile();58 const rumor = {kind: 15, content: "https://cdn.example/file.txt", tags: [["encryption-algorithm", "aes-gcm"], ["decryption-key", hex(file.key)], ["decryption-nonce", hex(file.nonce)], ["x", file.hash], ["ox", file.plaintextHash]]};59 const parsed = api.parse(rumor);60 const altered = new Uint8Array(file.encrypted); altered[0] ^= 1;61 await assert.rejects(api.decrypt(parsed, altered), /hash/);62 const wrongKey = {...parsed, key: new Uint8Array(32).fill(7)};63 await assert.rejects(api.decrypt(wrongKey, file.encrypted), /decryption failed|plaintext hash mismatch/);64});6566test("revokes attachment object URLs when its direct-file element disconnects", () => {67 const oldRevoke = sandbox.URL.revokeObjectURL;68 sandbox.URL.revokeObjectURL = value => revoked.push(value);69 const node = new DirectFile();70 node._attachmentURLs = new Set(["blob:attachment-1", "blob:attachment-2"]);71 node.disconnectedCallback();72 assert.deepEqual(revoked, ["blob:attachment-1", "blob:attachment-2"]);73 sandbox.URL.revokeObjectURL = oldRevoke;74});