clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / signer.test.mjs
signer.test.mjs 9.1K
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";67const 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");910async 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}4344test("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});5354test("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});6162test("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});7374test("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});8182test("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});100101test("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});108109test("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});117118test("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});130131test("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});141142test("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});