fix: régler l'identité n'a plus le droit de réclamer une session
Le partage était cassé : Bob n'ouvrait pas le document qu'Alice venait de lui partager, sans erreur, juste « (illisible) ». Régression introduite en scindant ensureIdentity(). Chaîne observée aux sondes, pas déduite : settleIdentity() appelait setCurrentUser, qui déclenche startConnect(), qui va chercher la session via le thunk getSession de l'application. Or l'exemple appelle init() DEPUIS L'EXÉCUTEUR qui construit sessionReady — le thunk ne peut donc pas répondre, par construction. Il lève, resolveAccount rend null, l'exécution est abandonnée sans restauration ni drainage, mais s'est déjà enregistrée « en vol ». Le ensureIdentity() suivant rejoint cette exécution morte et se résout sans avoir rien fait. Avant la scission, rien n'appelait setCurrentUser pendant l'évaluation du module : la session existait, l'exécution était saine, et la rejoindre était sans danger. C'était bien une affaire de moment. bootstrap.ts scinde le setter : adoptCurrentUser enregistre qui agit, setCurrentUser reste « enregistrer + connecter » pour tous les autres appelants. La moitié sans session ne réclame donc plus de session, et se connecter redevient l'affaire du seul ensureIdentity(), attendu, là où une session existe. Ce que ça bloque, tracé avant de livrer : une application qui appellerait init() sans jamais appeler ensureIdentity() n'aurait plus de restauration en arrière- plan. Aucun appelant de ce genre n'existe, et avant la scission init() était un passthrough nu qui ne déclenchait rien — c'est une répartition rétablie, pas un comportement retiré. Reste connu, non corrigé : connectedUser mémorise toujours une exécution abandonnée. Le piège est documenté sur setCurrentUser.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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 consumer's `getSession` thunk. Settling happens before `init()` has been
|
||||
* delegated to — and the reference application builds its `sessionReady` promise AROUND
|
||||
* that very `init()` call, so at that instant the promise it would wait on does not exist.
|
||||
* The thunk could not answer. 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 call the session thunk at
|
||||
* all, and signing in must not come back until the thunk has actually answered.
|
||||
*/
|
||||
import { test, expect, afterEach } from "bun:test";
|
||||
import { configure, ensureIdentity } from "../src/index";
|
||||
import { init } from "../src/surface/lifecycle";
|
||||
import { resetConfig, resetStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
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. */
|
||||
function inBrowser(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(),
|
||||
});
|
||||
}
|
||||
|
||||
const PAGE_GLOBALS = ["location", "localStorage", "history", "document"] as const;
|
||||
|
||||
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()` first, then `init()` called from INSIDE the executor that builds the very
|
||||
* promise the session thunk waits on.
|
||||
*
|
||||
* That shape is not a curiosity of this fixture, it is what the e2e serves: in the bundle,
|
||||
* `sessionReady` is a hoisted `var`, so while the executor runs it is still `undefined` and
|
||||
* the thunk has nothing to wait on. It therefore **refuses** rather than blocking — and an
|
||||
* application is entitled to refuse, since at that moment there is genuinely nothing to
|
||||
* return. A thunk that merely blocked would hide the whole defect, which is why the double
|
||||
* here refuses exactly as the application's does.
|
||||
*
|
||||
* The injected `init` resolves the session, as the real one does through its callback:
|
||||
* before it is called, nothing in the system can make the session exist.
|
||||
*/
|
||||
function bootTheApplication(identifier: string) {
|
||||
/** What the consumer's thunk was asked, and what it was able to say. */
|
||||
const thunk = { asked: 0, answered: 0, refused: 0 };
|
||||
let session: RegistrySession | null = null;
|
||||
// Deliberately assigned AFTER `init()` runs — see above. `undefined` until then.
|
||||
let sessionReady: Promise<RegistrySession> | undefined;
|
||||
let arrive!: (s: RegistrySession) => void;
|
||||
|
||||
configure({
|
||||
ng: {} as never, // no store behind it: the assertions are about what is REACHED
|
||||
useShape: (() => {}) as never,
|
||||
init: (..._args: unknown[]): Promise<string> => {
|
||||
arrive({ sessionId: "s", privateStoreId: "did:ng:o:private" });
|
||||
return Promise.resolve("delegated");
|
||||
},
|
||||
getSession: async (): Promise<RegistrySession> => {
|
||||
thunk.asked += 1;
|
||||
try {
|
||||
const s = session ?? (await sessionReady!);
|
||||
const answer = { sessionId: s.sessionId, privateStoreId: s.privateStoreId };
|
||||
thunk.answered += 1;
|
||||
return answer;
|
||||
} catch (refusal) {
|
||||
thunk.refused += 1;
|
||||
throw refusal;
|
||||
}
|
||||
},
|
||||
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
|
||||
});
|
||||
|
||||
inBrowser(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
|
||||
|
||||
let delegated!: Promise<unknown>;
|
||||
sessionReady = new Promise<RegistrySession>((resolve) => {
|
||||
arrive = resolve;
|
||||
delegated = init(() => {}, true, []) as Promise<unknown>;
|
||||
});
|
||||
void sessionReady.then((s) => { session = s; });
|
||||
|
||||
return { thunk, delegated };
|
||||
}
|
||||
|
||||
test("settling the identity never asks the application 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 consumer's thunk must not have been called ONCE —
|
||||
// not called-and-blocked, not called-and-refused, not called at all. Anything the gate
|
||||
// reaches that ends up at `getSession` 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 thunk 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);
|
||||
});
|
||||
Reference in New Issue
Block a user