diff --git a/.project/concepts/e2e-harness/_debt.md b/.project/concepts/e2e-harness/_debt.md new file mode 100644 index 0000000..0803289 --- /dev/null +++ b/.project/concepts/e2e-harness/_debt.md @@ -0,0 +1,10 @@ +# Doc-debt — e2e-harness + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/e2e/run.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/polyfill-entry.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/notebook.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42) +- TOUCHED packages/polyfill/e2e/probe-frame-check.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/.project/concepts/sign-in/_debt.md b/.project/concepts/sign-in/_debt.md new file mode 100644 index 0000000..ef55021 --- /dev/null +++ b/.project/concepts/sign-in/_debt.md @@ -0,0 +1,7 @@ +# Doc-debt — sign-in + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Raw markers (consolidate into blocks, then delete) +- TOUCHED packages/polyfill/src/shared-wallet/access-gate.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42) diff --git a/packages/polyfill/e2e/notebook.ts b/packages/polyfill/e2e/notebook.ts index 429c3cf..47e621c 100644 --- a/packages/polyfill/e2e/notebook.ts +++ b/packages/polyfill/e2e/notebook.ts @@ -69,6 +69,11 @@ const SUITE_DEADLINE_MS = 15 * 60 * 1000; /** * One journey. The longest (the newcomer's) does a wallet download, an import into a cold * profile, a broker round-trip and a first render — measured under 2 min, bounded at 6. + * The returning visitor's does that twice over and measures 36s, so it fits the same bound + * with room to spare; a bound of its own was tried and dropped, because on a healthy host + * nothing justified it and an 11-minute journey can outlast the SUITE's own deadline — + * which prints no summary at all. A journey that overruns THIS is a hang or a sick host, + * and both are worth hearing about rather than absorbing. */ const JOURNEY_MS = 6 * 60 * 1000; /** Signing an actor in: broker redirect, unlock, iframe, first render. Measured ~5-10s. */ @@ -89,10 +94,10 @@ function check(name: string, ok: boolean, detail?: string): void { * right for a journey that fails, but a journey that never RETURNS is caught by nothing — * and that is what three killed runs looked like from the outside. */ -async function journey(name: string, fn: () => Promise): Promise { +async function journey(name: string, fn: () => Promise, boundMs = JOURNEY_MS): Promise { console.log(`\n── ${name} ──`); try { - await within(`the journey "${name}"`, JOURNEY_MS, fn); + await within(`the journey "${name}"`, boundMs, fn); } catch (e: any) { check(name, false, "threw: " + String(e?.message ?? e)); } @@ -179,8 +184,14 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise< if (m.type() === "error") console.error(`[${id} console]`, m.text()); }); // `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's - // resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees - // the barrier. Here it is also how the suite signs an actor in without typing. + // resolution order, `shared-wallet/access-gate.ts`). Here it is also how the suite + // signs an actor in without typing. + // + // No barrier is met on this path, and the reason is the FRAME, not the identifier: + // `setupBrokerPage` goes straight to the broker's redirect, so the application only + // ever loads inside the iframe — where the round-trip is already behind it. The two + // journeys that load the application's own address top-level do meet the barrier, and + // must: that is the side a person actually arrives on. const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`); await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }); return { id, frame, page }; @@ -411,9 +422,10 @@ async function main(): Promise { }); // 5. The path no journey walked: somebody who holds NOTHING. No wallet in the - // profile, no identifier anywhere, and the application's own address — not the one - // `signIn()` builds, which appends `?ng-id=` and so makes the barrier resolve from - // the URL and never appear. Two defects shipped green behind that shortcut: the + // profile, no identifier anywhere, and the application's own address — not the + // broker redirect `signIn()` goes through, which loads the application already + // inside the iframe and so never meets the barrier. Two defects shipped green + // behind that shortcut: the // application handed the page to the broker BEFORE the barrier could show (a // first-time user landed on a login with no wallet and no way to get one), and the // file the barrier offers was served by nobody, so its link pointed at a 404. @@ -528,6 +540,151 @@ async function main(): Promise { try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } } }); + + // 6. The visit AFTER the first one, on the application's own address. The barrier used + // to skip itself here — it asked only when nobody was known — and skipping is + // silent: the page goes straight to the broker, and someone whose browser no longer + // holds the wallet lands on a static dead end with no return path. The wallet is + // handed out at the barrier and nowhere else, so the barrier has to be there. + // + // Nothing is seeded. The identifier this journey expects to find prefilled is the + // one it typed itself, one visit earlier, at the real barrier, on a device that + // started with nothing — the only way a person obtains it, and the only way this + // journey may (`rule_never-shortcut-the-sign-in`). The wallet is not planted either: + // it comes off the barrier's own link and through a real import, as it does above. + // + // Its OWN browser profile, for the same reason the newcomer's journey has one: a + // device that starts with nothing is the only one on which the wallet can be + // OBTAINED rather than found already there. It also keeps this journey off the + // actors' profile, which no journey should be adding broker pages to. + await journey("a returning visitor meets the barrier again, prefilled, and keeps their space", async () => { + const returning = `returning-${t}`; + const downloaded = path.join(tmpDir, "downloaded-by-the-returning-visitor.ngw"); + const fresh = await launchCleanProfileContext(); + let first: Page | null = null; + let again: Page | null = null; + const startedAtJourney = Date.now(); + /** + * Progress, not assertion. This journey is the longest in the suite, and when it + * overran its bound it had reported NOTHING — so there was no way to tell a slow + * broker from a genuine hang, or to know which step to look at. + */ + const at = (what: string): void => + console.log(` · ${what} (+${((Date.now() - startedAtJourney) / 1000).toFixed(0)}s)`); + /** Open the application's OWN address, top-level, and wait for the barrier. */ + const arriveAtTheBarrier = async (label: string): Promise => { + const p = await fresh.ctx.newPage(); + p.on("pageerror", (e) => console.error(`[${label} pageerror]`, e.message)); + p.on("console", (m) => { + if (m.type() === "error") console.error(`[${label} console]`, m.text()); + }); + await p.goto(url, { waitUntil: "domcontentloaded" }); + await p.locator('[data-testid="ng-identity-input"]').waitFor({ state: "visible", timeout: 30000 }); + return p; + }; + + try { + // The FIRST visit — how the identifier and the wallet come to exist at all on this + // device. Both are obtained here, neither is handed over by the test. + first = await arriveAtTheBarrier("returning-first-visit"); + at("first visit: the barrier is up"); + const gate = first.locator('[data-ng-eventually="access-gate"]'); + const [download] = await Promise.all([ + first.waitForEvent("download", { timeout: 30000 }), + gate.locator("a[download]").click(), + ]); + await download.saveAs(downloaded); + at("first visit: the wallet is downloaded"); + const password = ((await gate.locator("code").first().textContent()) ?? "").trim(); + const [walletPage] = await Promise.all([ + fresh.ctx.waitForEvent("page", { timeout: 30000 }), + gate.locator('a[target="_blank"]').click(), + ]); + await importWalletViaFile(walletPage, downloaded, password); + await walletPage.close().catch(() => {}); + at("first visit: the wallet is imported on this device"); + await first.locator('[data-testid="ng-identity-input"]').fill(returning); + await first.locator('[data-testid="ng-identity-enter"]').click(); + // The APPLICATION navigates, and it has to have DONE so before the broker login is + // driven: until then the top-level frame is still the application's own, and + // `completeBrokerLogin` would hand back that frame — which then navigates away, so + // everything waited for on it waits forever. Cost two runs to see. + await first.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); + at("first visit: handed over to the broker"); + const firstFrame = await completeBrokerLogin(first, url); + await firstFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); + // A note, so the second visit can be shown to land in the SAME space rather than + // merely displaying the same name. + at("first visit: the application is up"); + await writeNote({ id: returning, frame: firstFrame, page: first }, "protected", "Carnet", "de la première visite"); + at("first visit: a note is written"); + // Closed here, not merely at the end: the second visit has to be a fresh page that + // finds the identifier where the FIRST one left it, not a tab still holding it — + // and one broker page at a time in this profile. + await closeQuietly("the first visit's page", () => first!.close()); + first = null; + + // The SECOND visit — the same address a bookmark would open, nothing appended. The + // device now holds the wallet, and the barrier still neither knows nor asks: the + // steps are offered again and this person walks past them to the field. Whether a + // wallet was imported lives in another origin's storage and is unreadable, so the + // alternative would be asking them — the question this design refuses. + again = await arriveAtTheBarrier("returning-second-visit"); + at("return visit: the barrier is up again"); + check( + "the barrier still hands out the wallet without asking whether they have it", + (await again.locator('[data-ng-eventually="access-gate"]').locator("a[download]").count()) === 1, + ); + check( + "the barrier appears again, on a visit where the identifier is already known", + again.url().startsWith(url), + again.url(), + ); + const prefilled = await again.locator('[data-testid="ng-identity-input"]').inputValue(); + check( + "and it arrives prefilled — one click, nothing to retype", + prefilled === returning, + `field=${prefilled || "(vide)"}`, + ); + + // Confirmed, not retyped: what settles the identity here is the value the barrier + // itself put in the field. + await again.locator('[data-testid="ng-identity-enter"]').click(); + await again.waitForURL(/nextgraph\./, { timeout: 60000 }).catch(() => {}); + check( + "confirming the prefilled field is what hands the page over", + /nextgraph\./.test(again.url()) && again.url().includes(`ng-id%3D${returning}`), + again.url(), + ); + const backFrame = await completeBrokerLogin(again, url); + at("return visit: back inside the broker iframe"); + await backFrame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 180000 }); + const who = ((await backFrame.locator('[data-testid="who"]').textContent()) ?? "").trim(); + check("the round-trip brings them back as the same identity", who.includes(returning), who); + + // The identity is one space, not two — the failure that skipping the barrier used + // to hide was precisely a SECOND virtual space that looked like a working + // application. A note written before the round-trip is what tells them apart. + const arrived: Actor = { id: returning, frame: backFrame, page: again }; + await showScope(arrived, "protected", "Carnet"); + // `showScope` settles on an EMPTY list too, and a page this fresh can render one + // before its repos have synchronised — so the marker gets its own bounded wait. + // Not swallowed: if it never arrives, the check below reads the list and fails on + // what is actually there. + await backFrame.locator('li:has-text("Carnet")').waitFor({ timeout: 60000 }).catch(() => {}); + const list = (await backFrame.locator('[data-testid="notes"]').textContent()) ?? ""; + check( + "and into the same space — the note from the first visit is still theirs", + list.includes("Carnet") && list.includes("de la première visite"), + list.replace(/\s+/g, " ").slice(0, 70), + ); + } finally { + if (first) await closeQuietly("the first visit's page", () => first!.close()); + if (again) await closeQuietly("the return visit's page", () => again!.close()); + await closeContext("returning-visitor", fresh.ctx); + try { fs.rmSync(fresh.dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); } finally { // Bounded, and it has to be: `BrowserContext.close()` on a browser that has already // gone never resolves, and this `finally` is where that hang swallowed the summary. diff --git a/packages/polyfill/e2e/polyfill-entry.ts b/packages/polyfill/e2e/polyfill-entry.ts index aa5a02c..0835a60 100644 --- a/packages/polyfill/e2e/polyfill-entry.ts +++ b/packages/polyfill/e2e/polyfill-entry.ts @@ -199,9 +199,14 @@ const identity = new IdentityStore( * * Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can * pin the barrier itself: that it appears on a first access, that entering a value - * settles the identity normalized, and — the one that matters most — that it does NOT - * appear when the identity is already known, since a returning user seeing the barrier - * again is the visible face of the silent bug (a second virtual space). + * settles the identity normalized, and that it stands aside once the identity is known. + * + * That last one holds HERE because this page runs inside the broker iframe, and only + * there. Top-level the barrier shows on every load — a known identifier prefills the + * field instead of skipping the screen, since knowing who someone is says nothing about + * whether their browser still holds the wallet, and the broker's answer for someone who + * does not is a dead end with no way back. That side is the applicative suite's to walk + * (`e2e/notebook.ts`), with a real page and a real Back button. */ async accessGateFirstVisit(raw: string) { setCurrentUser(null); @@ -237,8 +242,8 @@ const identity = new IdentityStore( }; }, - /** The barrier must stay away once an identity is known. */ - async accessGateReturningVisit(known: string) { + /** Inside the iframe, the barrier must stay away once an identity is known. */ + async accessGateIdentityAlreadyKnown(known: string) { setCurrentUser(known); await ensureIdentity(); return { diff --git a/packages/polyfill/e2e/run.ts b/packages/polyfill/e2e/run.ts index 5a8e968..3adbd95 100644 --- a/packages/polyfill/e2e/run.ts +++ b/packages/polyfill/e2e/run.ts @@ -203,9 +203,13 @@ async function main(): Promise { `shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`, ); }); - await step("the gate stays away when the identity is already known", async () => { - const r = await sdk(frame, "accessGateReturningVisit", "erin"); - check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`); + // This harness page runs INSIDE the broker iframe — which is the whole of what makes + // the check below true. Top-level the barrier shows on every load, known identity or + // not, because a top-level page is one redirect away from a dead end for anyone whose + // browser has no wallet. The applicative suite walks that side (`e2e/notebook.ts`). + await step("past the round-trip, a known identity stands the barrier down", async () => { + const r = await sdk(frame, "accessGateIdentityAlreadyKnown", "erin"); + check("no barrier inside the broker iframe once the identity is known", r.shown === false && r.identity === "erin", `shown=${r.shown}`); }); // ── docs primitives ───────────────────────────────────────────────────── diff --git a/packages/polyfill/src/shared-wallet/access-gate.ts b/packages/polyfill/src/shared-wallet/access-gate.ts index 8f87cbe..d7ca265 100644 --- a/packages/polyfill/src/shared-wallet/access-gate.ts +++ b/packages/polyfill/src/shared-wallet/access-gate.ts @@ -53,6 +53,30 @@ * on every path, whatever settled the identity. Hence {@link rememberIdentity} on all * three, and hence {@link settleIdentity}, which the polyfill's own `init()` awaits before * it delegates (`surface/lifecycle.ts`) — so no application has to know that order. + * + * ── The barrier belongs to the TOP-LEVEL page, on every load ────────────── + * That same frontier decides WHEN the barrier shows, and the rule is not "when we do not + * know who this is". It is {@link insideBroker}: + * + * - **top-level** — the page has no session and is on its way to the hand-over. The + * barrier ALWAYS shows, prefilled with whatever identifier is already known. It is the + * last screen anyone sees before the redirect, and the only screen a person who holds + * no wallet will ever be offered: the broker sends a walletless visitor to a static + * dead-end with no return path, so a barrier that skipped itself would strand them. + * - **inside the broker iframe** — the round-trip has happened, the URL carries the + * identifier, a session is coming. Resolve and stand aside. + * + * Keying on identity PRESENCE instead is what this replaced, and it failed silently in the + * direction that matters: a returning visitor was recognised from storage, the barrier + * never showed, and someone who had cleared the wallet from this browser — or never + * imported it, having arrived on a device the identifier had reached by URL — was handed + * straight to a dead end with no way back but the browser's own Back button. + * + * Nothing here detects whether the wallet is already imported, and nothing may: that lives + * in the wallet application's own origin, which this page cannot read. So the steps are + * always all shown, and someone who already holds the wallet simply walks past them. The + * alternative — asking the person whether they have imported a wallet in this browser — is + * a question about a technical act they have no way to answer reliably. */ import { @@ -84,6 +108,27 @@ function normalizeIdentity(raw: string): string { } } +/** + * Is this document running INSIDE the broker's iframe, rather than top-level? + * + * The one signal that separates the two contexts this flow runs in, and it is deliberately + * the SAME one the level below uses: `@ng-org/web`'s `init()` compares `window.self` to + * `window.top` to decide whether to hand the page over. Asking the question the same way + * means the barrier shows exactly on the pages that are about to be redirected, and never + * on the one that came back — no second source of truth to drift from. + * + * A session would be the more obvious signal — it exists only inside the iframe — but this + * half of signing in runs BEFORE any session can exist ({@link settleIdentity}), so asking + * for one would deadlock. The frame check answers with nothing but the page. + * + * No `window` at all (server-side, a unit test) is not a frame: nothing there is going to + * be redirected, so the caller is treated as top-level and owns the identity itself. + */ +function insideBroker(): boolean { + if (typeof window === "undefined") return false; + return window.self !== window.top; +} + /** Where the gate stashes the identifier so a plain reload prefills it. */ const STORAGE_KEY = "ng-eventually:identity"; /** The URL parameter — the only channel that survives the broker round-trip. */ @@ -162,19 +207,47 @@ function rememberIdentity(id: string): void { } /** - * Show the gate and resolve with the identifier the user entered. + * Un-stick a top-level page that was handed to the broker and came BACK. * - * No prefill parameter, deliberately: the gate is shown ONLY when no identity is known, - * so there is never a value to prefill. The consumer this was moved from did prefill, - * because its screen reappeared after the broker round-trip — here the URL carries the - * identity across that round-trip, so a returning user does not see the barrier at all. - * The need is met one level up rather than papered over in the form. + * The hand-over is a navigation, so the browser may keep this document alive in its + * back/forward cache. Someone who reaches the broker without a wallet lands on a static + * dead end with no return path — the Back button is their only way out — and what Back + * restores is this page EXACTLY as it left: the barrier frozen on "Accès en cours…", its + * button dead, and an `init()` that has already delegated and will never redirect again. + * Nothing on that page can be clicked back to life. + * + * A reload is the reset, and it is the whole of it: `init()` runs again, settles again, + * and puts the barrier back up — prefilled from the address bar the hand-over left behind + * — with its button live. Only a restore from the cache does this (`persisted`), and a + * reload is not one, so it cannot loop. + * + * Armed on the way OUT rather than at load, so it exists exactly on the pages that have + * been handed over: someone still typing at the barrier who wanders off and comes back + * keeps what they typed instead of having it reloaded away. + */ +function armReturnFromHandOver(): void { + if (typeof window === "undefined") return; + window.addEventListener("pageshow", (event: PageTransitionEvent) => { + if (!event.persisted) return; + globalThis.location?.reload(); + }); +} + +/** + * Show the barrier and resolve with the identifier the person confirmed. + * + * `prefill` is what is already known about them — from the URL, from storage — and it goes + * in the field, never around the screen. The barrier still shows: a known identifier says + * nothing about whether this browser holds the wallet, and skipping the screen on the + * strength of it strands anyone who does not (see the header). So a returning visitor + * confirms a filled field, which is one click and no typing, and a first-time one gets the + * same screen with an empty field. * * Deliberately plain DOM: this is a technical barrier shown before an application * renders, like a password prompt on a closed beta. Binding it to a UI framework would * make every consumer adopt that framework for a screen that is going away. */ -function askForIdentity(cfg: SharedWalletConfig): Promise { +function askForIdentity(cfg: SharedWalletConfig, prefill: string): Promise { const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL; return new Promise((resolve) => { const host = document.createElement("div"); @@ -229,11 +302,32 @@ function askForIdentity(cfg: SharedWalletConfig): Promise { const input = root.querySelector("input") as HTMLInputElement; const go = root.querySelector("button.go") as HTMLButtonElement; + // Set on the live element rather than into the markup above: the value is a person's + // identifier and the markup is a template string, so interpolating it there would put + // whatever a link carried into the page as HTML. + input.value = prefill; const sync = (): void => { go.disabled = input.value.trim().length === 0; }; + // Answered once, and once only. A disabled button dispatches no click, but the field + // still takes an Enter key — so without this a second press would arm a second return + // listener and resolve a promise that has already been answered. + let entered = false; const enter = (): void => { const value = input.value.trim(); - if (!value) return; - host.remove(); + if (!value || entered) return; + entered = true; + if (insideBroker()) { + // The application renders behind the barrier: the session is already coming, and + // this screen has nothing left to say. + host.remove(); + } else { + // Top-level, settling is followed by the hand-over — `init()` awaits this and then + // navigates. Leaving the page bare for that moment would show a blank application; + // worse, coming BACK to a bare page would leave nothing to press. So the barrier + // stays up and says what is happening, and the return path is armed. + go.disabled = true; + go.textContent = "Accès en cours…"; + armReturnFromHandOver(); + } resolve(value); }; input.addEventListener("input", sync); @@ -311,21 +405,35 @@ export function settleIdentity(): Promise { /** * Resolve the identifier the three ways it can arrive — already set by the caller, read - * from the page (URL, then storage), or typed at the barrier — and remember it on every + * from the page (URL, then storage), or confirmed at the barrier — and remember it on every * one of them ({@link rememberIdentity}), which is the path-independent part that the * round-trip depends on. + * + * What a KNOWN identifier does depends on which side of the frontier is asking, and that is + * the whole shape of this function ({@link insideBroker}): inside the iframe it settles the + * question, top-level it only fills the field in. Reading it is never enough top-level, + * because it says nothing about whether this browser holds the wallet. */ async function resolveIdentity(): Promise { const already = getCurrentUser(); if (already !== null) { + // The caller took charge of the identity itself — the escape hatch the "no shared + // wallet configured" error below names. It is not a person arriving at a page, so + // there is nobody to show a barrier to, and no hand-over to protect: honour it and + // just put it where the round-trip will find it. Nothing an application ships does + // this; the harnesses that drive the surface directly do. rememberIdentity(already); return already; } const known = storedIdentity(); - if (known) { - // Even though `storedIdentity()` just read it: what it read may have come from THIS - // partition's storage, which the round-trip does not carry. The address bar does. + if (known !== null && insideBroker()) { + // Inside the iframe the round-trip has already happened: the identifier came across in + // the URL and a session is on its way. Stand aside. + // + // Remembered again even though `storedIdentity()` just read it: what it read may have + // come from THIS partition's storage, which the round-trip does not carry. The address + // bar does. rememberIdentity(known); adoptCurrentUser(known); return known; @@ -347,7 +455,7 @@ async function resolveIdentity(): Promise { ); } - const chosen = await askForIdentity(cfg); + const chosen = await askForIdentity(cfg, known ?? ""); const normalized = normalizeIdentity(chosen); rememberIdentity(normalized); adoptCurrentUser(normalized); @@ -362,8 +470,11 @@ async function resolveIdentity(): Promise { * the day the wallet supplies the identity, this resolves without showing anything and * the caller's code is unchanged. * - * A returning user never sees the gate: the identifier survives the broker round-trip in - * the URL, and a plain reload finds it in storage. + * A returning user sees the barrier again, with their identifier already in the field — + * one click, no typing. It is not shown because they are unknown (they are not); it is + * shown because a top-level page is one redirect away from a dead end for anyone whose + * browser has lost the wallet, and this screen is the only place that hands it back out. + * Past the round-trip, inside the iframe, it never appears. * * **It no longer has to be called before `init()`** — the order is structural now. Whatever * settles the identity leaves `?ng-id=` in the address bar, and `init()` hands the broker diff --git a/packages/polyfill/test/access-gate.test.ts b/packages/polyfill/test/access-gate.test.ts index ea93a70..346e368 100644 --- a/packages/polyfill/test/access-gate.test.ts +++ b/packages/polyfill/test/access-gate.test.ts @@ -25,17 +25,43 @@ function fakeStorage(initial: Record = {}) { }; } -/** Put the page in a given URL + storage state, as the browser would. */ -function inPage(search: string, storage: ReturnType) { +/** + * Which side of the broker frontier a page is on — the thing that decides whether the + * barrier shows at all, so no page double may leave it unsaid. + */ +type Side = "top-level" | "in the broker iframe"; + +/** + * The `window` a page of that side presents. + * + * `window.self !== window.top` is the signal the gate reads, and it is the one + * `@ng-org/web`'s `init()` reads to decide whether to hand the page over. A double that + * omitted `window` would silently be a top-level page — and every framed assertion here + * would be quietly testing the other context. + */ +function fakeWindow(side: Side, onEvent?: (type: string, fn: (e: unknown) => void) => void) { + const self = {}; + return { + self, + top: side === "top-level" ? self : {}, + addEventListener: (type: string, fn: (e: unknown) => void): void => onEvent?.(type, fn), + }; +} + +/** Put the page in a given URL + storage state, on a given side, as the browser would. */ +function inPage(search: string, storage: ReturnType, side: Side) { const location = { search, href: "https://app.example" + search }; - (globalThis as any).location = location; - (globalThis as any).localStorage = storage; - (globalThis as any).history = { replaceState: () => {} }; + Object.assign(globalThis, { + location, + localStorage: storage, + history: { replaceState: (): void => {} }, + window: fakeWindow(side), + }); 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; +const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; afterEach(() => { setCurrentUser(null); @@ -57,8 +83,10 @@ function configured() { } test("an identity already set is left alone — the gate never re-asks", async () => { + // The escape hatch: a caller that took charge of the identity itself is not a person + // arriving at a page, so there is nobody to show a barrier to — top-level or not. configured(); - inPage("", fakeStorage()); + inPage("", fakeStorage(), "top-level"); setCurrentUser("alice"); await ensureIdentity(); expect(getCurrentUser()).toBe("alice"); @@ -70,7 +98,7 @@ test("the URL parameter WINS over storage — it is the only thing that crosses // 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" })); + inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("fromurl"); }); @@ -78,14 +106,16 @@ test("the URL parameter WINS over storage — it is the only thing that crosses 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); + inPage("?ng-id=carol", storage, "in the broker iframe"); await ensureIdentity(); expect(storage.getItem(KEY)).toBe("carol"); }); -test("with no parameter, storage answers — a reload does not re-ask", async () => { +test("inside the iframe, with no parameter, storage answers — the barrier stays away", async () => { + // The round-trip is behind us here, so a known identifier settles the question outright. + // Top-level it does not, and the pair below says so. configured(); - inPage("", fakeStorage({ [KEY]: "dana" })); + inPage("", fakeStorage({ [KEY]: "dana" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("dana"); }); @@ -94,13 +124,13 @@ 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()); + inPage("", fakeStorage(), "top-level"); 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()); + inPage("", fakeStorage(), "top-level"); await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i); }); @@ -110,14 +140,14 @@ test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one s // 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()); + inPage("?ng-id=@Erin", fakeStorage(), "in the broker iframe"); 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" })); + inPage("", fakeStorage({ [KEY]: "@Frank" }), "in the broker iframe"); await ensureIdentity(); expect(getCurrentUser()).toBe("frank"); }); @@ -147,12 +177,13 @@ function fakeElement() { return { value: "", disabled: false, + textContent: "", 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({}); + fire(type: string, event: unknown = {}): void { + for (const fn of handlers.get(type) ?? []) fn(event); }, }; } @@ -186,12 +217,28 @@ function fakeDom() { get shown(): boolean { return mounted > 0; }, + /** What the field holds before anyone touches it — the prefill, seen as a user sees it. */ + get prefilled(): string { + return input.value; + }, + /** The button's label and whether it can be pressed — the frozen state, once entered. */ + get button(): { label: string; disabled: boolean } { + return { label: go.textContent, disabled: go.disabled }; + }, /** 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"); }, + /** What a returning user does: press Entrer on the field as they found it. */ + confirm(): void { + go.fire("click"); + }, + /** A press of Enter in the field — which a real one still takes once the button is dead. */ + pressEnter(): void { + input.fire("keydown", { key: "Enter" }); + }, }; } @@ -203,26 +250,48 @@ function fakeDom() { * `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 }) { +function inBrowser(opts: { url: string; storage?: ReturnType; side?: Side }) { const storage = opts.storage ?? fakeStorage(); const dom = fakeDom(); + const side = opts.side ?? "top-level"; let href = opts.url; + let reloads = 0; const location = { get href(): string { return href; }, set href(next: string) { href = next; }, get search(): string { return new URL(href).search; }, + reload: (): void => void (reloads += 1), }; const history = { replaceState: (_state: unknown, _title: string, next: string): void => void (href = next), }; + // The page-level listeners the gate installs, kept so a test can play the browser event + // that fires them. `pageshow` is the only one, and it cannot be simulated any other way. + const listeners = new Map void)[]>(); Object.assign(globalThis, { location, history, localStorage: storage, document: dom.document, + window: fakeWindow(side, (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn])), }); - return { location, storage, dom }; + return { + location, + storage, + dom, + /** How many times the page asked the browser to reload it. */ + get reloads(): number { return reloads; }, + /** Is anything listening for the browser restoring this page from its cache? */ + get armed(): boolean { return (listeners.get("pageshow") ?? []).length > 0; }, + /** + * The browser restoring this page — from its back/forward cache (`persisted`) or + * loading it afresh. Only the first is a return from a hand-over that did not complete. + */ + pageshow(persisted: boolean): void { + for (const fn of listeners.get("pageshow") ?? []) fn({ persisted }); + }, + }; } test("settled at the GATE: the address bar carries the identifier `init()` will hand over", async () => { @@ -231,6 +300,9 @@ test("settled at the GATE: the address bar carries the identifier `init()` will const settled = ensureIdentity(); expect(page.dom.shown).toBe(true); + // A first access knows nothing, so the field is bare — the prefill below has to be able + // to fail, and it cannot if the field is filled whatever happened. + expect(page.dom.prefilled).toBe(""); page.dom.submit("Gina"); expect(await settled).toBe("gina"); @@ -243,18 +315,30 @@ test("settled from STORAGE: the address bar gains the identifier — the silent // 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. + // + // Top-level, being recognised is not the same as being let through: the barrier shows, + // with the field already filled, and the identifier reaches the address bar when the + // person confirms it. configured(); const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); - expect(await ensureIdentity()).toBe("dana"); + const settled = ensureIdentity(); + expect(page.dom.prefilled).toBe("dana"); + page.dom.confirm(); + expect(await settled).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. + // The field shows the normalized form too: it is the identifier, not the spelling of the + // link, that names the space they are about to enter. configured(); const page = inBrowser({ url: APP + "?ng-id=@Erin" }); - expect(await ensureIdentity()).toBe("erin"); + const settled = ensureIdentity(); + expect(page.dom.prefilled).toBe("erin"); + page.dom.confirm(); + expect(await settled).toBe("erin"); expect(page.location.href).toBe(APP + "?ng-id=erin"); }); @@ -266,6 +350,147 @@ test("settled by the CALLER: an identity set before the call still reaches the a expect(page.location.href).toBe(APP + "?ng-id=alice"); }); +// ── Which side of the frontier decides whether the barrier shows ─────────── +// +// Not whether the identity is known. A top-level page is one redirect away from the +// broker, and someone whose browser has lost the wallet — or never had it, the identifier +// having reached them by link — lands there on a static dead end with no way back but the +// browser's own Back button. The barrier is the only screen that hands the wallet out, so +// it shows on every top-level load and stands aside only past the round-trip, inside the +// iframe. Keying it on identity presence is what made it skip itself, silently, for +// exactly the people who needed it. + +test("TOP-LEVEL, a known identifier fills the field — it does not skip the barrier", async () => { + // The regression this pair exists for. `dana` is known from storage, and the barrier + // shows anyway, prefilled: one click, no typing, and the wallet still on offer above it. + configured(); + const page = inBrowser({ url: APP, storage: fakeStorage({ [KEY]: "dana" }) }); + const settled = ensureIdentity(); + + expect(page.dom.shown).toBe(true); + expect(page.dom.prefilled).toBe("dana"); + + page.dom.confirm(); + expect(await settled).toBe("dana"); +}); + +test("INSIDE THE IFRAME, the same known identifier stands the barrier down", async () => { + // The other half, and what makes the one above an assertion rather than a tautology: + // same identity, same storage, a DOM perfectly able to show a barrier — and no barrier, + // because the round-trip is behind us. A returning user asked to confirm again on the + // far side would be answering the same question twice for nothing. + configured(); + const page = inBrowser({ + url: APP + "?ng-id=dana", + storage: fakeStorage({ [KEY]: "dana" }), + side: "in the broker iframe", + }); + expect(await ensureIdentity()).toBe("dana"); + expect(page.dom.shown).toBe(false); +}); + +test("inside the iframe, a page that knows NOBODY still asks — the safe failure", async () => { + // The last resort, and it must stay: an iframe reached without the parameter has no + // identity to act as, and continuing would provision an anonymous virtual space that + // looks like a working application. Asking is the honest outcome. + configured(); + const page = inBrowser({ url: APP, side: "in the broker iframe" }); + const settled = ensureIdentity(); + expect(page.dom.shown).toBe(true); + page.dom.submit("Hana"); + expect(await settled).toBe("hana"); +}); + +// ── Coming BACK from a hand-over that dead-ended ────────────────────────── +// +// The broker sends a walletless visitor to a static page with no return path. Back is +// their only way out, and it restores this document from the browser's cache exactly as +// it left — barrier frozen, button dead, `init()` already delegated and never redirecting +// again. Without a reset there is nothing on that page left to press. + +test("a top-level page that has been handed over holds its barrier, frozen", async () => { + configured(); + const page = inBrowser({ url: APP }); + const settled = ensureIdentity(); + page.dom.submit("Gina"); + await settled; + + // Not removed: the hand-over is a navigation, and a bare page is what the person would + // come back to. This is also the state the reset below un-sticks. + expect(page.dom.shown).toBe(true); + expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true }); + expect(page.armed).toBe(true); +}); + +test("restored from the browser's cache, it reloads — the button comes back to life", async () => { + configured(); + const page = inBrowser({ url: APP }); + const settled = ensureIdentity(); + page.dom.submit("Gina"); + await settled; + + page.pageshow(true); + // A reload re-runs `init()`, which settles again and puts the barrier back up prefilled + // from the address bar — the only reset available, since the redirect belongs to the + // level below and has already happened. + expect(page.reloads).toBe(1); +}); + +test("a barrier already answered ignores a second Enter — one return listener, one reload", async () => { + // The button is dead by then, and a dead button dispatches no click — but the field + // still takes the key. Twice armed would be twice reloaded on the way back. + configured(); + const page = inBrowser({ url: APP }); + const settled = ensureIdentity(); + page.dom.submit("Gina"); + await settled; + + page.dom.pressEnter(); + page.pageshow(true); + expect(page.reloads).toBe(1); +}); + +test("an ordinary load is not a return — it must not reload", async () => { + // `pageshow` fires on every load, cached or not. Reloading on the plain one would loop + // the page forever, which is a worse failure than the one being fixed. + configured(); + const page = inBrowser({ url: APP }); + const settled = ensureIdentity(); + page.dom.submit("Gina"); + await settled; + + page.pageshow(false); + expect(page.reloads).toBe(0); +}); + +test("someone still AT the barrier is not reset — what they typed survives", async () => { + // Armed on the way OUT, not at load: a person who wandered off before confirming comes + // back to the page they left, not to a reloaded one that lost their identifier. So the + // arming has to straddle the confirmation, and this pins both sides of it. + configured(); + const page = inBrowser({ url: APP }); + const settled = ensureIdentity(); + expect(page.armed).toBe(false); + + // A barrier left unanswered would hold the settling in flight and every later test would + // JOIN it instead of raising its own — so answer it, always. + page.dom.submit("Gina"); + await settled; + expect(page.armed).toBe(true); +}); + +test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => { + configured(); + const page = inBrowser({ url: APP, side: "in the broker iframe" }); + const settled = ensureIdentity(); + page.dom.submit("Hana"); + await settled; + + // And the barrier IS removed here: the application renders behind it. + expect(page.dom.shown).toBe(false); + expect(page.armed).toBe(false); +}); + 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 @@ -300,7 +525,9 @@ test("an address bar that cannot be rewritten fails safely — the gate asks aga }, }, }); - expect(await ensureIdentity()).toBe("dana"); + const settled = ensureIdentity(); + page.dom.confirm(); + expect(await settled).toBe("dana"); expect(page.storage.getItem(KEY)).toBe("dana"); expect(page.location.href).toBe(APP); }); diff --git a/packages/polyfill/test/lifecycle.test.ts b/packages/polyfill/test/lifecycle.test.ts index bec7287..b431928 100644 --- a/packages/polyfill/test/lifecycle.test.ts +++ b/packages/polyfill/test/lifecycle.test.ts @@ -33,22 +33,32 @@ function fakeStorage(initial: Record = {}) { }; } +/** + * Which side of the broker frontier the page is on. It decides whether the barrier shows + * at all — top-level it always does, past the round-trip it never does — so no page here + * may leave it unsaid: `window.self !== window.top` is what the gate reads, and a double + * without a `window` would silently be top-level. + */ +type Side = "top-level" | "in the broker iframe"; + /** * A browser page, faithful in the one way these assertions depend on: `location.search` * tracks `href`, and `history.replaceState` MOVES `href` — as a real browser does, and as * the real `init()` relies on when it reads the address bar. A double whose `replaceState` * did nothing would let a broken `init()` pass. */ -function inBrowser(url: string, storage: ReturnType) { +function inBrowser(url: string, storage: ReturnType, side: Side) { let href = url; const location = { get href(): string { return href; }, get search(): string { return new URL(href).search; }, }; + const self = {}; Object.assign(globalThis, { location, history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) }, localStorage: storage, + window: { self, top: side === "top-level" ? self : {}, addEventListener: (): void => {} }, }); return { location }; } @@ -99,10 +109,14 @@ function fakeDom() { input.fire("input"); go.fire("click"); }, + /** What a returning user does: press Entrer on the field as they found it, prefilled. */ + confirm(): void { + go.fire("click"); + }, }; } -const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const; +const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; afterEach(() => { setCurrentUser(null); @@ -170,11 +184,20 @@ test("the injected `init` finds the identifier ALREADY in the address bar", asyn // wrapper that delegated first and settled after would satisfy any ordering check and // still send the round-trip off bare. Identity from storage is the case that bites — // storage does not cross the broker's partition, the URL does. + // + // Top-level, which is the only side that hands over at all, so Hana meets the barrier + // with her identifier already in the field and confirms it. What must be true when the + // delegate is finally called is unchanged: the address bar already carries her. const wiring = consumerWiring(); configured(wiring); - inBrowser(APP, fakeStorage({ [KEY]: "hana" })); + inBrowser(APP, fakeStorage({ [KEY]: "hana" }), "top-level"); + const dom = fakeDom(); + Object.assign(globalThis, { document: dom.document }); - await within(init(() => {}, true, [])); + const delegated = init(() => {}, true, []); + await Promise.resolve(); + dom.confirm(); + await within(delegated); expect(wiring.calls.length).toBe(1); expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=hana"); @@ -186,9 +209,12 @@ test("it resolves though the session exists only AFTER it delegates — the cycl // `session()` → the consumer's thunk → a promise only `init()`'s callback resolves. Await // that here and nothing ever runs: `init` is never called, so the session never arrives, // so the wait never ends. `init()` therefore awaits the SESSION-FREE half only. + // + // Inside the iframe, which is where the cycle actually closes: the identifier arrived in + // the URL, the barrier stands aside, and delegating is what opens the session. const wiring = consumerWiring(); configured(wiring); - inBrowser(APP + "?ng-id=iris", fakeStorage()); + inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe"); await within(init(() => {}, true, [])); @@ -204,7 +230,7 @@ test("a settling failure rejects — the page is not handed to the broker withou // is still visible. const wiring = consumerWiring(); configured(wiring, { sharedWallet: false }); - inBrowser(APP, fakeStorage()); + inBrowser(APP, fakeStorage(), "top-level"); await expect(init(() => {}, true, [])).rejects.toThrow(/no shared wallet configured/i); expect(wiring.calls.length).toBe(0); @@ -218,7 +244,7 @@ test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two", // it never hands the page to the broker — the application hangs before it ever loads. const wiring = consumerWiring(); configured(wiring); - inBrowser(APP, fakeStorage()); + inBrowser(APP, fakeStorage(), "top-level"); const dom = fakeDom(); Object.assign(globalThis, { document: dom.document }); @@ -239,7 +265,7 @@ test("arguments and return value pass through untouched — it is still a forwar // would be a difference the application has to unlearn at migration. const wiring = consumerWiring(); configured(wiring); - inBrowser(APP, fakeStorage()); + inBrowser(APP, fakeStorage(), "top-level"); setCurrentUser("juno"); const callback = (): void => {}; diff --git a/packages/polyfill/test/sign-in-connects.test.ts b/packages/polyfill/test/sign-in-connects.test.ts index 653a356..e172812 100644 --- a/packages/polyfill/test/sign-in-connects.test.ts +++ b/packages/polyfill/test/sign-in-connects.test.ts @@ -40,8 +40,16 @@ function fakeStorage() { }; } -/** A page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on. */ -function inBrowser(url: string): void { +/** + * A page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on. + * + * INSIDE THE BROKER IFRAME (`window.self !== window.top`), and that is not decoration: it + * is the side of the frontier this whole file describes. The identifier has already crossed + * in the URL, so the barrier stands aside; and the session exists only here, opened by the + * callback `init()` is given. Top-level, `init()` navigates away and no session is ever + * established, so the deadlock pinned below could not even be reached. + */ +function inBrokerIframe(url: string): void { let href = url; Object.assign(globalThis, { location: { @@ -50,10 +58,11 @@ function inBrowser(url: string): void { }, history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) }, localStorage: fakeStorage(), + window: { self: {}, top: {}, addEventListener: (): void => {} }, }); } -const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const; +const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const; afterEach(() => { setCurrentUser(null); @@ -108,7 +117,7 @@ function bootTheApplication(identifier: string) { sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, }); - inBrowser(`${APP}?ng-id=${encodeURIComponent(identifier)}`); + inBrokerIframe(`${APP}?ng-id=${encodeURIComponent(identifier)}`); let delegated!: Promise; sessionReady = new Promise((resolve) => {