| 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, getPublicKey, verifyEvent } from "nostr-tools" ; |
| 6 | import { sharedSigning } from "./test-signing.mjs" ; |
| 7 | |
| 8 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 9 | const tinySource = fs.readFileSync("tinyclient/tiny.js" , "utf8" ); |
| 10 | const wikiSource = source.slice(source.indexOf(" const publishSigned = " ), source.indexOf(" // ApprovalItem reacts" )); |
| 11 | const isHex64 = value => /^[0-9a-f]{64}$/.test(value || "" ); |
| 12 | |
| 13 | const util = (() => { |
| 14 | const sandbox = {TextEncoder, TextDecoder, URL, crypto: {subtle: {}}, document: {}}; |
| 15 | sandbox.globalThis = sandbox; |
| 16 | vm.runInNewContext(tinySource, sandbox); |
| 17 | return sandbox.tiny.util; |
| 18 | })(); |
| 19 | |
| 20 | test("wikiName follows the relay's NIP-54 normalization" , () => { |
| 21 | for (const [title, name] of [ |
| 22 | ["What's Up?" , "whats-up" ], |
| 23 | ["日本語 Article" , "日本語-article" ], |
| 24 | ["Москва" , "москва" ], |
| 25 | [" --Hello__World-- " , "hello-world" ], |
| 26 | ["Release notes 1.4" , "release-notes-14" ], |
| 27 | ["Files & Private Repositories" , "files-private-repositories" ], |
| 28 | ["café au lait" , "café-au-lait" ], |
| 29 | ["***" , "" ], |
| 30 | ["" , "" ] |
| 31 | ]) { |
| 32 | assert.equal(util.wikiName(title), name, title); |
| 33 | } |
| 34 | assert.equal(util.wikiName(undefined), "" ); |
| 35 | }); |
| 36 | |
| 37 | // setup runs one element class from components.js against fakes for the |
| 38 | // signer, the relay and the form, the way collaboration-compose.test does. |
| 39 | function setup(name, attributes, values = {}, options = {}) { |
| 40 | const secret = generateSecretKey(), pubkey = getPublicKey(secret); |
| 41 | const sent = [], signed = []; |
| 42 | class FormElement { |
| 43 | getAttribute(key) { return attributes[key] ?? null; } |
| 44 | report(message) { this.message = message; } |
| 45 | } |
| 46 | const window = {NostrSigner: {verifyEvent}, nostr: { |
| 47 | getPublicKey: async () => pubkey, |
| 48 | signEvent: async event => { |
| 49 | signed.push(structuredClone(event)); |
| 50 | if (options.change) event.content = "changed" ; |
| 51 | return finalizeEvent(structuredClone(event), secret); |
| 52 | } |
| 53 | }}; |
| 54 | const tiny = {util, navigated: [], signedFetch: async (path, method, body) => { |
| 55 | sent.push({path, method, event: JSON.parse(body)}); |
| 56 | return Response.json(options.results?.[sent.length - 1] ?? options.result ?? {accepted: true}); |
| 57 | }, navigate: async href => { tiny.navigated.push(href); }}; |
| 58 | tiny.signing = sharedSigning(window, tiny); |
| 59 | const Constructor = vm.runInNewContext(`${wikiSource}\n${name}` , {FormElement, window, tiny, URL, isHex64, Date, JSON, encodeURIComponent}); |
| 60 | const component = new Constructor(); |
| 61 | component.submitter = options.submitter ? {value: options.submitter, textContent: options.label || "" } : null; |
| 62 | const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}]))}; |
| 63 | return {component, form, sent, signed, tiny, pubkey}; |
| 64 | } |
| 65 | |
| 66 | const author = "a" .repeat(64), base = "b" .repeat(64), mergeID = "c" .repeat(64); |
| 67 | const coordinate = `30818:${author}:release-notes-1-4` ; |
| 68 | const page = {name: "release-notes-1-4" , author, coordinate, event: base}; |
| 69 | |
| 70 | test("a new page signs d, title and summary and opens the page" , async () => { |
| 71 | const s = setup("WikiCompose" , {name: "" , author: "" , coordinate: "" , event: "" }, {title: "What's Up?" , name: "" , summary: "A greeting" , content: "# Hi\n\nHello." }); |
| 72 | await s.component.submit(s.form); |
| 73 | assert.equal(s.sent.length, 1); |
| 74 | const event = s.sent[0].event; |
| 75 | assert.equal(s.sent[0].path, "/events" ); |
| 76 | assert.equal(event.kind, 30818); |
| 77 | assert.deepEqual(event.tags, [["d" , "whats-up" ], ["title" , "What's Up?" ], ["summary" , "A greeting" ]]); |
| 78 | assert.equal(event.content, "# Hi\n\nHello." ); |
| 79 | assert.equal(verifyEvent(event), true); |
| 80 | assert.equal(s.component.message, "Published." ); |
| 81 | assert.deepEqual(s.tiny.navigated, ["/wiki/whats-up" ]); |
| 82 | }); |
| 83 | |
| 84 | test("the name field wins over the title and is normalized" , async () => { |
| 85 | const s = setup("WikiCompose" , {name: "" , author: "" , coordinate: "" , event: "" }, {title: "Bitcoin" , name: " BTC Coin " , content: "Money." }); |
| 86 | await s.component.submit(s.form); |
| 87 | assert.deepEqual(s.sent[0].event.tags, [["d" , "btc-coin" ], ["title" , "Bitcoin" ]]); |
| 88 | }); |
| 89 | |
| 90 | test("another signer publishes a fork of the version in view" , async () => { |
| 91 | const s = setup("WikiCompose" , page, {title: "Release notes 1.4" , name: "release-notes-1-4" , summary: "" , content: "Rewritten." }); |
| 92 | await s.component.submit(s.form); |
| 93 | assert.equal(s.sent.length, 1); |
| 94 | assert.deepEqual(s.sent[0].event.tags, [["d" , "release-notes-1-4" ], ["title" , "Release notes 1.4" ], ["a" , coordinate, "" , "fork" ], ["e" , base, "" , "fork" ]]); |
| 95 | assert.notEqual(s.sent[0].event.pubkey, author); |
| 96 | }); |
| 97 | |
| 98 | test("propose publishes the fork and then a merge request to the author" , async () => { |
| 99 | const s = setup("WikiCompose" , page, {title: "Release notes 1.4" , name: "release-notes-1-4" , summary: "Fix the upload section" , content: "Rewritten." }, {submitter: "propose" }); |
| 100 | await s.component.submit(s.form); |
| 101 | assert.equal(s.sent.length, 2); |
| 102 | const [version, request] = s.sent.map(item => item.event); |
| 103 | assert.equal(version.kind, 30818); |
| 104 | assert.equal(request.kind, 818); |
| 105 | assert.deepEqual(request.tags, [["a" , coordinate], ["e" , version.id, "" , "source" ], ["e" , base], ["p" , author]]); |
| 106 | assert.equal(request.content, "Fix the upload section" ); |
| 107 | assert.equal(verifyEvent(request), true); |
| 108 | assert.match(s.component.message, /proposed to aaaaaaaaaaaa/); |
| 109 | assert.deepEqual(s.tiny.navigated, ["/wiki/release-notes-1-4" ]); |
| 110 | }); |
| 111 | |
| 112 | test("the author's own edit carries no fork tags and cannot be proposed" , async () => { |
| 113 | const s = setup("WikiCompose" , page, {title: "Release notes 1.4" , name: "release-notes-1-4" , content: "Edited." }); |
| 114 | s.component.getAttribute = key => ({...page, author: s.pubkey, coordinate: `30818:${s.pubkey}:release-notes-1-4` })[key] ?? null; |
| 115 | await s.component.submit(s.form); |
| 116 | assert.deepEqual(s.sent[0].event.tags, [["d" , "release-notes-1-4" ], ["title" , "Release notes 1.4" ]]); |
| 117 | const own = setup("WikiCompose" , page, {title: "Release notes 1.4" , name: "release-notes-1-4" , content: "Edited." }, {submitter: "propose" }); |
| 118 | own.component.getAttribute = key => ({...page, author: own.pubkey})[key] ?? null; |
| 119 | await assert.rejects(own.component.submit(own.form), /your own version/); |
| 120 | assert.equal(own.sent.length, 0); |
| 121 | }); |
| 122 | |
| 123 | test("missing fields, a changed signature and a relay refusal stop before or after one event" , async () => { |
| 124 | for (const [values, message] of [[{title: "" , name: "" , content: "x" }, /title/], [{title: "***" , name: "" , content: "x" }, /letter or digit/], [{title: "T" , name: "" , content: " " }, /content/]]) { |
| 125 | const s = setup("WikiCompose" , {name: "" , author: "" , coordinate: "" , event: "" }, values); |
| 126 | await assert.rejects(s.component.submit(s.form), message); |
| 127 | assert.equal(s.signed.length, 0); |
| 128 | } |
| 129 | const changed = setup("WikiCompose" , page, {title: "T" , name: "t" , content: "x" }, {change: true}); |
| 130 | await assert.rejects(changed.component.submit(changed.form), /changed/); |
| 131 | assert.equal(changed.sent.length, 0); |
| 132 | const refused = setup("WikiCompose" , page, {title: "T" , name: "t" , content: "x" }, {submitter: "propose" , results: [{accepted: false, message: "blocked" }]}); |
| 133 | await assert.rejects(refused.component.submit(refused.form), /blocked/); |
| 134 | assert.equal(refused.sent.length, 1); |
| 135 | assert.deepEqual(refused.tiny.navigated, []); |
| 136 | }); |
| 137 | |
| 138 | test("nostr-react signs the reaction the pressed button carries" , async () => { |
| 139 | for (const [reaction, message] of [["+" , "Accepted." ], ["-" , "Rejected." ]]) { |
| 140 | const s = setup("NostrReact" , {event: mergeID, pubkey: author, kind: "818" }, {}, {submitter: reaction, label: reaction === "+" ? "Accept" : "Reject" }); |
| 141 | await s.component.submit(s.form); |
| 142 | const event = s.sent[0].event; |
| 143 | assert.equal(event.kind, 7); |
| 144 | assert.equal(event.content, reaction); |
| 145 | assert.deepEqual(event.tags, [["e" , mergeID, "" , author], ["p" , author], ["k" , "818" ]]); |
| 146 | assert.equal(verifyEvent(event), true); |
| 147 | assert.equal(s.component.message, message); |
| 148 | } |
| 149 | const plain = setup("NostrReact" , {event: mergeID, pubkey: author}, {}, {submitter: "+" }); |
| 150 | await plain.component.submit(plain.form); |
| 151 | assert.deepEqual(plain.sent[0].event.tags, [["e" , mergeID, "" , author], ["p" , author]]); |
| 152 | }); |
| 153 | |
| 154 | test("nostr-react refuses unknown reactions and bad addresses before signing" , async () => { |
| 155 | for (const [attributes, submitter, message] of [ |
| 156 | [{event: mergeID, pubkey: author}, "meh" , /Approve or Deny/], |
| 157 | [{event: "short" , pubkey: author}, "+" , /missing/], |
| 158 | [{event: mergeID, pubkey: "" }, "-" , /missing/] |
| 159 | ]) { |
| 160 | const s = setup("NostrReact" , attributes, {}, {submitter}); |
| 161 | await assert.rejects(s.component.submit(s.form), message); |
| 162 | assert.equal(s.signed.length, 0); |
| 163 | assert.equal(s.sent.length, 0); |
| 164 | } |
| 165 | }); |