fc3c129bd3
@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.
307 lines
12 KiB
TypeScript
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);
|
|
});
|