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.
This commit is contained in:
@@ -27,18 +27,21 @@ function fakeStorage(initial: Record<string, string> = {}) {
|
||||
|
||||
/** Put the page in a given URL + storage state, as the browser would. */
|
||||
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
|
||||
(globalThis as any).location = { search, href: "https://app.example" + search };
|
||||
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();
|
||||
delete (globalThis as any).location;
|
||||
delete (globalThis as any).localStorage;
|
||||
delete (globalThis as any).history;
|
||||
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
|
||||
});
|
||||
|
||||
function configured() {
|
||||
@@ -118,3 +121,186 @@ test("a stored value is normalized too — an old entry cannot key onto a second
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user