Files
ng-eventually/packages/polyfill/test/access-gate.test.ts
T
Sylvain Duchesne fc3c129bd3 fix: l'identifiant est dans l'URL avant qu'init() ne l'emporte
@ng-org/web fait déjà la redirection vers le broker — même hôte, même forme,
même test de cadre (dist/ngweb.js). Le polyfill n'a donc rien à réimplémenter
là : ce qui lui revient, c'est la seule chose qu'init() ne peut pas faire, à
savoir mettre ?ng-id= dans window.location.href avant qu'il ne le lise.

rememberIdentity() s'exécute désormais sur les trois chemins — identité posée
par l'appelant, venue du stockage, ou saisie à la barrière. Le cas du stockage
était le silencieux : le paramètre restait absent, l'iframe lisait une identité
vide et provisionnait un second espace virtuel, sans erreur.

L'écriture dans le stockage et celle dans la barre d'adresse sont deux try
indépendants : un stockage qui refuse d'écrire ne doit pas emporter avec lui le
paramètre, qui est le seul à franchir la frontière de partition.

Le contrat retire l'obligation « être ouverte via la redirection du broker » :
elle n'a jamais été celle de l'application. Ni broker, ni iframe, ni redirection
n'y sont plus nommés.
2026-08-11 11:59:57 +02:00

307 lines
12 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 { 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; },
};
}
/** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
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<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,
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<typeof fakeStorage> }) {
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);
});