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.
425 lines
19 KiB
TypeScript
425 lines
19 KiB
TypeScript
/**
|
|
* `init()` — the hand-over to the broker, and the one thing that must happen before it.
|
|
*
|
|
* The real `@ng-org/web` `init()` navigates a top-level page to NextGraph's login as its
|
|
* FIRST statement, carrying `window.location.href` AS IT FINDS IT. Everything the
|
|
* application would have done afterwards runs in a document that no longer exists. So an
|
|
* application that signed in on the next line never signed in at all: no barrier, no
|
|
* identifier in the URL that crossed, and a first-time user parked on a login with no
|
|
* wallet and no error anywhere.
|
|
*
|
|
* The library's `init()` therefore settles the identity BEFORE it delegates. That order is
|
|
* carried by composition, not by an instruction to a caller — so what these tests pin is
|
|
* what the injected `init` OBSERVES when it is called, never the sequence of calls: a
|
|
* regression that delegated first would still call things in the right order, and would
|
|
* still hand over a bare URL.
|
|
*/
|
|
import { test, expect, afterEach } from "bun:test";
|
|
import { configure, ensureIdentity } from "../src/index";
|
|
import { init } from "../src/surface/lifecycle";
|
|
import {
|
|
configureStoreRegistry,
|
|
resetConfig,
|
|
resetStoreRegistry,
|
|
setCurrentUser,
|
|
} from "../src/shared-wallet/bootstrap";
|
|
import { sharedWalletSession } from "../src/shared-wallet/session";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
|
|
const KEY = "ng-eventually:identity";
|
|
const APP = "https://app.example/";
|
|
|
|
/** 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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<typeof fakeStorage>, 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 };
|
|
}
|
|
|
|
/** One element of the barrier, 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 that COUNTS the barriers mounted on it — the one thing this file needs from a
|
|
* document. It does not parse markup, so `querySelector` hands back the elements it was
|
|
* built with; what the markup contains is `access-gate.test.ts`'s subject and the e2e's.
|
|
*/
|
|
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) } },
|
|
/** How many barriers are on screen right now. */
|
|
get mounted(): number {
|
|
return mounted;
|
|
},
|
|
/** What a user does at the barrier: 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, prefilled. */
|
|
confirm(): void {
|
|
go.fire("click");
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
/** The event the real `init` sends its callback once the broker answers — `ngweb.js:124`. */
|
|
const BROKER_SESSION = { session_id: "s", private_store_id: "did:ng:o:p" };
|
|
const loggedIn = (session: Record<string, unknown> = BROKER_SESSION) => ({
|
|
status: "loggedin",
|
|
session,
|
|
});
|
|
|
|
/**
|
|
* The consumer's real wiring, reduced to the cycle it creates.
|
|
*
|
|
* The session exists only from `init()`'s callback: nothing else in the system opens one,
|
|
* so before `init()` runs there is none and there cannot be. That is why the injected
|
|
* `init` here is what produces it — a session that answered straight away would be a state
|
|
* the real system never reaches, and it is precisely the state under which the deadlock
|
|
* below is invisible.
|
|
*
|
|
* It produces it the way the real one does: by calling the callback it was HANDED, once,
|
|
* with `{ status: "loggedin", session }`. Since 2026-08-12 that callback is the library's
|
|
* wrapper, so this also exercises the capture; `sessionReady` here is this fixture's own
|
|
* view of the same instant, kept so the assertions can name it.
|
|
*
|
|
* The spy records what the real `init()` reads at the moment it is called — the address
|
|
* bar — and returns a promise, as the real one does.
|
|
*/
|
|
function consumerWiring(session: Record<string, unknown> = BROKER_SESSION) {
|
|
let arrived!: (s: RegistrySession) => void;
|
|
const sessionReady = new Promise<RegistrySession>((resolve) => { arrived = resolve; });
|
|
const calls: { href: string; args: unknown[] }[] = [];
|
|
const returned = { itsOwnReturnValue: true };
|
|
const injectedInit = (...args: unknown[]): Promise<unknown> => {
|
|
calls.push({ href: String((globalThis as { location?: { href: string } }).location?.href), args });
|
|
const callback = args[0];
|
|
if (typeof callback === "function") void (callback as (e: unknown) => unknown)(loggedIn(session));
|
|
arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" });
|
|
return Promise.resolve(returned);
|
|
};
|
|
return { sessionReady, calls, injectedInit, returned, getSession: () => sessionReady };
|
|
}
|
|
|
|
function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWallet?: boolean } = {}) {
|
|
configure({
|
|
ng: {} as never,
|
|
useShape: (() => {}) as never,
|
|
init: wiring.injectedInit,
|
|
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
|
|
});
|
|
// The registry reaches the session through the internal wiring path, pointed at THIS
|
|
// fixture's promise — which, like the package's own, only `init()` can resolve. Without
|
|
// that the deadlock test below would be measuring a session that arrives by itself.
|
|
configureStoreRegistry({
|
|
getSession: wiring.getSession,
|
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Turn a hang into a readable failure. NOT part of the mechanism — nothing in the library
|
|
* races or times out; this only says WHY a test stopped, instead of leaving the runner to
|
|
* report a bare timeout on a test whose whole subject is a deadlock.
|
|
*/
|
|
function within<T>(p: Promise<T>): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const deadline = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(new Error("init() never resolved — it is waiting on the session it is itself supposed to open")),
|
|
1000,
|
|
);
|
|
});
|
|
return Promise.race([p, deadline]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
test("the injected `init` finds the identifier ALREADY in the address bar", async () => {
|
|
// The assertion is on what the delegate OBSERVES, not on call order: the real `init()`
|
|
// reads `window.location.href` at its first statement and carries it to the broker, so a
|
|
// 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" }), "top-level");
|
|
const dom = fakeDom();
|
|
Object.assign(globalThis, { document: dom.document });
|
|
|
|
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");
|
|
});
|
|
|
|
test("it resolves though the session exists only AFTER it delegates — the cycle, pinned", async () => {
|
|
// The deadlock this split exists to prevent, reproduced exactly. Signing in fully
|
|
// (`ensureIdentity`) awaits the connection work → `resolveAccount` → `lookupAccount` →
|
|
// `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(), "in the broker iframe");
|
|
|
|
await within(init(() => {}, true, []));
|
|
|
|
expect(wiring.calls.length).toBe(1);
|
|
// And the direction the cycle was broken in: delegating is what made the session exist.
|
|
await expect(within(wiring.sessionReady)).resolves.toMatchObject({ sessionId: "s" });
|
|
});
|
|
|
|
test("a settling failure rejects — the page is not handed to the broker without an identity", async () => {
|
|
// Nothing known, and no shared wallet to hand the user one. Delegating anyway IS the
|
|
// defect: the page navigates to a login the user cannot complete and cannot come back
|
|
// from, silently. Failing at the call the application made is the only place the cause
|
|
// is still visible.
|
|
const wiring = consumerWiring();
|
|
configured(wiring, { sharedWallet: false });
|
|
inBrowser(APP, fakeStorage(), "top-level");
|
|
|
|
await expect(init(() => {}, true, [])).rejects.toThrow(/no shared wallet configured/i);
|
|
expect(wiring.calls.length).toBe(0);
|
|
});
|
|
|
|
test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two", async () => {
|
|
// Exactly the reference application's bootstrap: `init()` at module load, then `signIn()`
|
|
// awaiting `ensureIdentity()` (`examples/notebook/app.ts`). Both settle the identity now,
|
|
// so both reach the barrier in the same tick. Two barriers is not a cosmetic fault: the
|
|
// user answers whichever is on top, the other never resolves, and the `init()` waiting on
|
|
// it never hands the page to the broker — the application hangs before it ever loads.
|
|
const wiring = consumerWiring();
|
|
configured(wiring);
|
|
inBrowser(APP, fakeStorage(), "top-level");
|
|
const dom = fakeDom();
|
|
Object.assign(globalThis, { document: dom.document });
|
|
|
|
const delegated = init(() => {}, true, []);
|
|
const signedIn = ensureIdentity();
|
|
await Promise.resolve();
|
|
|
|
expect(dom.mounted).toBe(1);
|
|
dom.submit("kira");
|
|
await within(delegated);
|
|
expect(await within(signedIn)).toBe("kira");
|
|
expect(wiring.calls[0]!.href).toBe(APP + "?ng-id=kira");
|
|
});
|
|
|
|
test("arguments and return value pass through untouched — it is still a forwarder", async () => {
|
|
// Settling is added BEFORE the delegate, never around it, and the return value comes back
|
|
// as it left: anything this wrapper altered on the way in or out is a difference the
|
|
// application has to unlearn at migration.
|
|
//
|
|
// The ONE exception is the callback, wrapped since 2026-08-12 so the package can keep the
|
|
// session the SDK delivers through it. What must therefore hold is not that the same
|
|
// function object arrives — it does not — but that the caller's callback still sees
|
|
// exactly the event the SDK sent, unchanged and un-narrowed. That is the property an
|
|
// application depends on, and the only one that survives migration.
|
|
const wiring = consumerWiring();
|
|
configured(wiring);
|
|
inBrowser(APP, fakeStorage(), "top-level");
|
|
setCurrentUser("juno");
|
|
const seen: unknown[] = [];
|
|
const callback = (event: unknown): void => void seen.push(event);
|
|
|
|
const result = await within(init(callback, true, ["a-broker"]));
|
|
|
|
expect(wiring.calls[0]!.args.slice(1)).toEqual([true, ["a-broker"]]);
|
|
expect(seen).toEqual([loggedIn()]);
|
|
expect(result).toBe(wiring.returned);
|
|
});
|
|
|
|
test("the session id is RELAYED, not rebuilt — what the broker sent is what is kept", async () => {
|
|
// The real broker answers `session_id: 1` — a NUMBER (upstream types it `string | number`,
|
|
// `index.d.ts:266`) — and the wasm binding deserializes it by that type. Normalizing it to
|
|
// a string was written into the capture first, and the applicative e2e refused every call
|
|
// in the batch: `Deserialization error of session_id JsValue("1")`. Nothing downstream
|
|
// reads this value, it only travels; so the capture must relay it untouched.
|
|
const wiring = consumerWiring({ session_id: 1, private_store_id: "did:ng:o:p" });
|
|
configured(wiring);
|
|
inBrowser(APP + "?ng-id=otto", fakeStorage(), "in the broker iframe");
|
|
|
|
await within(init(() => {}, true, []));
|
|
|
|
const relayed: unknown = (await within(sharedWalletSession())).sessionId;
|
|
expect(relayed).toBe(1);
|
|
});
|
|
|
|
// ── Awaiting `ensureIdentity()` before `init()` ────────────────────────────
|
|
//
|
|
// A session arrives through `init()`'s callback and through nothing else, so an application
|
|
// that awaits signing in FIRST is waiting for something that cannot happen. It used to wait
|
|
// forever — no error, no timeout, the application simply stopped where it awaited — which is
|
|
// the worst failure to hand someone integrating. The package owns the session now, so it can
|
|
// tell "not yet" from "never" outright, with no timeout, no race and no heuristic delay.
|
|
//
|
|
// The two below wire the registry the way an APPLICATION does — through `configure` alone,
|
|
// which points it at the package's own holder. The route the rest of this file substitutes
|
|
// holds a session no `init()` of ours opened, and there a wait legitimately ends.
|
|
|
|
/** Let pending microtasks and timers run, so "has it settled yet" is a fair question. */
|
|
function flush(): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
/**
|
|
* An application's bootstrap, with the injected `init` kept on a leash.
|
|
*
|
|
* The real one answers when the BROKER does, which is not the moment it is called — so the
|
|
* callback is held here rather than fired, which is what makes "`init()` has been called and
|
|
* the session has not arrived yet" a state these tests can be IN rather than assume.
|
|
*/
|
|
function anApplicationThatConfigured() {
|
|
let deliver: ((event: unknown) => void) | null = null;
|
|
configure({
|
|
ng: {} as never,
|
|
useShape: (() => {}) as never,
|
|
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
|
init: (...args: unknown[]): Promise<void> => {
|
|
const callback = args[0];
|
|
if (typeof callback === "function") deliver = callback as (e: unknown) => void;
|
|
return Promise.resolve();
|
|
},
|
|
});
|
|
return {
|
|
/** The broker answering, at last — `{ status: "loggedin", session }` (`ngweb.js:124`). */
|
|
theBrokerAnswers(): void {
|
|
if (deliver === null) throw new Error("the injected `init` was never called");
|
|
deliver(loggedIn());
|
|
},
|
|
};
|
|
}
|
|
|
|
test("awaited BEFORE `init()`, signing in fails loudly — it does not wait on a session nobody will open", async () => {
|
|
// Inside the iframe, where the barrier stands aside: what is judged here is the wait that
|
|
// follows settling, and a barrier nobody answered would hold the call up for its own
|
|
// unrelated reason. The error has to name the call to make — a rejection saying only that
|
|
// something went wrong would leave the integrator exactly as stuck, just faster.
|
|
anApplicationThatConfigured();
|
|
inBrowser(APP + "?ng-id=iris", fakeStorage(), "in the broker iframe");
|
|
|
|
await expect(within(ensureIdentity())).rejects.toThrow(/awaited before init\(\)/i);
|
|
});
|
|
|
|
test("with `init()` called, signing in WAITS for the session — the normal case", async () => {
|
|
// The other half, and the reason the refusal cannot be a blanket one: an application calls
|
|
// `init()` and then awaits `ensureIdentity()`, and between those two the session genuinely
|
|
// has not arrived yet. Waiting there is right, and a refusal that fired here would break
|
|
// every application it was meant to help.
|
|
// Its OWN identifier: the connection work keys what it has in flight by identity, module
|
|
// -wide, so two tests sharing one would let a run left pending by the other be JOINED here
|
|
// instead of started — and this one would then be measuring that run, not its own.
|
|
const app = anApplicationThatConfigured();
|
|
inBrowser(APP + "?ng-id=nora", fakeStorage(), "in the broker iframe");
|
|
|
|
void init(() => {}, true, []);
|
|
const signedIn = ensureIdentity();
|
|
let outcome: string | null = null;
|
|
void signedIn.then(
|
|
(id) => { outcome = `resolved: ${id}`; },
|
|
(failure) => { outcome = `rejected: ${String(failure)}`; },
|
|
);
|
|
await flush();
|
|
expect(outcome).toBe(null);
|
|
|
|
app.theBrokerAnswers();
|
|
expect(await within(signedIn)).toBe("nora");
|
|
});
|
|
|
|
test("the package holds the session even when the caller passes NO callback", async () => {
|
|
// Upstream the callback is optional (`callback: Function | null`), and an application
|
|
// that wants nothing from the lifecycle channel legitimately passes none. The session
|
|
// still has to reach the library, or every read that follows waits on a session that
|
|
// was delivered to nobody — the same silence, from the opposite direction.
|
|
//
|
|
// Inside the iframe: the only side where a session is ever opened.
|
|
const wiring = consumerWiring();
|
|
configured(wiring);
|
|
inBrowser(APP + "?ng-id=nell", fakeStorage(), "in the broker iframe");
|
|
|
|
await within(init(undefined, true, []));
|
|
|
|
await expect(within(sharedWalletSession())).resolves.toEqual({
|
|
sessionId: "s",
|
|
privateStoreId: "did:ng:o:p",
|
|
});
|
|
});
|