| 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 | async function page({signedIn = false, bunkerResult = null, href = "https://tiny.example/r/work/signin" , referrer = "" , connect = async () => {}} = {}) { |
| 11 | const elements = new Map(); |
| 12 | for (const id of ["session-login" , "nostrconnect" , "nostrconnect-open" , "signer-qr" , "signer-status" , "bunker" , "bunker-url" , ...(signedIn ? ["session-logout" ] : [])]) { |
| 13 | elements.set(id, {hidden: true, value: "" , addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}}); |
| 14 | } |
| 15 | const saved = new Map(); |
| 16 | const documentEvents = new Map(); |
| 17 | const requests = []; |
| 18 | const signer = {bp: {pubkey: "a" .repeat(64), relays: ["wss://tiny.example/r/work" ]}, connect, getPublicKey: async () => "a" .repeat(64), signEvent: async event => ({...event, pubkey: "a" .repeat(64), sig: "test" })}; |
| 19 | const sandbox = { |
| 20 | tiny: {}, |
| 21 | document: {getElementById: id => elements.get(id), querySelector: () => null, referrer, addEventListener(name, handler) {documentEvents.set(name, handler);}, dispatchEvent() {}, documentElement: {dataset: {}}}, |
| 22 | crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, setTimeout, clearTimeout, |
| 23 | btoa: value => Buffer.from(value).toString("base64" ), |
| 24 | location: {pathname: new URL(href).pathname, href, origin: new URL(href).origin, assign(url) { sandbox.opened = url; }, replace(url) { sandbox.opened = url; sandbox.replaced = true; }, reload() { sandbox.reloaded = true; }}, |
| 25 | navigator: {clipboard: {writeText: async () => {} }}, |
| 26 | getComputedStyle: () => ({getPropertyValue: () => "" }), |
| 27 | matchMedia: () => ({matches: false, addEventListener() {}}), |
| 28 | addEventListener() {}, |
| 29 | localStorage: {getItem: () => null, setItem() {}, removeItem() {}}, |
| 30 | sessionStorage: {getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value), removeItem: key => saved.delete(key)}, |
| 31 | NostrSigner: { |
| 32 | generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b" .repeat(64), createNostrConnectURI: () => "nostrconnect://test" , |
| 33 | bytesToHex: () => "test-key" , hexToBytes: () => new Uint8Array(32), parseBunkerInput: async () => bunkerResult, |
| 34 | BunkerSigner: {fromURI: async () => signer, fromBunker: () => signer} |
| 35 | }, |
| 36 | fetch: async (url, options) => { requests.push({url, options}); return new Response("{}" ); } |
| 37 | }; |
| 38 | sandbox.window = sandbox; |
| 39 | vm.runInNewContext(shared + bridge, sandbox); |
| 40 | await new Promise(resolve => setTimeout(resolve, 0)); |
| 41 | return {elements, saved, requests, sandbox, documentEvents}; |
| 42 | } |
| 43 | |
| 44 | test("approving a phone signer signs in and opens the tenant homepage" , async () => { |
| 45 | const {elements, saved, requests, sandbox} = await page(); |
| 46 | await elements.get("nostrconnect" ).click({currentTarget: elements.get("nostrconnect" ), preventDefault() {}}); |
| 47 | assert.equal(requests.length, 1); |
| 48 | assert.equal(requests[0].url, "/r/work/session" ); |
| 49 | assert.ok(requests[0].options.headers.authorization.startsWith("Nostr " )); |
| 50 | assert.equal(sandbox.opened, "/r/work/" ); |
| 51 | assert.equal(saved.get("tiny.bunker/r/work.identity" ), "a" .repeat(64)); |
| 52 | }); |
| 53 | |
| 54 | test("invalid bunker input reports a useful error without connecting" , async () => { |
| 55 | const {elements, requests} = await page({bunkerResult: null}); |
| 56 | elements.get("bunker-url" ).value = "not-a-bunker" ; |
| 57 | await elements.get("bunker" ).submit({currentTarget: elements.get("bunker" ), preventDefault() {}}); |
| 58 | assert.equal(requests.length, 0); |
| 59 | assert.equal(elements.get("signer-status" ).textContent, "Remote signer error: Enter a valid bunker URL with at least one relay." ); |
| 60 | }); |
| 61 | |
| 62 | test("NIP-07 signer can sign in directly" , async () => { |
| 63 | const {elements, requests, sandbox} = await page(); |
| 64 | assert.equal(sandbox.tiny.signerState(), "disconnected" ); |
| 65 | sandbox.nostr = {signEvent: async event => ({...event, pubkey: "c" .repeat(64), sig: "extension" })}; |
| 66 | await elements.get("session-login" ).click({currentTarget: elements.get("session-login" )}); |
| 67 | assert.equal(requests.length, 1); |
| 68 | assert.equal(requests[0].url, "/r/work/session" ); |
| 69 | assert.match(elements.get("signer-status" ).textContent, /Signing in|Signed in/); |
| 70 | assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 71 | assert.equal(sandbox.opened, "/r/work/" ); |
| 72 | }); |
| 73 | |
| 74 | test("sign out revokes the session without requiring a connected phone" , async () => { |
| 75 | const {elements, requests, sandbox} = await page({signedIn: true}); |
| 76 | await elements.get("session-logout" ).click({currentTarget: elements.get("session-logout" )}); |
| 77 | assert.equal(requests[0].url, "/r/work/session/logout" ); |
| 78 | assert.equal(requests[0].options.credentials, "same-origin" ); |
| 79 | assert.equal(sandbox.reloaded, true); |
| 80 | }); |
| 81 | |
| 82 | test("sign-in returns to the requested page and replaces the login history entry" , async () => { |
| 83 | const target = "/r/work/rooms/hermes/thread/" + "d" .repeat(64) + "?view=thread#msg-123" ; |
| 84 | const href = "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target.split("#" )[0]) + "#return=" + encodeURIComponent(target); |
| 85 | for (const mode of ["extension" , "phone" , "bunker" ]) { |
| 86 | const {elements, sandbox} = await page({href, bunkerResult: {pubkey: "a" .repeat(64), relays: ["wss://relay.test" ]}}); |
| 87 | if (mode === "extension" ) { |
| 88 | sandbox.nostr = {signEvent: async event => event}; |
| 89 | await elements.get("session-login" ).click({currentTarget: elements.get("session-login" )}); |
| 90 | } else if (mode === "phone" ) { |
| 91 | await elements.get("nostrconnect" ).click({currentTarget: elements.get("nostrconnect" ), preventDefault() {}}); |
| 92 | } else { |
| 93 | elements.get("bunker-url" ).value = "bunker://test" ; |
| 94 | await elements.get("bunker" ).submit({currentTarget: elements.get("bunker" ), preventDefault() {}}); |
| 95 | } |
| 96 | assert.equal(sandbox.opened, target, mode); |
| 97 | assert.equal(sandbox.replaced, true, mode); |
| 98 | } |
| 99 | }); |
| 100 | |
| 101 | test("embedded login keeps the protected page, query and fragment" , async () => { |
| 102 | const target = "/r/work/manage/connect?view=client#setup" ; |
| 103 | const {elements, sandbox} = await page({href: "https://tiny.example" + target}); |
| 104 | sandbox.nostr = {signEvent: async event => event}; |
| 105 | await elements.get("session-login" ).click({currentTarget: elements.get("session-login" )}); |
| 106 | assert.equal(sandbox.opened, target); |
| 107 | }); |
| 108 | |
| 109 | test("return targets reject external addresses, tenant changes and sign-in loops" , async () => { |
| 110 | for (const target of ["https://evil.test" , "//evil.test" , "/r/elsewhere/rooms" , "/r/work/signin" , "/r/work/signin?next=/r/work/rooms" , "/r/work/../../r/other/" , "/\\evil.test" , "/r/work/rooms\n" ]) { |
| 111 | const {elements, sandbox} = await page({href: "https://tiny.example/r/work/signin?next=" + encodeURIComponent(target)}); |
| 112 | sandbox.nostr = {signEvent: async event => event}; |
| 113 | await elements.get("session-login" ).click({currentTarget: elements.get("session-login" )}); |
| 114 | assert.equal(sandbox.opened, "/r/work/" , target); |
| 115 | } |
| 116 | }); |
| 117 | |
| 118 | test("sign-in links carry private fragments only in the browser fragment" , async () => { |
| 119 | const target = "/r/work/file?hash=abc#key=private-key&name=notes.txt" ; |
| 120 | const {sandbox, documentEvents} = await page({href: "https://tiny.example" + target}); |
| 121 | const attrs = new Map([["fx-action" , "/r/work/signin" ]]); |
| 122 | const link = {href: "https://tiny.example/r/work/signin" , hasAttribute: name => attrs.has(name), setAttribute: (name,value) => attrs.set(name,value)}; |
| 123 | documentEvents.get("click" )({target: {closest: () => link}}); |
| 124 | const login = new URL(link.href, sandbox.location.href); |
| 125 | assert.equal(login.searchParams.get("next" ), "/r/work/file?hash=abc" ); |
| 126 | assert.equal(login.search.includes("private-key" ), false); |
| 127 | assert.equal(new URLSearchParams(login.hash.slice(1)).get("return" ), target); |
| 128 | assert.equal(attrs.get("fx-action" ), link.href); |
| 129 | }); |
| 130 | |
| 131 | test("a suspended remembered signer can reconnect without revoking the web session" , async () => { |
| 132 | const {saved, sandbox} = await page(); |
| 133 | saved.set("tiny.bunker/r/work.uri" , "bunker://remembered" ); |
| 134 | saved.set("tiny.bunker/r/work.sk" , "test-key" ); |
| 135 | saved.set("tiny.bunker/r/work.identity" , "a" .repeat(64)); |
| 136 | saved.set("tiny.bunker/r/work.remote" , JSON.stringify({pubkey: "a" .repeat(64), relays: ["wss://tiny.example/r/work" ]})); |
| 137 | await sandbox.tiny.reconnectSigner({force: true}); |
| 138 | assert.equal(sandbox.tiny.signerState(), "ready" ); |
| 139 | assert.equal(saved.get("tiny.bunker/r/work.identity" ), "a" .repeat(64)); |
| 140 | }); |
| 141 | |
| 142 | test("failed signer recovery reports loss and preserves reconnect credentials" , async () => { |
| 143 | const {saved, sandbox} = await page({connect: async () => { throw Error("suspended" ); }}); |
| 144 | saved.set("tiny.bunker/r/work.uri" , "bunker://remembered" ); |
| 145 | saved.set("tiny.bunker/r/work.sk" , "test-key" ); |
| 146 | saved.set("tiny.bunker/r/work.identity" , "a" .repeat(64)); |
| 147 | saved.set("tiny.bunker/r/work.remote" , JSON.stringify({pubkey: "a" .repeat(64), relays: ["wss://tiny.example/r/work" ]})); |
| 148 | await assert.rejects(sandbox.tiny.reconnectSigner({force: true}), /suspended/); |
| 149 | assert.equal(sandbox.tiny.signerState(), "lost" ); |
| 150 | assert.equal(saved.get("tiny.bunker/r/work.sk" ), "test-key" ); |
| 151 | }); |