1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools";6import { sharedSigning } from "./test-signing.mjs";78const root = "a".repeat(64), author = "b".repeat(64), parent = "c".repeat(64), parentAuthor = "d".repeat(64);9const coordinate = `30617:${author}:test`;10const source = fs.readFileSync("tinyclient/components.js", "utf8");11const composeSource = source.slice(source.indexOf(" class NostrCompose "), source.indexOf(" class NostrKey "));12function setup(attributes, values = {}, options = {}) {13 const sent = [], signed = [];14 let resets = 0;15 class FormElement {16 getAttribute(name) { return attributes[name] ?? null; }17 report(message) { this.message = message; }18 }19 const window = {NostrSigner: {verifyEvent}, nostr: {signEvent: async event => {20 signed.push(structuredClone(event));21 if (options.change) event.content = "changed";22 return finalizeEvent(structuredClone(event), generateSecretKey());23 }}};24 const tiny = {signedFetch: async (path, method, body) => {25 sent.push({path, method, event: JSON.parse(body)});26 return Response.json(options.result ?? {accepted: true});27 }};28 tiny.signing = sharedSigning(window, tiny);29 const Constructor = vm.runInNewContext(`${composeSource}\nNostrCompose`, {FormElement, window, tiny, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || "")});30 const component = new Constructor();31 const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}])), reset: () => { resets++; }};32 return {component, form, sent, signed, resets: () => resets};33}34const rootAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1621"};3536test("issue composition signs subject, deduplicated labels and body", async () => {37 const s = setup({kind: "1621", coordinate}, {title: " Bug ", labels: "ui, bug, ui", content: "Markdown **body**"});38 await s.component.submit(s.form);39 assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["p", author], ["subject", "Bug"], ["t", "ui"], ["t", "bug"]]);40 assert.equal(s.sent[0].path, "/events");41 assert.equal(s.sent[0].event.kind, 1621);42 assert.equal(verifyEvent(s.sent[0].event), true);43 assert.equal(s.resets(), 1);44});4546test("nested NIP-22 replies preserve the root and address the immediate parent", async () => {47 const s = setup({...rootAttrs, kind: "1111", parent, "parent-pubkey": parentAuthor, "parent-kind": "1111"}, {content: "A reply"});48 await s.component.submit(s.form);49 assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["E", root, "", author], ["K", "1621"], ["P", author], ["e", parent, "", parentAuthor], ["k", "1111"], ["p", parentAuthor]]);50});5152test("review comments carry file and line tags from hidden inputs or attributes", async () => {53 const pullAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1618"};54 const fromInputs = setup({...pullAttrs, kind: "1111"}, {content: "Prefer two", file: "README", line: "2", side: "new"});55 await fromInputs.component.submit(fromInputs.form);56 assert.deepEqual(fromInputs.sent[0].event.tags.slice(-2), [["file", "README"], ["line", "2", "new"]]);57 const fromAttributes = setup({...pullAttrs, kind: "1111", file: "src/app.js", line: "14", side: "old"}, {content: "Keep this"});58 await fromAttributes.component.submit(fromAttributes.form);59 assert.deepEqual(fromAttributes.sent[0].event.tags.slice(-2), [["file", "src/app.js"], ["line", "14", "old"]]);60 const plain = setup({...pullAttrs, kind: "1111"}, {content: "General note"});61 await plain.component.submit(plain.form);62 assert.equal(plain.sent[0].event.tags.some(tag => tag[0] === "file" || tag[0] === "line"), false);63});6465test("incomplete or misplaced line references are rejected before signing", async () => {66 const pullAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1618"};67 for (const [attrs, values] of [68 [{...pullAttrs, kind: "1111"}, {content: "x", file: "README", line: "0", side: "new"}],69 [{...pullAttrs, kind: "1111"}, {content: "x", file: "README", line: "2", side: "left"}],70 [{...pullAttrs, kind: "1111"}, {content: "x", line: "2", side: "new"}],71 [{...pullAttrs, kind: "1111"}, {content: "x", file: "README"}],72 [{...rootAttrs, kind: "1111"}, {content: "x", file: "README", line: "2", side: "new"}]73 ]) {74 const s = setup(attrs, values);75 await assert.rejects(s.component.submit(s.form), /line|pull request/);76 assert.equal(s.signed.length, 0);77 assert.equal(s.sent.length, 0);78 }79});8081test("status selector signs a NIP-34 root marker with an optional empty body", async () => {82 const s = setup({...rootAttrs, kind: "status"}, {status: "1632", content: ""});83 await s.component.submit(s.form);84 assert.equal(s.sent[0].event.kind, 1632);85 assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["e", root, "", "root"], ["p", author]]);86});8788test("PRs publish the commit, clone and merge-base tags", async () => {89 const commit = "1".repeat(40), base = "2".repeat(40);90 const s = setup({kind: "1618", coordinate}, {title: "Change", commit, "merge-base": base, clone: "https://example.test/project.git", content: "Why"});91 await s.component.submit(s.form);92 assert.deepEqual(s.sent[0].event.tags.slice(-3), [["c", commit], ["clone", "https://example.test/project.git"], ["merge-base", base]]);93});9495test("relay rejection and signer substitution retain the draft", async () => {96 for (const options of [{result: {accepted: false, message: "blocked"}}, {change: true}]) {97 const s = setup({kind: "1621", coordinate}, {title: "Bug", content: "Details"}, options);98 await assert.rejects(s.component.submit(s.form), /blocked|changed/);99 assert.equal(s.resets(), 0);100 if (options.change) assert.equal(s.sent.length, 0);101 }102});103104test("invalid conversation addresses and PR commit IDs are rejected before signing", async () => {105 for (const [attrs, values] of [[{...rootAttrs, kind: "1111", parent: "bad"}, {content: "Reply"}], [{kind: "1618", coordinate}, {title: "PR", commit: "main", clone: "https://example.test/repo"}]]) {106 const s = setup(attrs, values);107 await assert.rejects(s.component.submit(s.form));108 assert.equal(s.signed.length, 0);109 assert.equal(s.sent.length, 0);110 }111});