Files
ng-eventually/packages/polyfill/test/sign-in-connects.test.ts
T
Sylvain Duchesne 8c8ade7a9e fix: une connexion qui échoue ne se résout plus comme une réussite
connectedUser() restaure les capacités reçues et draine les inbox. Aucun de ses
chemins ne pouvait échouer : un broker injoignable rendait exactement la même
promesse qu'un succès complet. L'application affichait alors des listes vides,
et rien nulle part ne disait que la restauration n'avait pas eu lieu.

L'énumération m'avait échappé sur deux points, l'agent les a établis.

resolveAccount attrapait tout et rendait null : une lecture qui ÉCHOUAIT
ressortait donc comme « ce compte n'existe pas ». L'échec était déguisé en
absence — c'est la racine du partage cassé trouvé ce matin, dont on n'avait
traité que le déclencheur. lookupAccount le remplace : le silence n'est plus
possible que sur une absence VÉRIFIÉE.

Et readLinks comme readInboxCapPairs avalaient leur propre erreur en rendant un
tableau vide, un étage sous le catch de connect. Une panne n'y parvenait même
pas. Elles relèvent désormais.

La règle est simple : tout échec remonte, seul « il n'y avait rien à faire »
se résout en silence. Ce qui reste silencieux — aucun détenteur, compte
réellement absent, identité changée en route — l'est parce que c'est la vérité.

Le piège consigné hier est fermé par là même : une exécution qui ne peut pas
répondre rejette, et ceux qui la rejoignent en héritent. Sa feuille est
supprimée, la question qu'elle laissait ouverte étant tranchée.

Trois fixtures de test utilisaient un ng vide — une forme qu'aucune plateforme
ne présente, dont le TypeError était mangé par le catch. Remplacées par un
broker au portefeuille vide. Aucune assertion modifiée.
2026-08-13 11:58:29 +02:00

193 lines
8.8 KiB
TypeScript

