Files
ng-eventually/packages/sdk/test/anti-fork.test.ts
T
Sylvain Duchesne b7dc8ca2c3 fix: la suite n'était pas hermétique, et deux tests ne pouvaient pas échouer
Second tour adverse sur le lot D. Trois trouvailles, et une erreur de diagnostic de ma
part qui vaut d'être consignée.

**La suite verte dépendait de l'ordre des fichiers.** `bun test isolation-active
public-store` donnait 5 échecs quand chaque fichier seul était vert — donc un checkout de
CI avec un autre ordre d'inodes livrait rouge. Deux causes distinctes :

- le travail de connexion, lancé sans être attendu par `setCurrentUser`, débordait d'un
  fichier sur le suivant et armait l'émulation. `connectedUser` abandonne désormais dès
  que l'identité pour laquelle il a démarré n'est plus connectée — ce qui est de toute
  façon la bonne sémantique : en amont une session appartient à un utilisateur, et
  changer d'utilisateur est une autre session ;
- et surtout **mon propre test de store public exposait le cap d'un document que
  personne ne détient** — un état que la bibliothèque ne produit jamais. Il ne passait
  que tant que l'émulation était désarmée. Alice crée sa note avant de l'exposer,
  maintenant. Balayage des 21 paires de fichiers : plus aucune ne pollue.

**Le contrôle symétrique ajouté hier ne pouvait pas échouer.** « La liste d'Alice ne
contient pas la note de Bob » lisait un rendu ANTÉRIEUR à l'écriture de Bob : l'attente
de `showScope` était satisfaite au premier sondage par le marqueur déjà à l'écran, sans
synchroniser quoi que ce soit. Alice écrit désormais une note APRÈS celle de Bob —
`writeNote` attend son apparition, donc ce qui suit est un rendu qui post-date. Et le
`.catch` qui avalait le délai d'attente est retiré : une liste qui ne se stabilise jamais
est un échec à voir, pas une dégradation à absorber.

**Le test anti-fork prouvait « pas le premier », pas « le canonique ».** Son minimum
lexicographique était aussi le DERNIER élément, si bien qu'un choix positionnel — la
faute exacte que ce test existe pour attraper — restait vert. Le minimum est déplacé au
milieu ; vérifié par mutation, « prendre le dernier » le fait rougir.

**Mon erreur de diagnostic.** J'ai cru trouver, sous la trouvaille d'ordre, une fuite
entre utilisateurs — les caps d'Alice classés chez Bob — et je l'ai « reproduite ». Le
repro était faux : son faux `ng` ignorait le sujet dans la requête d'inbox, donc l'inbox
de Bob résolvait vers celle d'Alice. Une fois le faux corrigé, la fuite ne se reproduit
plus, ni avec ni sans correctif. Le danger reste réel en lecture du code — trois chemins
classent des caps plusieurs `await` après la garde qui les autorisait — donc
`caps.holderKey`/`learnFor` le ferment par construction, mais les commentaires disent
maintenant ce que c'est : un risque fermé, pas un défaut observé.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
2026-08-10 10:02:45 +02:00

331 lines
15 KiB
TypeScript

/**
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
* polyfill-era shim (src/store-registry.ts), redesigned around the
* pointer → doc-shim indirection. Two groups, one file:
*
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
* resolve to the SAME canonical doc every time (lexicographically-smallest
* NURI), so the session that WROTE an entity and a fresh page that RESOLVES
* the doc never disagree. Robustness against PAST fork residue.
*
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
* persistent wallet resolves the SAME account through the doc-shim's
* first-`State` BARRIER, with NO account-level retry. The account records
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
* POINTER triple in the store-root; opening the doc-shim makes a cold read
* authoritative, so a genuinely-present account is found on the first read
* and never re-provisioned (no fork). This replaced the deleted
* `resolveAccountReliably` / `provisionRetry` account retry.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveAccount,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
const ROOT = "did:ng:PRIV-AF";
// ---------------------------------------------------------------------------
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
//
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
// graph (keyed by GRAPH <ROOT>).
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
// anchor arg = the doc-shim NURI).
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
// (the barrier).
// ---------------------------------------------------------------------------
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeSparqlUpdate(quads: Quad[]) {
return mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
}
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
* of bindings — a corrupted shim. */
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
bySubject.set(q.s, rec);
}
const bindings: Array<Record<string, { value: string }>> = [];
for (const rec of bySubject.values()) {
if (!rec.id) continue;
const pubs = rec.pub.length ? rec.pub : [""];
const prots = rec.prot.length ? rec.prot : [""];
const privs = rec.priv.length ? rec.priv : [""];
for (const pub of pubs)
for (const prot of prots)
for (const priv of privs)
bindings.push({
id: { value: rec.id },
docPublic: { value: pub },
docProtected: { value: prot },
docPrivate: { value: priv },
});
}
return bindings;
}
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
// one doc-shim account read" (no account RETRY).
const accountReadsByAnchor = new Map<string, number>();
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT — anchored to the doc-shim (the authoritative read).
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
if (typeof cb === "function") cb({ V0: { State: {} } });
return () => {};
});
return {
doc_create, sparql_update, sparql_query, doc_subscribe,
_quads: quads,
// Total account reads across all anchors.
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
// Account reads anchored to a did:ng:o: doc-shim.
getDocShimAccountReads: () =>
[...accountReadsByAnchor.entries()]
.filter(([g]) => g.startsWith("did:ng:o:"))
.reduce((a, [, n]) => a + n, 0),
};
}
function inject(
fakeNg: unknown,
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
) {
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(),
pointerGuard,
});
resetRegistryCache();
resetOpenedRepos();
}
// ---------------------------------------------------------------------------
// (1) Deterministic resolution over fork residue (in the doc-shim)
// ---------------------------------------------------------------------------
describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const docShim = "did:ng:o:shimdoc";
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const subj = "urn:ng-eventually:shim:account:dupuser";
// The minimum must sit NEITHER first NOR last, or the test cannot tell a canonical
// pick from a positional one. It used to end on `pub-a`, so `rows[rows.length - 1]`
// — an order-dependent pick, precisely the fault this test exists to catch — passed
// it. Only `rows[0]` failed. Found by mutation, 2026-08-10.
const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-z",
];
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics)
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
const r1 = await resolveAccount("dupuser");
resetRegistryCache();
const r2 = await resolveAccount("dupuser");
resetRegistryCache();
const viaShim = await resolveAccount("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
expect(r2?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
});
});
// ---------------------------------------------------------------------------
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
// ---------------------------------------------------------------------------
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
// First login: provisions the account (1 doc-shim + 3 scope docs).
const first = await ensureAccount("LauraBarrier");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
resetRegistryCache();
resetOpenedRepos();
const second = await ensureAccount("LauraBarrier");
expect(second.docPublic).toBe(first.docPublic);
expect(second.docProtected).toBe(first.docProtected);
expect(second.docPrivate).toBe(first.docPrivate);
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
});
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
// Seed session 1; capture the account NURIs + the persistent quads.
const seed = makeFakeNg();
inject(seed);
const orig = await ensureAccount("BarrierUser");
expect(seed.doc_create).toHaveBeenCalledTimes(4);
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
// it is NOT used for the account.
const reconnect = makeFakeNg();
reconnect._quads.push(...seed._quads);
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
const resolved = await ensureAccount("BarrierUser");
expect(resolved.docPublic).toBe(orig.docPublic);
expect(resolved.docProtected).toBe(orig.docProtected);
expect(resolved.docPrivate).toBe(orig.docPrivate);
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
// Barrier-authoritative: found on the FIRST doc-shim read.
expect(reconnect.getDocShimAccountReads()).toBe(1);
});
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
const fakeNg = makeFakeNg();
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
const docShim = "did:ng:o:preexisting-shim";
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const rec = await ensureAccount("BrandNewUser");
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
expect(fakeNg.getDocShimAccountReads()).toBe(1);
});
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
const rec = await ensureAccount("SyncUser");
// 1 doc-shim + 3 scope docs.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
expect(fakeNg.getDocShimAccountReads()).toBe(1);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const a = await ensureAccount("SameUser");
const b = await ensureAccount("SameUser");
expect(b).toEqual(a);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
});
});