tinyrelayfile
tinyrelay/scripts/signer.test.mjs/

scripts/signer.test.mjs raw
1import assert from "node:assert/strict";
2import {webcrypto} from "node:crypto";
3import {readFile} from "node:fs/promises";
4import test from "node:test";
5import vm from "node:vm";
6
7const bridge = await readFile(new URL("../tinyclient/bridge.js", import.meta.url), "utf8");
8const shared = await readFile(new URL("../tinyclient/tiny.js", import.meta.url), "utf8");
9
10async 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
44test("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
54test("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
62test("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
74test("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
82test("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
101test("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
109test("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
118test("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
131test("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
142test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs