/** * The access gate's identity resolution. * * This is the piece whose failure is SILENT: get the order wrong and the broker iframe * reads an empty identity, provisions a second virtual user, and the returning user * lands in an empty space with no error anywhere. So the order is pinned, not trusted. */ import { getCurrentUser } from "../src/shared-wallet/bootstrap"; import { test, expect, afterEach } from "bun:test"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { ensureIdentity } from "../src/shared-wallet/access-gate"; const KEY = "ng-eventually:identity"; /** A localStorage double — the real one is absent in `bun test`. */ function fakeStorage(initial: Record = {}) { const map = new Map(Object.entries(initial)); return { getItem: (k: string) => map.get(k) ?? null, setItem: (k: string, v: string) => void map.set(k, v), removeItem: (k: string) => void map.delete(k), get size() { return map.size; }, }; } /** Put the page in a given URL + storage state, as the browser would. */ function inPage(search: string, storage: ReturnType) { const location = { search, href: "https://app.example" + search }; (globalThis as any).location = location; (globalThis as any).localStorage = storage; (globalThis as any).history = { replaceState: () => {} }; return { location }; } /** Everything a page installs on the global object, so nothing leaks into the next test. */ const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const; afterEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name); }); function configured() { configureStoreRegistry({ getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }), normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), }); configure({ ng: {} as never, useShape: (() => {}) as never, sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, }); } test("an identity already set is left alone — the gate never re-asks", async () => { configured(); inPage("", fakeStorage()); setCurrentUser("alice"); await ensureIdentity(); expect(getCurrentUser()).toBe("alice"); }); test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => { // The top-level page and the broker iframe have separate localStorage partitions, so a // value written on one side is not the value the other reads. The URL survives the // round-trip; storage does not. If storage won here, a user entering a second // identifier would keep being sent back to the first one's space. configured(); inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" })); await ensureIdentity(); expect(getCurrentUser()).toBe("fromurl"); }); test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => { configured(); const storage = fakeStorage(); inPage("?ng-id=carol", storage); await ensureIdentity(); expect(storage.getItem(KEY)).toBe("carol"); }); test("with no parameter, storage answers — a reload does not re-ask", async () => { configured(); inPage("", fakeStorage({ [KEY]: "dana" })); await ensureIdentity(); expect(getCurrentUser()).toBe("dana"); }); test("nothing known and no DOM to ask on → it refuses loudly", async () => { // Continuing silently would provision an anonymous virtual space, which is the failure // this module exists to prevent. The error names what the caller must do. configured(); inPage("", fakeStorage()); await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i); }); test("no shared wallet configured → it refuses, rather than inventing a space", async () => { configure({ ng: {} as never, useShape: (() => {}) as never }); inPage("", fakeStorage()); await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i); }); test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => { // Ported from the consumer's `identifiant-resolution` feature, and it caught a real // defect here: the gate normalized what a user TYPED but not what the URL carried, so // a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person // typing `erin`. One normalizer — the injected one — for all three entry paths. configured(); inPage("?ng-id=@Erin", fakeStorage()); await ensureIdentity(); expect(getCurrentUser()).toBe("erin"); }); test("a stored value is normalized too — an old entry cannot key onto a second space", async () => { configured(); inPage("", fakeStorage({ [KEY]: "@Frank" })); await ensureIdentity(); expect(getCurrentUser()).toBe("frank"); }); // ── The parameter must be in the address bar before `init()` reads it ────── // // The round-trip to the broker is `ng.init()`'s job and stays there: it navigates to // NextGraph's login with `window.location.href` AS IT FINDS IT. It knows nothing of // `ng-id`, so the one thing the gate owes the round-trip is that the parameter is already // in the address bar when `init()` runs — whatever settled the identity. Miss one path and // the iframe reads no identity, provisions a second virtual user, and the returning user // lands in an empty space with no error anywhere. // // So every test below asserts on `location.href`: that IS what `init()` would hand over. const APP = "https://app.example/"; /** Let pending microtasks and timers run — the gate resolves on a click, then continues. */ function flush(): Promise { return new Promise((r) => setTimeout(r, 0)); } /** One element of the gate, holding the listeners the gate attaches to it. */ function fakeElement() { const handlers = new Map void)[]>(); return { value: "", disabled: false, addEventListener(type: string, fn: (e: unknown) => void): void { handlers.set(type, [...(handlers.get(type) ?? []), fn]); }, focus(): void {}, fire(type: string): void { for (const fn of handlers.get(type) ?? []) fn({}); }, }; } /** * A DOM for the gate, honest about its one limitation: it does not parse markup, so * `querySelector` hands back the input and the button it was built with rather than ones * found in the HTML. What that markup contains is the e2e's job; what these tests assert * is what happens AFTER a user submits. Everything else is a real element's behaviour: a * value, a disabled flag, listeners that fire. */ function fakeDom() { const input = fakeElement(); const go = fakeElement(); let mounted = 0; const root = { innerHTML: "", querySelector: (sel: string) => (sel === "input" ? input : sel === "button.go" ? go : null), }; const host = { setAttribute: (): void => {}, attachShadow: () => root, remove: (): void => void (mounted -= 1), }; return { document: { createElement: () => host, body: { appendChild: (): void => void (mounted += 1) }, }, /** Is the barrier on screen? */ get shown(): boolean { return mounted > 0; }, /** What a user does at the gate: type an identifier, then press Entrer. */ submit(id: string): void { input.value = id; input.fire("input"); go.fire("click"); }, }; } /** * Put the code in a real browser page. * * Faithful in the ways these assertions depend on, because a double that got them wrong * would prove nothing: `location.search` tracks `href`, and `history.replaceState` MOVES * `href` — as a real browser does, and as `init()` relies on when it reads the address bar * afterwards. A fake whose `replaceState` did nothing would let a broken gate pass. */ function inBrowser(opts: { url: string; storage?: ReturnType }) { const storage = opts.storage ?? fakeStorage(); const dom = fakeDom(); let href = opts.url; const location = { get href(): string { return href; }, set href(next: string) { href = next; }, get search(): string { return new URL(href).search; }, }; const history = { replaceState: (_state: unknown, _title: string, next: string): void => void (href = next), }; Object.assign(globalThis, { location, history, localStorage: storage, document: dom.document, }); return { location, storage, dom }; } test("settled at the GATE: the address bar carries the identifier `init()` will hand over", async () => { configured(); const page = inBrowser({ url: APP }); const settled = ensureIdentity(); expect(page.dom.shown).toBe(true); page.dom.submit("Gina"); expect(await settled).toBe("gina"); expect(page.storage.getItem(KEY)).toBe("gina"); expect(page.location.href).toBe(APP + "?ng-id=gina"); }); test("settled from STORAGE: the address bar gains the identifier — the silent case", async () => { // The one that bites. Storage does not cross the frontier: the top-level page and the // broker iframe read different localStorage partitions. A returning user is recognised // from storage and the URL stays bare, so unless the parameter is written HERE the // round-trip leaves without it and the user lands in a second, empty virtual space. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); expect(await ensureIdentity()).toBe("dana"); expect(page.location.href).toBe(APP + "?ng-id=dana"); }); test("settled from the URL: the parameter is rewritten NORMALIZED, not as the link spelled it", async () => { // `@Erin` and `erin` must be one virtual space, and it is the address bar that survives // the round-trip — so it is the normalized form that has to be in it, not the raw one. configured(); const page = inBrowser({ url: APP + "?ng-id=@Erin" }); expect(await ensureIdentity()).toBe("erin"); expect(page.location.href).toBe(APP + "?ng-id=erin"); }); test("settled by the CALLER: an identity set before the call still reaches the address bar", async () => { configured(); const page = inBrowser({ url: APP }); setCurrentUser("alice"); expect(await ensureIdentity()).toBe("alice"); expect(page.location.href).toBe(APP + "?ng-id=alice"); }); test("a storage that refuses writes does not cost the round-trip its parameter", async () => { // Private mode, quota, a sandboxed document: `setItem` throws. Storage is only // same-partition convenience — the address bar is what crosses the frontier — so the two // writes must fail independently. Sharing one `try` would let the lesser failure take // the greater one down, silently. configured(); const refuses = { getItem: () => null, setItem: (): never => { throw new Error("QuotaExceededError: the storage is full or write-protected"); }, removeItem: (): void => {}, }; const page = inBrowser({ url: APP }); Object.assign(globalThis, { localStorage: refuses }); setCurrentUser("alice"); expect(await ensureIdentity()).toBe("alice"); expect(page.location.href).toBe(APP + "?ng-id=alice"); }); test("an address bar that cannot be rewritten fails safely — the gate asks again", async () => { // A sandboxed document without `allow-same-origin` throws on `replaceState`. Nothing // here can substitute for it: the URL `init()` hands over is the browser's, not ours. So // signing in must still succeed locally, and the identity must still be remembered — // the round-trip loses it and the gate asks again, which is the safe failure. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); Object.assign(globalThis, { history: { replaceState: (): never => { throw new Error("SecurityError: the document is sandboxed and lacks allow-same-origin"); }, }, }); expect(await ensureIdentity()).toBe("dana"); expect(page.storage.getItem(KEY)).toBe("dana"); expect(page.location.href).toBe(APP); });