clone

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

feat/tinyagent
tinyrelay / scripts / wiki-compose.test.mjs
wiki-compose.test.mjs 8.6K
1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools";6import { sharedSigning } from "./test-signing.mjs";78const source = fs.readFileSync("tinyclient/components.js", "utf8");9const tinySource = fs.readFileSync("tinyclient/tiny.js", "utf8");10const wikiSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts"));11const isHex64 = value => /^[0-9a-f]{64}$/.test(value || "");1213const 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})();1920test("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});3637// setup runs one element class from components.js against fakes for the38// signer, the relay and the form, the way collaboration-compose.test does.39function 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}6566const author = "a".repeat(64), base = "b".repeat(64), mergeID = "c".repeat(64);67const coordinate = `30818:${author}:release-notes-1-4`;68const page = {name: "release-notes-1-4", author, coordinate, event: base};6970test("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});8384test("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});8990test("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});9798test("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});111112test("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});122123test("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});137138test("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});153154test("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});