| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools" ; |
| 6 | import { sharedSigning } from "./test-signing.mjs" ; |
| 7 | |
| 8 | const root = "a" .repeat(64), author = "b" .repeat(64), parent = "c" .repeat(64), parentAuthor = "d" .repeat(64); |
| 9 | const coordinate = `30617:${author}:test` ; |
| 10 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 11 | const composeSource = source.slice(source.indexOf(" class NostrCompose " ), source.indexOf(" class NostrKey " )); |
| 12 | function 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 | } |
| 34 | const rootAttrs = {coordinate, root, "root-pubkey" : author, "root-kind" : "1621" }; |
| 35 | |
| 36 | test("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 | }); |
| 45 | |
| 46 | test("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 | }); |
| 51 | |
| 52 | test("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 | }); |
| 64 | |
| 65 | test("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 | }); |
| 80 | |
| 81 | test("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 | }); |
| 87 | |
| 88 | test("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 | }); |
| 94 | |
| 95 | test("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 | }); |
| 103 | |
| 104 | test("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 | }); |