| 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. |
| 4 | import test from "node:test" ; |
| 5 | import assert from "node:assert/strict" ; |
| 6 | import fs from "node:fs" ; |
| 7 | import vm from "node:vm" ; |
| 8 | import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools" ; |
| 9 | import { sharedSigning } from "./test-signing.mjs" ; |
| 10 | |
| 11 | const request = "a" .repeat(64), asker = "b" .repeat(64); |
| 12 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 13 | const 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. |
| 17 | const 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 | |
| 25 | function 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 | } |
| 49 | const attrs = {event: request, pubkey: asker, kind: "1111" }; |
| 50 | |
| 51 | test("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 | |
| 68 | test("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 | |
| 77 | test("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 | |
| 91 | test("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. |
| 117 | const workerSource = fs.readFileSync("tinyclient/sw.js" , "utf8" ); |
| 118 | function 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 | } |
| 129 | const waitUntil = () => { let done; const event = {waitUntil(promise) { done = promise; }}; return {event, finished: () => done}; }; |
| 130 | |
| 131 | test("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 | |
| 148 | test("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 | }); |