| 1 | import assert from "node:assert/strict" ; |
| 2 | import {webcrypto} from "node:crypto" ; |
| 3 | import {readFile} from "node:fs/promises" ; |
| 4 | import test from "node:test" ; |
| 5 | import vm from "node:vm" ; |
| 6 | |
| 7 | const bridge = await readFile(new URL("../tinyclient/bridge.js" , import.meta.url), "utf8" ); |
| 8 | const shared = await readFile(new URL("../tinyclient/tiny.js" , import.meta.url), "utf8" ); |
| 9 | |
| 10 | class Element { |
| 11 | constructor(tag = "div" ) { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = new Map(); this.hidden = false; } |
| 12 | setAttribute(k, v) { this.attributes.set(k, String(v)); } |
| 13 | getAttribute(k) { return this.attributes.get(k) ?? null; } |
| 14 | hasAttribute(k) { return this.attributes.has(k); } |
| 15 | append(...items) { this.children.push(...items); } |
| 16 | replaceChildren(...items) { this.children = items; } |
| 17 | addEventListener(name, fn) { this.events ||= {}; this.events[name] = fn; } |
| 18 | dispatch(name, event = {}) { return this.events?.[name]?.({...event, currentTarget: this}); } |
| 19 | matches() { return false; } |
| 20 | querySelector() { return null; } |
| 21 | get textContent() { return this.children.map(child => child.textContent ?? "" ).join("" ); } |
| 22 | set textContent(value) { this.children = [{textContent: String(value)}]; } |
| 23 | } |
| 24 | |
| 25 | async function page({actor = "a" .repeat(64), native = null, stored = false, signerFactory = () => null, now = Date.now()} = {}) { |
| 26 | const nodes = new Map(); |
| 27 | for (const id of ["session-status" , "session-login" , "session-logout" , "signer-status" , "bunker" , "bunker-url" ]) nodes.set(id, new Element(id)); |
| 28 | const connection = new Element("signer-connection" ); connection.setAttribute("actor" , actor); nodes.set("signer-connection" , connection); |
| 29 | const docEvents = new Map(), winEvents = new Map(), session = new Map(), local = new Map(), requests = []; |
| 30 | const on = (map, name, fn) => map.set(name, [...(map.get(name) || []), fn]); |
| 31 | const fire = (map, name, event = {}) => Promise.all((map.get(name) || []).map(fn => fn(event))); |
| 32 | const nativeObject = native; |
| 33 | const signer = nativeObject; |
| 34 | let clock = now; |
| 35 | const sandbox = { |
| 36 | tiny: {}, document: { |
| 37 | visibilityState: "visible" , referrer: "" , documentElement: {dataset: {}}, |
| 38 | getElementById: id => nodes.get(id), querySelector: selector => selector === "signer-connection" ? nodes.get("signer-connection" ) : null, |
| 39 | querySelectorAll: () => [], createElement: tag => new Element(tag), |
| 40 | addEventListener: on.bind(null, docEvents), dispatchEvent: event => fire(docEvents, event.type, event) |
| 41 | }, |
| 42 | crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Date: class extends Date { static now() { return clock; } }, setTimeout, clearTimeout, |
| 43 | btoa: value => Buffer.from(value).toString("base64" ), |
| 44 | location: {pathname: "/r/work/rooms/general" , href: "https://tiny.example/r/work/rooms/general?x=1#part" , origin: "https://tiny.example" , assign(url) { this.assigned = url; }, replace(url) { this.replaced = url; }, reload() { this.reloaded = true; }}, |
| 45 | navigator: {clipboard: {writeText: async () => {}}}, getComputedStyle: () => ({getPropertyValue: () => "" }), |
| 46 | matchMedia: () => ({matches: false, addEventListener() {}}), addEventListener: on.bind(null, winEvents), |
| 47 | localStorage: {getItem: k => local.get(k) ?? null, setItem: (k, v) => local.set(k, String(v)), removeItem: k => local.delete(k)}, |
| 48 | sessionStorage: {getItem: k => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: k => session.delete(k)}, |
| 49 | NostrSigner: { |
| 50 | hexToBytes: () => new Uint8Array(32), bytesToHex: () => "key" , generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b" .repeat(64), |
| 51 | parseBunkerInput: async () => ({pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}), BunkerSigner: {fromBunker: signerFactory} |
| 52 | }, |
| 53 | fetch: async (url, options) => { requests.push({url, options}); return new Response("{}" ); } |
| 54 | }; |
| 55 | sandbox.window = sandbox; |
| 56 | if (signer) sandbox.nostr = signer; |
| 57 | vm.runInNewContext(shared + bridge, sandbox); |
| 58 | await new Promise(resolve => setTimeout(resolve, 0)); |
| 59 | if (stored) { session.set("tiny.bunker/r/work.uri" , "bunker://saved" ); session.set("tiny.bunker/r/work.sk" , "key" ); session.set("tiny.bunker/r/work.identity" , "c" .repeat(64)); session.set("tiny.bunker/r/work.remote" , JSON.stringify({pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]})); } |
| 60 | return {sandbox, nodes, docEvents, winEvents, session, local, requests, setClock: value => { clock = value; }, fire: (map, name, event) => fire(map, name, event)}; |
| 61 | } |
| 62 | |
| 63 | const saved = (session, actor = "c" .repeat(64)) => { |
| 64 | session.set("tiny.bunker/r/work.uri" , "bunker://saved" ); session.set("tiny.bunker/r/work.sk" , "key" ); session.set("tiny.bunker/r/work.identity" , actor); session.set("tiny.bunker/r/work.remote" , JSON.stringify({pubkey: actor, relays: ["wss://relay.test" ]})); |
| 65 | }; |
| 66 | |
| 67 | test("recovery checks a late native signer without replacing window.nostr" , async () => { |
| 68 | const native = {getPublicKey: async () => "a" .repeat(64), signEvent: async event => event}; |
| 69 | const {sandbox, winEvents} = await page(); |
| 70 | sandbox.nostr = native; |
| 71 | await sandbox.tiny.reconnectSigner(); |
| 72 | assert.equal(sandbox.nostr, native); |
| 73 | assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 74 | assert.equal(sandbox.location.replaced, undefined); |
| 75 | assert.ok(winEvents.get("pageshow" )); |
| 76 | }); |
| 77 | |
| 78 | test("missing remote credentials gives a connect path and navigation restores it" , async () => { |
| 79 | const {sandbox, nodes, docEvents} = await page(); |
| 80 | await sandbox.tiny.reconnectSigner({force: true}).catch(() => {}); |
| 81 | assert.equal(nodes.get("signer-connection" ).hidden, false); |
| 82 | assert.equal(nodes.get("signer-connection" ).children[1].textContent, "Connect signer" ); |
| 83 | nodes.get("signer-connection" ).children[1].dispatch("click" ); |
| 84 | assert.match(sandbox.location.assigned, /signin\?connect=1&next=/); |
| 85 | const replacement = new Element("signer-connection" ); replacement.setAttribute("actor" , "a" .repeat(64)); nodes.set("signer-connection" , replacement); |
| 86 | await Promise.all((docEvents.get("tiny:navigation" ) || []).map(fn => fn())); |
| 87 | assert.equal(replacement.hidden, false); |
| 88 | }); |
| 89 | |
| 90 | test("remote ping keeps the same signer and failed replacement closes both pools" , async () => { |
| 91 | let oldClose = 0, oldDestroy = 0, candidateClose = 0, candidateDestroy = 0, ping = 0; |
| 92 | const pool = () => ({destroy: () => { oldDestroy++; }}); |
| 93 | const old = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, pool: pool(), connect: async () => {}, ping: async () => { ping++; }, close: () => { oldClose++; }, getPublicKey: async () => "c" .repeat(64), signEvent: async e => e}; |
| 94 | const candidate = {bp: old.bp, pool: {destroy: () => { candidateDestroy++; }}, connect: async () => {}, close: () => { candidateClose++; }, getPublicKey: async () => "c" .repeat(64)}; |
| 95 | const failedCandidate = {...candidate, connect: async () => { throw Error("offline" ); }}; |
| 96 | const {sandbox, session, setClock} = await page({actor: "c" .repeat(64), signerFactory: () => old, now: 1000}); |
| 97 | saved(session); |
| 98 | await sandbox.tiny.reconnectSigner({force: true}); |
| 99 | const first = sandbox.tinySigner; |
| 100 | setClock(20000); |
| 101 | await sandbox.tiny.reconnectSigner(); |
| 102 | assert.equal(sandbox.tinySigner, first); assert.equal(ping, 1); |
| 103 | // Force a replacement after ping fails. |
| 104 | old.ping = async () => { throw Error("offline" ); }; |
| 105 | sandbox.NostrSigner.BunkerSigner.fromBunker = () => failedCandidate; |
| 106 | await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /offline/); |
| 107 | assert.equal(sandbox.tiny.signerState(), "lost" ); |
| 108 | assert.ok(oldClose > 0 && oldDestroy > 0 && candidateClose > 0 && candidateDestroy > 0); |
| 109 | assert.equal(session.get("tiny.bunker/r/work.sk" ), "key" ); |
| 110 | }); |
| 111 | |
| 112 | test("signer denial stays ready while transport failure reports lost" , async () => { |
| 113 | let reject = false; |
| 114 | const remote = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, connect: async () => {}, getPublicKey: async () => "c" .repeat(64), signEvent: async () => { throw Error(reject ? "user rejected" : "connection closed" ); }}; |
| 115 | const {sandbox, session} = await page({actor: "c" .repeat(64), signerFactory: () => remote}); saved(session); |
| 116 | await sandbox.tiny.reconnectSigner({force: true}); |
| 117 | await assert.rejects(sandbox.tinySigner.signEvent({}), /connection closed/); assert.equal(sandbox.tiny.signerState(), "lost" ); |
| 118 | reject = true; |
| 119 | await sandbox.tiny.reconnectSigner({force: true}); |
| 120 | await assert.rejects(sandbox.tinySigner.signEvent({}), /user rejected/); assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 121 | }); |
| 122 | |
| 123 | test("a brief background suspension forces a fresh remote health check" , async () => { |
| 124 | let pings = 0; |
| 125 | const remote = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, connect: async () => {}, ping: async () => { pings++; }, getPublicKey: async () => "c" .repeat(64)}; |
| 126 | const {sandbox, session, fire, docEvents, setClock} = await page({actor: "c" .repeat(64), signerFactory: () => remote, now: 1000}); saved(session); |
| 127 | await sandbox.tiny.reconnectSigner({force: true}); |
| 128 | setClock(2000); |
| 129 | sandbox.document.visibilityState = "hidden" ; |
| 130 | await fire(docEvents, "visibilitychange" ); |
| 131 | sandbox.document.visibilityState = "visible" ; |
| 132 | await fire(docEvents, "visibilitychange" ); |
| 133 | await sandbox.tiny.reconnectSigner(); |
| 134 | assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 135 | assert.equal(pings, 1); |
| 136 | }); |
| 137 | |
| 138 | test("successful remote replacement closes the old signer and changes the facade" , async () => { |
| 139 | let oldClose = 0, oldDestroy = 0; |
| 140 | const old = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, connect: async () => {}, pool: {destroy: () => { oldDestroy++; }}, close: () => { oldClose++; }, getPublicKey: async () => "c" .repeat(64), ping: async () => { throw Error("offline" ); }}; |
| 141 | const next = {bp: old.bp, connect: async () => {}, pool: {destroy() {}}, close() {}, getPublicKey: async () => "c" .repeat(64)}; |
| 142 | const {sandbox, session} = await page({actor: "c" .repeat(64), signerFactory: () => old}); saved(session); |
| 143 | await sandbox.tiny.reconnectSigner({force: true}); const previous = sandbox.tinySigner; |
| 144 | sandbox.NostrSigner.BunkerSigner.fromBunker = () => next; |
| 145 | await sandbox.tiny.reconnectSigner({force: true}); |
| 146 | assert.notEqual(sandbox.tinySigner, previous); assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 147 | assert.ok(oldClose > 0 && oldDestroy > 0); |
| 148 | }); |
| 149 | |
| 150 | test("an old pending signer operation cannot clear a replacement" , async () => { |
| 151 | let release; |
| 152 | const old = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c" .repeat(64), signEvent: async () => new Promise(resolve => { release = resolve; }), ping: async () => { throw Error("offline" ); }}; |
| 153 | const next = {bp: old.bp, connect: async () => {}, close() {}, pool: {destroy() {}}, getPublicKey: async () => "c" .repeat(64)}; |
| 154 | const {sandbox, session} = await page({actor: "c" .repeat(64), signerFactory: () => old}); saved(session); |
| 155 | await sandbox.tiny.reconnectSigner({force: true}); |
| 156 | const pending = sandbox.tinySigner.signEvent({}); |
| 157 | sandbox.NostrSigner.BunkerSigner.fromBunker = () => next; |
| 158 | await sandbox.tiny.reconnectSigner({force: true}); |
| 159 | await assert.rejects(pending, /connection changed/); |
| 160 | release({}); |
| 161 | assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 162 | }); |
| 163 | |
| 164 | test("native signer identity mismatch reports loss" , async () => { |
| 165 | const native = {getPublicKey: async () => "b" .repeat(64), signEvent: async event => event}; |
| 166 | const {sandbox} = await page({actor: "a" .repeat(64), native}); |
| 167 | sandbox.nostr = native; |
| 168 | await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /signed-in account/); |
| 169 | assert.equal(sandbox.tiny.signerState(), "lost" ); assert.equal(sandbox.nostr, native); |
| 170 | }); |
| 171 | |
| 172 | test("NIP-44 transport failure reports loss" , async () => { |
| 173 | const remote = {bp: {pubkey: "c" .repeat(64), relays: ["wss://relay.test" ]}, connect: async () => {}, getPublicKey: async () => "c" .repeat(64), nip44Decrypt: async () => { throw Error("connection closed" ); }}; |
| 174 | const {sandbox, session} = await page({actor: "c" .repeat(64), signerFactory: () => remote}); saved(session); |
| 175 | await sandbox.tiny.reconnectSigner({force: true}); |
| 176 | await assert.rejects(sandbox.tinySigner.nip44Decrypt("peer" , "ciphertext" ), /connection closed/); |
| 177 | assert.equal(sandbox.tiny.signerState(), "lost" ); |
| 178 | }); |