55714d0a23
Le contrat faisait porter à l'application trois choses qui sont des artefacts de notre implémentation, pas de la cible. Le rechargement de page. Au retour depuis le cache du navigateur, la barrière se rechargeait pour rejouer init() — et détruisait au passage l'état de l'application, qui ne pouvait ni s'y opposer ni nettoyer avant. Le paquet détenait pourtant ce qu'il fallait : la fonction init injectée et le callback de l'appelant. Il enregistre désormais sa délégation, ranime sa barrière au retour — champ conservé, bouton réactivé — et redélègue à la confirmation. Rien hors de la barrière n'est touché. Vérifié dans le bundle amont : en page de tête, init navigue à chaque appel, sa garde « une seule fois » ne portant que sur la branche iframe. L'ordre d'appel silencieux. ensureIdentity() attendu avant init() ne se résolvait jamais, sans erreur. Le paquet possédant la session, il distingue maintenant les deux cas sans délai ni heuristique : session pas encore arrivée → il attend ; init jamais appelé → elle n'arrivera pas, il lève en nommant l'appel à faire d'abord. Et la clause qui annonçait la barrière était rangée dans les exigences de déploiement, alors qu'une application n'y peut rien. Elle passe dans les garanties, avec ce qui la remplace : la page n'est jamais rechargée. Il reste deux lignes d'exigences : servir le fichier de portefeuille, et appeler init avant d'attendre l'identité — ce qui échoue désormais bruyamment.
620 lines
27 KiB
TypeScript
620 lines
27 KiB
TypeScript
/**
|
|
* 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 { init } from "../src/surface/lifecycle";
|
|
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<string, string> = {}) {
|
|
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; },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<typeof fakeStorage>, side: Side) {
|
|
const location = { search, href: "https://app.example" + search };
|
|
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", "window"] as const;
|
|
|
|
afterEach(() => {
|
|
setCurrentUser(null);
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
|
|
});
|
|
|
|
function configured(injectedInit?: (...args: unknown[]) => unknown) {
|
|
configure({
|
|
ng: {} as never,
|
|
useShape: (() => {}) as never,
|
|
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
|
// Only where a test needs a hand-over to come back FROM: the delegation is what the
|
|
// polyfill's `init()` registers, and a page that never delegated was never handed over.
|
|
...(injectedInit ? { init: injectedInit } : {}),
|
|
});
|
|
// AFTER `configure`, which wires the registry onto the package's own session — a session
|
|
// that only `init()` can open, and no page here calls it. The substitution is what lets
|
|
// these tests reach the gate without a broker, and it must be the last word.
|
|
configureStoreRegistry({
|
|
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
|
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
});
|
|
}
|
|
|
|
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(), "top-level");
|
|
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" }), "in the broker iframe");
|
|
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, "in the broker iframe");
|
|
await ensureIdentity();
|
|
expect(storage.getItem(KEY)).toBe("carol");
|
|
});
|
|
|
|
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" }), "in the broker iframe");
|
|
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(), "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(), "top-level");
|
|
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(), "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" }), "in the broker iframe");
|
|
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<void> {
|
|
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<string, ((e: unknown) => void)[]>();
|
|
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, event: unknown = {}): void {
|
|
for (const fn of handlers.get(type) ?? []) fn(event);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 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" });
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<typeof fakeStorage>; 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<string, ((e: unknown) => 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,
|
|
/**
|
|
* How many times the page asked the browser to reload it — which must stay at zero.
|
|
* A reload throws away everything the application holds in memory, and it cannot opt
|
|
* out, observe it, or clean up first; the barrier is this package's screen, so putting
|
|
* it back is this package's business and nothing else on the page may be disturbed.
|
|
*/
|
|
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; },
|
|
/** How many such listeners — one hand-over or ten, the page needs exactly one. */
|
|
get armings(): number { return (listeners.get("pageshow") ?? []).length; },
|
|
/**
|
|
* 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 () => {
|
|
configured();
|
|
const page = inBrowser({ url: APP });
|
|
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");
|
|
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.
|
|
//
|
|
// 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" }) });
|
|
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" });
|
|
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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
// ── 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.
|
|
//
|
|
// The reset used to be `location.reload()`, and it was this package charging its own
|
|
// scaffolding to the application: a reload destroys whatever the application held in
|
|
// memory, and the application cannot opt out of it, observe it, or clean up first. So the
|
|
// barrier — which is entirely ours — is what comes back, and the page is left alone.
|
|
//
|
|
// Every test below therefore goes through the polyfill's `init()`: the hand-over IS that
|
|
// delegation, so a page that never delegated has nothing to come back from, and a fixture
|
|
// that played `pageshow` at it would be replaying a state no browser produces.
|
|
|
|
/**
|
|
* A page wired the way an application wires one, with the injected `init` recording what
|
|
* the address bar said each time it was handed over — which is what the broker would carry.
|
|
*
|
|
* It records rather than navigates: a delegation that really navigated would end the test
|
|
* (and the document), and what is being judged here is that the SECOND one happens at all.
|
|
*/
|
|
function handingOverFrom(opts: { url: string; storage?: ReturnType<typeof fakeStorage>; side?: Side }) {
|
|
const handOvers: string[] = [];
|
|
configured((..._args: unknown[]) => {
|
|
handOvers.push(String((globalThis as { location?: { href: string } }).location?.href));
|
|
return Promise.resolve();
|
|
});
|
|
const page = inBrowser(opts);
|
|
return { page, handOvers };
|
|
}
|
|
|
|
test("a top-level page that has been handed over holds its barrier, frozen", async () => {
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
|
|
expect(handOvers.length).toBe(1);
|
|
// 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 revival 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, the BARRIER comes back to life — the page is not reloaded", async () => {
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
expect(handOvers.length).toBe(1);
|
|
|
|
page.pageshow(true);
|
|
|
|
// Nothing outside the barrier is touched: no reload, so whatever the application held in
|
|
// memory is still there, and it never had to be told to hold nothing.
|
|
expect(page.reloads).toBe(0);
|
|
// And the barrier is usable again — the same screen, the field as they left it, the
|
|
// button live and labelled to be pressed rather than frozen on what already failed.
|
|
expect(page.dom.shown).toBe(true);
|
|
expect(page.dom.prefilled).toBe("Gina");
|
|
expect(page.dom.button).toEqual({ label: "Entrer", disabled: false });
|
|
});
|
|
|
|
test("confirming the revived barrier hands the page over AGAIN — which is the way out", async () => {
|
|
// The point of reviving it. A live button that led nowhere would be the same dead end
|
|
// with a friendlier face: what the person needs is the hand-over to happen again, and
|
|
// `init()` cannot do it — it delegated once and its promise is long answered.
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
|
|
page.pageshow(true);
|
|
page.dom.confirm();
|
|
|
|
expect(handOvers).toEqual([APP + "?ng-id=gina", APP + "?ng-id=gina"]);
|
|
expect(page.reloads).toBe(0);
|
|
// Handing over again freezes it again — a second round-trip is in flight, and the same
|
|
// listener is still there for a second return.
|
|
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
|
|
});
|
|
|
|
test("someone who comes back to change their identifier leaves with the NEW one", async () => {
|
|
// Not a hypothetical: the dead end they came back from is exactly where a person
|
|
// discovers they entered the wrong identifier. So the second confirmation is a full
|
|
// settling — normalized, in the address bar, and the identity the page now acts as —
|
|
// and not a replay of the value that left.
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
|
|
page.pageshow(true);
|
|
page.dom.submit("Hank");
|
|
|
|
expect(page.location.href).toBe(APP + "?ng-id=hank");
|
|
expect(page.storage.getItem(KEY)).toBe("hank");
|
|
expect(getCurrentUser()).toBe("hank");
|
|
expect(handOvers[1]).toBe(APP + "?ng-id=hank");
|
|
expect(page.reloads).toBe(0);
|
|
});
|
|
|
|
test("a barrier handing over ignores a second Enter — one listener, one hand-over", async () => {
|
|
// The button is dead by then, and a dead button dispatches no click — but the field
|
|
// still takes the key. Twice acted on would be twice handed over, and twice armed would
|
|
// leave a listener behind on every round-trip.
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
|
|
page.dom.pressEnter();
|
|
|
|
expect(handOvers.length).toBe(1);
|
|
expect(page.armings).toBe(1);
|
|
});
|
|
|
|
test("an ordinary load is not a return — the barrier is left frozen", async () => {
|
|
// `pageshow` fires on every load, cached or not. Reviving on the plain one would arm the
|
|
// button of a page whose hand-over is still on its way out, and offer a second one.
|
|
const { page, handOvers } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Gina");
|
|
await delegated;
|
|
|
|
page.pageshow(false);
|
|
|
|
expect(page.reloads).toBe(0);
|
|
expect(page.dom.button).toEqual({ label: "Accès en cours…", disabled: true });
|
|
page.dom.confirm();
|
|
expect(handOvers.length).toBe(1);
|
|
});
|
|
|
|
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, with the field as they left it and the button still live.
|
|
// So the arming has to straddle the confirmation, and this pins both sides of it.
|
|
const { page } = handingOverFrom({ url: APP });
|
|
const delegated = init(() => {}, true, []);
|
|
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 delegated;
|
|
expect(page.armed).toBe(true);
|
|
});
|
|
|
|
test("inside the iframe nothing is armed — there was no hand-over to come back from", async () => {
|
|
const { page } = handingOverFrom({ url: APP, side: "in the broker iframe" });
|
|
const delegated = init(() => {}, true, []);
|
|
page.dom.submit("Hana");
|
|
await delegated;
|
|
|
|
// 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
|
|
// 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");
|
|
},
|
|
},
|
|
});
|
|
const settled = ensureIdentity();
|
|
page.dom.confirm();
|
|
expect(await settled).toBe("dana");
|
|
expect(page.storage.getItem(KEY)).toBe("dana");
|
|
expect(page.location.href).toBe(APP);
|
|
});
|