/** * anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount / * resolveAccountReliably (src/store-registry.ts). * * The guard is: before treating a 0-row read as "genuinely new account" and * provisioning a new set of scope docs, the registry retries a bounded number of * times (with ensureRepoOpen + backoff between each). If the account IS found on * any attempt, it is reused (no second set of docs = no fork). Only if EVERY * attempt still reads 0 rows does the registry treat it as a first-time account * and provision exactly once. * * This file uses a FAKE `ng` that simulates broker sync lag: the sparql_query for * the account record returns 0 rows for the first K calls, then returns real rows. * We control the retry budget via `provisionRetry.attempts`. */ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; import { ensureAccount, resetRegistryCache, } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig, } from "../src/polyfill"; import { resetOpenedRepos } from "../src/open-repo"; afterAll(() => { resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); }); const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" }; // --------------------------------------------------------------------------- // Fake ng with sync-lag simulation // // `lagCalls` controls how many account-record reads return 0 rows before the // real data is visible. Each call to sparql_query that matches the account // SELECT decrements the counter; once it hits 0 the real data is returned. // --------------------------------------------------------------------------- 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 makeLaggedNg(lagCalls: number) { const quads: Quad[] = []; let docCounter = 0; let accountQueryCount = 0; const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); const sparql_update = 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; }); const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; if (query.includes("")) { // This is an account SELECT (resolveAccount / loadShim) accountQueryCount++; // Still in the lag window → pretend the store hasn't synced yet if (accountQueryCount <= lagCalls) { return { results: { bindings: [] } }; } // Lag lifted → return real data from the quads store const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.id) .map((r) => ({ id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, })); return { results: { bindings } }; } // Entity-index SELECT const bindings = quads .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") .map((q) => ({ e: { value: q.o } })); return { results: { bindings } }; }); return { doc_create, sparql_update, sparql_query, _quads: quads, getAccountQueryCount: () => accountQueryCount, }; } function inject(laggedNg: ReturnType, attempts: number) { configure({ ng: laggedNg as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u) => u.trim().toLowerCase(), provisionRetry: { attempts, baseMs: 0, maxStepMs: 0 }, }); resetRegistryCache(); resetOpenedRepos(); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("anti-fork: resolveAccountReliably / ensureAccount", () => { beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); }); it("(a) sync lag: 0 rows for K-1 calls then real rows → reuses existing docs (no fork)", async () => { // budget = 3 attempts; lag = 1 call (the first targeted read returns 0, // but the second (after retry) returns the real data written in the same // session before the cache was dropped). We simulate a scenario where the // account IS already provisioned (written to quads by a previous // ensureAccount call in an earlier "session"), then we reset the cache and // re-call with lag to simulate broker sync delay. const laggedNg = makeLaggedNg(0); // no lag — first provision a real account inject(laggedNg, 1); const first = await ensureAccount("LauraLag"); expect(laggedNg.doc_create).toHaveBeenCalledTimes(3); // Now simulate a fresh session with lag: cache cleared, first K reads are 0 // We reuse the SAME quads store (same laggedNg with quads intact) but // rebuild with lag > 0 so the first read returns 0, then real data. const laggedNg2 = makeLaggedNg(1); // 1 empty read before real data // Copy the quads from the first ng into laggedNg2 so it "knows" the account for (const q of laggedNg._quads) { laggedNg2._quads.push(q); } inject(laggedNg2, 3); // 3 attempts, lag=1 → second attempt succeeds const second = await ensureAccount("LauraLag"); // ANTI-FORK: must return the SAME scope docs — no new doc_create expect(second.docPublic).toBe(first.docPublic); expect(second.docProtected).toBe(first.docProtected); expect(second.docPrivate).toBe(first.docPrivate); // doc_create must NOT have been called (the lagged ng2 finds the account on retry) expect(laggedNg2.doc_create).toHaveBeenCalledTimes(0); }); it("(b) always-0 rows (genuinely new account): provisions exactly ONCE (1 set of docs)", async () => { // Lag longer than the retry budget → account is treated as new const laggedNg = makeLaggedNg(999); // always returns 0 for account queries inject(laggedNg, 2); // budget: 2 attempts, both see 0 → provision const rec = await ensureAccount("NewUser"); // Exactly 3 doc_create calls (1 set of scope docs) expect(laggedNg.doc_create).toHaveBeenCalledTimes(3); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docProtected).not.toBe(rec.docPublic); expect(rec.docPrivate).not.toBe(rec.docProtected); // Verify budget was fully consumed: both attempts fired before provisioning expect(laggedNg.getAccountQueryCount()).toBe(2); }); it("idempotence within a session: calling ensureAccount twice never creates 2 sets", async () => { const laggedNg = makeLaggedNg(0); // no lag inject(laggedNg, 1); const a = await ensureAccount("SameUser"); const b = await ensureAccount("SameUser"); expect(b).toEqual(a); // Must still be exactly 3 (not 6) expect(laggedNg.doc_create).toHaveBeenCalledTimes(3); }); it("retry budget of 1 (sync fake): a genuinely-absent account provisions without extra retries", async () => { const laggedNg = makeLaggedNg(0); // will never return lag (account not in quads) inject(laggedNg, 1); await ensureAccount("BrandNew"); // 3 scope docs created exactly once expect(laggedNg.doc_create).toHaveBeenCalledTimes(3); // The targeted account query was called exactly once (no retries at attempts=1) const accountQueryCalls = laggedNg.getAccountQueryCount(); expect(accountQueryCalls).toBe(1); }); it("lag of exactly (attempts-1): found on last allowed attempt → reused, not forked", async () => { // 3 attempts, lag=2 (first 2 calls return 0, 3rd returns real data). // We pre-populate the quads by provisioning with a fresh no-lag ng first. const seedNg = makeLaggedNg(0); inject(seedNg, 1); const original = await ensureAccount("EdgeUser"); expect(seedNg.doc_create).toHaveBeenCalledTimes(3); // Now re-inject with the same quads but with lag=2, budget=3 const laggedNg = makeLaggedNg(2); for (const q of seedNg._quads) laggedNg._quads.push(q); inject(laggedNg, 3); const retried = await ensureAccount("EdgeUser"); // Found on the 3rd attempt (lag=2 means first 2 return 0, 3rd returns real) expect(retried.docPublic).toBe(original.docPublic); expect(retried.docProtected).toBe(original.docProtected); expect(laggedNg.doc_create).toHaveBeenCalledTimes(0); // no new provisioning }); });