clone

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

feat/ui-updates
tinyrelay / scripts / approvals.test.mjs
approvals.test.mjs 9.0K
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});