tinyrelayfile
tinyrelay/scripts/approvals.test.mjs/

scripts/approvals.test.mjs raw
1// Approvals in the browser: the nostr-react element signs the answer the
2// person chose, and the service worker routes a notification action to the
3// 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";
10
11const 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"));
14
15// 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});
24
25function 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"};
50
51test("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});
67
68test("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});
76
77test("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});
90
91test("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});
114
115// The service worker runs against a fake self with a registration and a
116// 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}; };
130
131test("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?id=" + 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?id=" + 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});
147
148test("a notification action opens the answer in the existing window, or a new one", async () => {
149 const url = "https://tiny.example/approvals?id=" + 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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs