clone

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

main
tinyrelay / scripts / approvals.test.mjs
approvals.test.mjs 15.3K
1// Approvals in the browser: the nostr-react element signs the answer the2// person chose, and the service worker routes a notification action to the3// page that asks for confirmation. Both run in a sandbox with a fake DOM.4import test from "node:test";5import assert from "node:assert/strict";6import fs from "node:fs";7import vm from "node:vm";8import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools";9import { sharedSigning } from "./test-signing.mjs";1011const request = "a".repeat(64), asker = "b".repeat(64);12const source = fs.readFileSync("tinyclient/components.js", "utf8");13const reactSource = source.slice(source.indexOf(" const publishSigned = "), source.indexOf(" // ApprovalItem reacts"));1415// node builds enough of an element for the confirmation prompt: text,16// listeners, children and focus.17const node = (name, text) => ({18 name, textContent: text ?? "", children: [], handlers: {}, disabled: false, focused: false, dataset: {},19 addEventListener(event, fn) { this.handlers[event] = fn; },20 replaceChildren(...items) { this.children = items; },21 focus() { this.focused = true; },22 click() { return this.handlers.click?.(); }23});2425function setup(attributes, options = {}) {26 const sent = [], signed = [], listeners = {};27 let navigated = null;28 class FormElement {29 constructor() { this.form = {buttons: []}; this.output = node("output"); this.reports = []; }30 getAttribute(name) { return attributes[name] ?? null; }31 report(message, error) { this.reports.push(message); this.output.textContent = message; if (error) this.output.dataset.error = ""; }32 busy() {}33 }34 const window = {NostrSigner: {verifyEvent}, nostr: options.noSigner ? undefined : {signEvent: async event => {35 signed.push(structuredClone(event));36 if (options.change) event.content = "changed";37 return finalizeEvent(structuredClone(event), generateSecretKey());38 }}};39 const tiny = {40 signedFetch: async (path, method, body) => { sent.push({path, method, event: JSON.parse(body)}); return Response.json(options.result ?? {accepted: true}); },41 navigate: async href => { navigated = href; }42 };43 tiny.signing = sharedSigning(window, tiny);44 const document = {addEventListener(event, fn) { listeners[event] = fn; }};45 const Constructor = vm.runInNewContext(`${reactSource}\nNostrReact`, {FormElement, window, tiny, document, el: node, isHex64: value => /^[0-9a-f]{64}$/.test(value || ""), globalThis: {location: {href: "https://tiny.example/approvals?id=" + request + "&answer=approve"}}, Date, JSON, Math, Object, Response});46 const component = new Constructor();47 return {component, sent, signed, listeners, navigated: () => navigated};48}49const attrs = {event: request, pubkey: asker, kind: "1111"};5051test("approve and deny sign a kind 7 reaction naming the request and its asker", async () => {52 for (const [choice, word] of [["+", "Approved."], ["-", "Denied."]]) {53 const s = setup(attrs);54 await s.component.submit(s.component.form, choice);55 assert.equal(s.sent.length, 1);56 assert.equal(s.sent[0].path, "/events");57 assert.equal(s.sent[0].method, "POST");58 const event = s.sent[0].event;59 assert.equal(event.kind, 7);60 assert.equal(event.content, choice);61 assert.deepEqual(event.tags, [["e", request, "", asker], ["p", asker], ["k", "1111"]]);62 assert.equal(verifyEvent(event), true);63 assert.equal(s.component.reports.at(-1), word);64 assert.equal(s.navigated(), "https://tiny.example/approvals");65 }66});6768test("the pressed button chooses the answer and nothing is signed without a choice", async () => {69 const s = setup(attrs);70 await assert.rejects(s.component.submit(s.component.form), /Choose Approve or Deny/);71 s.component.choice = "-";72 await s.component.submit(s.component.form);73 assert.equal(s.sent[0].event.content, "-");74 assert.equal(s.component.choice, null);75});7677test("a missing address, a changed event or a relay refusal signs or publishes nothing", async () => {78 const missing = setup({event: request});79 await assert.rejects(missing.component.submit(missing.component.form, "+"), /request address/);80 assert.equal(missing.signed.length, 0);81 const changed = setup(attrs, {change: true});82 await assert.rejects(changed.component.submit(changed.component.form, "+"), /changed/);83 assert.equal(changed.sent.length, 0);84 const refused = setup(attrs, {result: {accepted: false, message: "blocked"}});85 await assert.rejects(refused.component.submit(refused.component.form, "+"), /blocked/);86 assert.equal(refused.navigated(), null);87 const unsigned = setup(attrs, {noSigner: true});88 await assert.rejects(unsigned.component.submit(unsigned.component.form, "+"), /Connect a signer/);89});9091test("the confirmation prompt signs only after Confirm and waits for a signer", async () => {92 const s = setup(attrs);93 s.component.prompt("approve");94 assert.equal(s.signed.length, 0);95 const [question, confirm, , cancel] = s.component.output.children;96 assert.equal(question.textContent, "Approve this request? ");97 assert.equal(confirm.textContent, "Confirm");98 assert.equal(confirm.focused, true);99 cancel.click();100 assert.equal(s.component.output.textContent, "");101 s.component.prompt("deny");102 await s.component.output.children[1].click();103 await new Promise(resolve => setTimeout(resolve, 20));104 assert.equal(s.sent.length, 1);105 assert.equal(s.sent[0].event.content, "-");106 const unknown = setup(attrs);107 unknown.component.prompt("reply");108 assert.equal(unknown.component.output.children.length, 0);109 const waiting = setup(attrs, {noSigner: true});110 waiting.component.prompt("approve");111 assert.match(waiting.component.output.textContent, /Connect a signer/);112 assert.equal(typeof waiting.listeners["tiny:signer"], "function");113});114115// The service worker runs against a fake self with a registration and a116// client list, so the click handler's window choice is observable.117const workerSource = fs.readFileSync("tinyclient/sw.js", "utf8");118function worker({windows = []} = {}) {119 const handlers = {}, shown = [], opened = [];120 const self = {121 addEventListener(name, fn) { handlers[name] = fn; },122 skipWaiting() {},123 registration: {scope: "https://tiny.example/", showNotification: async (title, options) => { shown.push({title, options}); }},124 clients: {claim() {}, matchAll: async () => windows, openWindow: async url => { opened.push(url); return {focus() {}}; }}125 };126 vm.runInNewContext(workerSource, {self, navigator: {}, location: new URL("https://tiny.example/"), URL, caches: {keys: async () => []}, console});127 return {handlers, shown, opened};128}129const waitUntil = () => { let done; const event = {waitUntil(promise) { done = promise; }}; return {event, finished: () => done}; };130131test("push renders the relay's actions on the notification", async () => {132 const w = worker();133 const {event, finished} = waitUntil();134 event.data = {json: () => ({title: "tiny", body: "hermes asks: Publish?", url: "https://tiny.example/approvals/" + request, tag: "tiny-approvals", badge: 1, actions: [{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}, {action: "bad action"}]})};135 w.handlers.push(event);136 await finished();137 assert.equal(w.shown.length, 1);138 assert.equal(JSON.stringify(w.shown[0].options.actions), JSON.stringify([{action: "approve", title: "Approve"}, {action: "deny", title: "Deny"}, {action: "reply", title: "Reply"}]));139 assert.equal(w.shown[0].options.data.url, "https://tiny.example/approvals/" + request);140 const plain = worker();141 const second = waitUntil();142 second.event.data = {json: () => ({title: "tiny", body: "Replied to you"})};143 plain.handlers.push(second.event);144 await second.finished();145 assert.equal(plain.shown[0].options.actions.length, 0);146});147148test("a notification action opens the answer in the existing window, or a new one", async () => {149 const url = "https://tiny.example/approvals/" + request;150 const navigated = [];151 const open = {url: "https://tiny.example/inbox", navigate: async target => { navigated.push(target); return {focus() { navigated.push("focused"); }}; }};152 const existing = worker({windows: [open]});153 for (const [action, expected] of [["approve", url + "?answer=approve"], ["deny", url + "?answer=deny"], ["reply", url + "?answer=reply"], ["", url], [undefined, url]]) {154 const {event, finished} = waitUntil();155 event.action = action;156 event.notification = {close() {}, data: {url}};157 existing.handlers.notificationclick(event);158 await finished();159 assert.equal(navigated.at(-2), expected);160 assert.equal(navigated.at(-1), "focused");161 }162 assert.equal(existing.opened.length, 0);163 const fresh = worker();164 const {event, finished} = waitUntil();165 event.action = "approve";166 event.notification = {close() {}, data: {url: "https://tiny.example/approvals"}};167 fresh.handlers.notificationclick(event);168 await finished();169 assert.deepEqual(fresh.opened, ["https://tiny.example/approvals?answer=approve"]);170 const odd = waitUntil();171 odd.event.action = "../x";172 odd.event.notification = {close() {}, data: {url}};173 fresh.handlers.notificationclick(odd.event);174 await odd.finished();175 assert.equal(fresh.opened.at(-1), url);176});177178// A Tiny agent interaction on the Approvals page is answered by the179// chat-decision element from chat-activity.js. Its host there is an inert180// chat-activity whose endpoint is the page itself: read() re-fetches181// /approvals to confirm the request still waits for this account, and without182// a Fixi action the host neither polls nor swaps, so refresh() returns at once.183const activitySource = fs.readFileSync("tinyclient/chat-activity.js", "utf8");184const owner = "c".repeat(64);185class Element {186 constructor(tag = "div") { this.localName = tag; this.childNodes = []; this.attributes = {}; this.parent = null; this.listeners = {}; }187 get isConnected() { return Boolean(this.parent); }188 append(...items) { for (const item of items) { if (item instanceof Element) item.parent = this; this.childNodes.push(item); } }189 replaceChildren(...items) { this.childNodes = []; this.append(...items); }190 setAttribute(name, value) { this.attributes[name] = String(value); }191 getAttribute(name) { return this.attributes[name] ?? null; }192 hasAttribute(name) { return Object.hasOwn(this.attributes, name); }193 querySelectorAll() { return []; }194 contains(node) { return node === this || this.childNodes.some(child => child instanceof Element && child.contains(node)); }195 closest(selector) { return selector === this.localName ? this : this.parent?.closest(selector) || null; }196 dispatchEvent(event) { this.listeners[event.type]?.(event); return true; }197 addEventListener(name, fn) { this.listeners[name] = fn; }198 removeEventListener(name) { delete this.listeners[name]; }199}200201// approvalsPage renders the host and decision as the approvals template does202// for one native request, with the page fetch answered by `listing`: the203// chat-decision elements the re-read page still carries.204function approvalsPage({selection = "single", interaction = "approval", freeform = false, listing = null} = {}) {205 const document = new Element("document"); document.hidden = false; document.activeElement = null;206 const definitions = {}, fetched = [], published = [];207 const signer = {getPublicKey: async () => owner};208 const tiny = {signer: () => signer, localPath: path => "/r/team" + path, signing: {publish: async event => { published.push(event); return event; }},209 ui: {FormElement: class extends Element { report(message) { this.message = message; } connectedCallback() { this.form = {}; } }}};210 const template = new Element("template");211 document.createElement = tag => tag === "template" ? template : new Element(tag);212 const sandbox = {document, HTMLElement: Element, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny,213 location: {href: "https://tiny.example/r/team/approvals", origin: "https://tiny.example"}, URL, AbortController, setInterval: () => 1, clearInterval() {}, setTimeout, clearTimeout, queueMicrotask,214 CustomEvent: class { constructor(type, options) { this.type = type; Object.assign(this, options); } }, morph() {},215 fetch: async (url, options) => { fetched.push({url: String(url), options}); return {ok: true, text: async () => "<html><body><section id=approvals></section></body></html>"}; }, Date, JSON, Error};216 vm.runInNewContext(activitySource, sandbox);217 const host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;218 host.setAttribute("endpoint", "/approvals"); host.setAttribute("room", "build"); host.setAttribute("actor", owner);219 const attributes = [["event", request], ["pubkey", asker], ["kind", "9"], ["room", "build"], ["actor", owner], ["expires", "0"], ["interaction", interaction], ["selection", selection], ["native", ""]];220 if (freeform) attributes.push(["freeform", ""]);221 if (interaction === "question") attributes.push(["question", ""]);222 if (selection === "multiple") attributes.push(["multiple", ""]);223 const decision = new (definitions["chat-decision"])(); decision.localName = "chat-decision";224 const fresh = new Element("chat-decision");225 for (const [name, value] of attributes) { decision.setAttribute(name, value); fresh.setAttribute(name, value); }226 fresh.querySelectorAll = () => selection === "text" ? [] : [{value: "yes"}, {value: "no"}];227 host.append(decision); host.connectedCallback();228 template.content = {querySelectorAll: name => name === "chat-decision" ? (listing ?? [fresh]) : []};229 return {host, decision, fetched, published};230}231232test("a native answer from the Approvals page re-reads the page and publishes the room-tagged NIP-22 answer", async () => {233 const page = approvalsPage();234 await page.decision.submit({elements: {option: {value: "yes", checked: true}}});235 assert.equal(page.fetched.length, 1);236 assert.equal(page.fetched[0].url, "https://tiny.example/r/team/approvals");237 assert.equal(page.fetched[0].options.credentials, "same-origin");238 assert.equal(page.fetched[0].options.cache, "no-store");239 assert.equal(page.published.length, 1);240 const event = page.published[0];241 assert.equal(event.kind, 1111);242 assert.equal(event.content, "yes");243 assert.deepEqual(JSON.parse(JSON.stringify(event.tags)), [["h", "build"], ["E", request, "", asker], ["K", "9"], ["P", asker], ["e", request, "", asker], ["k", "9"], ["p", asker]]);244 assert.equal(page.decision.message, "Answer sent.");245 // The inert host has no Fixi action: nothing polls the page after the answer.246 assert.equal(page.host.__fixi, undefined);247 assert.equal(page.host.loading, undefined);248});249250test("a multiple-choice question with free text publishes the combined answer from the Approvals page", async () => {251 const page = approvalsPage({selection: "multiple", interaction: "question", freeform: true});252 await page.decision.submit({elements: {option: [{value: "yes", checked: true}, {value: "no", checked: true}], "custom-answer": {value: " Both hold. "}}});253 assert.equal(page.published[0].content, '{"choices":["yes","no"],"text":"Both hold."}');254 const text = approvalsPage({selection: "text", interaction: "question", freeform: true});255 await text.decision.submit({elements: {"custom-answer": {value: "main"}}});256 assert.equal(text.published[0].content, "main");257});258259test("a request the re-read Approvals page no longer lists is not answered", async () => {260 const page = approvalsPage({listing: []});261 await assert.rejects(page.decision.submit({elements: {option: {value: "yes", checked: true}}}), /no longer waiting/);262 assert.equal(page.published.length, 0);263 page.host.disconnectedCallback();264});