/**
* Signing in CONNECTS — and settling, on its own, does not reach for the session.
*
* These two are one subject seen from both ends, and the applicative e2e is what found it:
* after Alice shared a protected note with Bob, Bob reopened the application and read
* "(illisible)". Nothing threw anywhere. It cost minutes of real browser and real broker to
* see, so it is pinned here for the price of a millisecond.
*
* ── The mechanism, so a future reader can judge a change against it ────────
* Settling the identity called `setCurrentUser`, which FIRES the connection work. Firing is
* not awaiting, but it is still running: the work's first act is `resolveAccount`, which
* awaits the session. Settling happens before `init()` has been delegated to — and the
* reference application built its `sessionReady` promise AROUND that very `init()` call, so
* at that instant the promise it would wait on did not exist. The session could not be
* answered. It threw, `resolveAccount` answered null, and the run abandoned before
* restoring a single capability — after registering itself as the connection in flight. The
* `connectedUser()` that `ensureIdentity()` awaits then JOINED that abandoned run instead of
* doing the work, and resolved having done nothing. The application rendered, and Bob held
* no key to a note that had been shared with him.
*
* So what is pinned is not "the calls happen in this order" — a regression would still call
* them in order. It is what each half TOUCHES: settling must not reach for the session at
* all, and signing in must not come back until the session has actually answered.
*
* ── What moved on 2026-08-12, and what it does not excuse ─────────────────
* The session is no longer the application's to supply: the package captures it from
* `init()`'s callback and holds it (`shared-wallet/session.ts`), so the promise the
* connection work waits on is now the library's own. That closes the *shape* of the failure
* above — a holder that WAITS cannot throw, so a run can no longer abandon for want of an
* answer. It closes nothing about the ordering: the session still arrives only through
* `init()`, so a half that reached for it too early would still be waiting on something
* that does not exist yet. Both properties below are therefore still worth their assertion,
* and the fixture instruments the package's own holder rather than a consumer's thunk.
*/
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 APP = "https://app.example/";
/** A localStorage double — the real one is absent in `bun test`. */
function fakeStorage() {
const map = new Map<string, string>();
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),
};
}
/**
* A page whose address bar MOVES on `replaceState`, as the gate and `init()` both rely on.
*
* INSIDE THE BROKER IFRAME (`window.self !== window.top`), and that is not decoration: it
* is the side of the frontier this whole file describes. The identifier has already crossed
* in the URL, so the barrier stands aside; and the session exists only here, opened by the
* callback `init()` is given. Top-level, `init()` navigates away and no session is ever
* established, so the deadlock pinned below could not even be reached.
*/
function inBrokerIframe(url: string): void {
let href = url;
Object.assign(globalThis, {
location: {
get href(): string { return href; },
get search(): string { return new URL(href).search; },
},
history: { replaceState: (_s: unknown, _t: string, next: string): void => void (href = next) },
localStorage: fakeStorage(),
window: { self: {}, top: {}, addEventListener: (): void => {} },
});
}
const PAGE_GLOBALS = ["location", "localStorage", "history", "document", "window"] as const;
/** A broker over an empty wallet: reads answer nothing, `doc_create` mints a NURI. */
function emptyWallet() {
let created = 0;
return {
sparql_query: async (): Promise<unknown> => ({ results: { bindings: [] } }),
sparql_update: async (): Promise<void> => undefined,
doc_create: async (): Promise<string> => `did:ng:o:signin${++created}`,
};
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
});
/**
* The reference application's bootstrap, in its real order (`examples/notebook/app.ts`):
* `configure()`, then `init()` — and nothing else, since the session stopped being
* something an application assembles.
*
* The injected `init` produces the session the way the real one does: by calling the
* callback it was handed, with `{ status: "loggedin", session }` (`ngweb.js:124`). Before
* it is called, nothing in the system can make the session exist — which is the whole
* cycle these two tests measure.
*
* The counters sit on the REGISTRY's route to the session, substituted through the internal
* wiring path. That is where the connection work actually asks, so it is where "was it
* reached, and did it answer" can be told apart. `refused` is kept though the package's
* holder cannot throw: a future change that put a refusing thunk back on this route is
* exactly the regression the file exists to catch, and a counter nobody kept would let it
* back in silently.
*/
function bootTheApplication(identifier: string) {
/** What the registry's route to the session was asked, and what it was able to say. */
const thunk = { asked: 0, answered: 0, refused: 0 };
configure({
// A broker over an EMPTY wallet — nobody has signed in yet. The assertions below are
// about what is REACHED on the way to the session, not about anything stored; what
// this must NOT be is `{}`, a shape no real platform presents. The connection work
// called into it, got a `TypeError`, and swallowed it — so the counters were read off
// a run that had already died of the fixture. Now that a failed connection is reported
// (`emulated-verifier/connect.ts`), the fake has to answer like a broker with nothing
// in it, which is exactly the state a first sign-in meets.
ng: emptyWallet() as never,
useShape: (() => {}) as never,
init: (...args: unknown[]): Promise<string> => {
const callback = args[0];
if (typeof callback === "function") {
void (callback as (e: unknown) => unknown)({
status: "loggedin",
session: { session_id: "s", private_store_id: "did:ng:o:private" },
});
}
return Promise.resolve("delegated");
},
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
configureStoreRegistry({
getSession: async (): Promise<RegistrySession> => {
thunk.asked += 1;
try {
const answer = await sharedWalletSession();
thunk.answered += 1;
return answer;
} catch (refusal) {
thunk.refused += 1;
throw refusal;
}
},
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
inBrokerIframe(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
const delegated = init(() => {}, true, []) as Promise<unknown>;
return { thunk, delegated };
}
test("settling the identity never reaches for a session", async () => {
// The session-free half, taken at its word. `init()` awaits settling and nothing else, so
// by the time it has delegated, the session must not have been asked for ONCE — not
// asked-and-blocked, not asked-and-refused, not asked at all. Anything the gate reaches
// that ends up at the session is outside the half it claims to be.
const app = bootTheApplication("bob");
await app.delegated;
expect(app.thunk.asked).toBe(0);
expect(app.thunk.refused).toBe(0);
});
test("signing in does not come back until the connection work has reached a live session", async () => {
// The consequence, from the application's side. `ensureIdentity()` promises that what was
// shared with you is readable when it resolves; it can only keep that promise by having
// restored and drained, and both begin by resolving the account — which needs the
// session. So a run that resolved without the session ever ANSWERING did no such work,
// whatever it reported. That is exactly the state Bob's page was in.
const app = bootTheApplication("bob");
await app.delegated;
await ensureIdentity();
expect(app.thunk.answered).toBeGreaterThanOrEqual(1);
expect(app.thunk.refused).toBe(0);
});