Files
ng-eventually/packages/polyfill/test/user-inbox-surfaces-failure.test.ts
T
Sylvain Duchesne e32b6d04fc fix: un échec de lecture ne fabrique plus d'état durable et faux
userInbox avalait deux échecs, et chacun laissait des dégâts sur le disque.

À la lecture : la requête « quelle inbox ce compte possède-t-il » levait, on
journalisait, et on créait une inbox de plus. Deux associations pour un même
couple utilisateur/portée — vérifié en conditions, sdoc5 et sdoc6 coexistants.

À l'écriture : l'INSERT levait, on retournait quand même la référence en la
mettant en cache. Le propriétaire tenait sdoc5 pendant qu'un déposant résolvait
sdoc6. Il lit une boîte où nul n'écrit, ils écrivent dans une boîte que nul ne
lit.

Et un troisième que je n'avais pas vu : recordInbox avalait son propre INSERT
puis marquait son index en mémoire — l'inbox était associée mais refusée à la
session suivante, donc tous les dépôts rebondissaient, définitivement.

Le balayage demandé a trouvé la famille entière : onze sites de cette forme, un
catch qui journalise puis une exécution qui continue comme si la chose cherchée
était absente. Les huit autres corrigés vont d'une seconde racine de registre
créée sur budget épuisé, à un document public qui ne sert plus jamais sa clé.

La règle appliquée partout : seule une absence VÉRIFIÉE autorise à créer, et une
référence n'est remise à personne avant que son association ait atterri.

Les sites laissés échouent en fermeture — un refus, une liste vide — sans rien
écrire. Ils sont listés, pas oubliés.
2026-08-13 13:21:54 +02:00

671 lines
29 KiB
TypeScript

/**
* Only a VERIFIED ABSENCE may mint an inbox — the whole case space of `userInbox`.
*
* `userInbox(id, scope)` answers *which inbox document does this virtual user own for this
* scope*, and mints one the first time. Everything downstream is addressed through that
* answer: `share` deposits into it, `connect.connectedUser` drains it, `isOwnInbox` guards
* reads with it. So the one thing it must never do is treat a failure as an absence — where
* `connect.ts` lost a restore for the same fault (2026-08-13), this one WRITES:
*
* - a read that could not answer, taken for "no inbox exists", MINTS a second document for
* one (user, scope). Two `shim:docInbox` triples then sit on the same subject and which
* one wins later is decided by `canonicalDoc` picking among them — the owner and a
* depositor can resolve different documents;
* - a persist that failed, followed by handing the document back anyway, gives the owner a
* reference whose triple was never written. Nobody else can resolve it: a depositor
* finding nothing mints yet another. The owner reads a box nobody writes to, depositors
* write to boxes nobody reads.
*
* The rule this file pins, one line: **a failure surfaces; only an absence the broker
* actually confirmed may mint.** Resolving from the cache, joining a run in flight, and
* minting on a confirmed 0 are unchanged — they are the states that are true.
*
* ── Why every branch is here, not just the interesting ones ───────────────
* A swallowed failure is invisible by construction, so a suite covering the happy path and
* one error leaves exactly the places a bug hides. Every path through the function is
* asserted below: the cache, a run in flight (both outcomes), each of the four collaborators
* it awaits before deciding (session, doc-shim, account, the read), the read answering and
* the read finding nothing, and each of the three writes the mint performs.
*
* ── The faults are the broker's, not the test's ───────────────────────────
* Every failure is injected at the `ng` boundary — a read that throws `RepoNotFound` (what
* the engine hard-errors when a repo is not in `self.repos`,
* `engine/verifier/src/request_processor.rs:264`), a write or a `doc_create` that cannot
* reach the broker — or at the consumer's injected `getSession`, which is the other edge of
* the library. Nothing reaches into the library to make one of its own functions reject: a
* fake that fabricates a state the real system never produces goes green while leaving the
* real state untested.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure } from "../src/index";
import {
adoptCurrentUser,
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import {
createEntityDoc,
ensureAccount,
isKnownInbox,
resetRegistryCache,
userInbox,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {
documentInboxAddress,
openDocumentInbox,
} from "../src/emulated-verifier/branch-registers";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import type { InboxScope, Nuri } from "../src/model/types";
const SESSION: RegistrySession = { sessionId: "sid-inbox", privateStoreId: "PRIV-INBOX" };
const SHIM = "urn:ng-eventually:shim";
interface Quad { g: string; s: string; p: string; o: string }
/**
* What the broker refuses to do, and when. Every field arms a REAL failure of the
* corresponding platform call; `false` means "answer normally". Mutable after `inject`, so
* a test can build a healthy world first and only then break the one call it is about.
*/
interface Faults {
/** The consumer's `getSession` thunk rejects — the wallet cannot answer. */
session: boolean;
/** `doc_create` throws — the broker cannot mint a document. */
docCreate: boolean;
/** The doc-shim read that answers "which inbox does this user own" throws. */
inboxLookup: boolean;
/** The shim write that records "this NURI IS an inbox" throws. */
inboxRecord: boolean;
/** The shim write that associates the inbox with its (user, scope) throws. */
inboxPersist: boolean;
/** The store-root read that answers "where is the doc-shim" throws. */
pointerRead: boolean;
/** The store-root write that publishes the doc-shim's address throws. */
pointerWrite: boolean;
/** The doc-shim read that answers "does this account exist" throws. */
accountLookup: boolean;
/** The doc-shim write that records an account's three scope documents throws. */
accountRecord: boolean;
/** The User-branch write that records "I opened this document's inbox" throws. */
inboxCapPersist: boolean;
/** The Header-branch write that publishes WHERE to deposit for a document throws. */
addressPublish: boolean;
}
function noFaults(): Faults {
return {
session: false,
docCreate: false,
inboxLookup: false,
inboxRecord: false,
inboxPersist: false,
pointerRead: false,
pointerWrite: false,
accountLookup: false,
accountRecord: false,
inboxCapPersist: false,
addressPublish: false,
};
}
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
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;
}
/**
* A stateful fake `ng` over an in-memory quad store, modelling the pointer → doc-shim
* indirection the registry is built on: the store-root graph carries the write-once pointer,
* the doc-shim carries the account records, the inbox index and the (user, scope) → inbox
* associations. Same shape as the one `anti-fork.test.ts` drives the registry with, plus the
* fault switches above.
*
* `doc_subscribe` pushes a first `State` so the barrier `ensureRepoOpen` waits on resolves
* at once — the platform's real behaviour, and it keeps every failure below attributable to
* the call that was armed rather than to a missing primitive.
*/
function makeFakeNg(faults: Faults) {
const quads: Quad[] = [];
let docCounter = 0;
/** How many times the (user, scope) → inbox association was READ. */
let inboxLookups = 0;
const doc_create = mock(async () => {
if (faults.docCreate) throw new Error("BrokerError: cannot create document");
return `did:ng:o:idoc${++docCounter}`;
});
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (query.includes(`${SHIM}:isInbox`) && faults.inboxRecord) {
throw new Error("BrokerError: cannot write the inbox index");
}
if (query.includes(`${SHIM}:docInbox`) && faults.inboxPersist) {
throw new Error("BrokerError: cannot write the inbox association");
}
if (query.includes(`${SHIM}:shimDoc`) && faults.pointerWrite) {
throw new Error("BrokerError: cannot write the pointer");
}
if (query.includes(`${SHIM}:docPublic`) && faults.accountRecord) {
throw new Error("BrokerError: cannot write the account record");
}
if (query.includes(`${SHIM}:inboxCap`) && faults.inboxCapPersist) {
throw new Error("BrokerError: cannot write the inbox cap");
}
// Only the INSERT half: the `DELETE` that clears the previous address lands, which is
// the state that bites — the old address gone and the new one never written.
if (
query.includes("INSERT DATA") && query.includes(`${SHIM}:inboxAddress`) &&
faults.addressPublish
) {
throw new Error("BrokerError: cannot publish the inbox address");
}
// `DELETE WHERE { <s> <p> ?x }` — the clear half of a Header-branch replacement.
const dm = query.match(/DELETE WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?\w+\s*\}/);
if (dm) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === dm[1] && q.p === dm[2]) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses;
// everything else writes the anchored default graph.
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const g = gm ? gm[1]! : anchor;
if (!g) return undefined;
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${SHIM}:Account`;
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const rows = (anchor: string | undefined, pred: string, name: string) => ({
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred)
.map((q) => ({ [name]: { value: q.o } })),
},
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Store-root pointer → the doc-shim.
if (query.includes(`<${SHIM}:shimDoc>`)) {
if (faults.pointerRead) throw new Error("RepoNotFound");
return rows(anchor, `${SHIM}:shimDoc`, "shimDoc");
}
// The account record — the read `lookupAccount` issues, bounded to one subject.
if (query.includes(`<${SHIM}:id>`)) {
if (faults.accountLookup) throw new Error("RepoNotFound");
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
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 ?? "" },
})),
},
};
}
// Which inbox does this (user, scope) own — the read the whole file is about.
if (query.includes(`${SHIM}:docInbox`)) {
inboxLookups += 1;
if (faults.inboxLookup) throw new Error("RepoNotFound");
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return {
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })),
},
};
}
if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i");
// The registers a document and a store carry — enough for `createEntityDoc`,
// `ownsDocument` and `openDocumentInbox` to run against this wallet.
if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c");
if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c");
if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e");
if (query.includes(`<${SHIM}:inboxCap>`)) return rows(anchor, `${SHIM}:inboxCap`, "c");
if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a");
// Anchored per-doc read (`SELECT ?s ?p ?o`).
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
// Push a `State` on subscribe — the sync barrier `open-repo.ts` waits for.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: unknown) => {
if (typeof cb === "function") (cb as (r: unknown, t?: string) => void)({ V0: { State: {} } });
return () => {};
});
return {
doc_create,
sparql_query,
sparql_update,
doc_subscribe,
/** Documents minted so far — what "did it mint a second one" reads. */
created: (): number => docCounter,
/** Times the (user, scope) → inbox association was read. */
lookups: (): number => inboxLookups,
/** The inbox NURIs DURABLY associated with `scope`, across every account subject. */
associated: (scope: InboxScope): string[] =>
quads.filter((q) => q.p === `${SHIM}:docInbox:${scope}`).map((q) => q.o),
/** Every triple written with `pred`, whatever its subject or graph. */
written: (pred: string): string[] =>
quads.filter((q) => q.p === `${SHIM}:${pred}`).map((q) => q.o),
};
}
/** Wire a clean world: fresh fake broker, fresh caches, nobody connected. */
function inject(faults: Faults) {
const ng = makeFakeNg(faults);
configure({ ng: ng as never, useShape: (() => {}) as never });
configureStoreRegistry({
getSession: async (): Promise<RegistrySession> => {
if (faults.session) throw new Error("WalletError: the session cannot answer yet");
return SESSION;
},
normalizeId: (id: string) => id.trim().toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
resetCaps();
setCurrentUser(null);
return ng;
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetCaps();
});
/** A later session over the SAME wallet: every in-memory cache gone, the quads kept. */
function freshSession(): void {
resetRegistryCache();
resetOpenedRepos();
}
// --- resolving without asking: the two states that are already true ---------
test("A — an inbox already resolved comes back from the cache, untouched", async () => {
const ng = inject(noFaults());
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
const createdBefore = ng.created();
const lookupsBefore = ng.lookups();
const again = await userInbox("bob", "public");
expect(again).toBe(inbox);
expect(ng.lookups()).toBe(lookupsBefore); // no read at all
expect(ng.created()).toBe(createdBefore); // and nothing minted
});
test("B — a concurrent caller joins the resolution in flight instead of racing a second inbox", async () => {
// Two independent callers ask at once on a cold cache — the ordinary shape on a fresh
// page. Without the join each would see "no inbox" and mint one, forking the (user,
// scope) association in-session.
const ng = inject(noFaults());
adoptCurrentUser("bob");
await ensureAccount("bob");
const [first, second] = await Promise.all([
userInbox("bob", "public"),
userInbox("bob", "public"),
]);
expect(second).toBe(first);
expect(ng.associated("public")).toEqual([first]); // ONE association, not two
});
test("B — a concurrent caller joins the resolution in flight and inherits its FAILURE", async () => {
// The join is only safe if what it hands out is the run's real outcome. A joiner that
// inherits a resolved promise over a failed run is the sibling defect fixed in
// `connect.ts`: a caller that did nothing wrong carries on over work that never happened.
const faults = noFaults();
inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxPersist = true;
// Both handlers attached in the same tick, as two real concurrent callers would: awaiting
// one and only THEN the other leaves the second rejection momentarily unobserved, which
// the runtime reports as an unhandled rejection rather than as the outcome under test.
const outcomes = await Promise.allSettled([
userInbox("bob", "public"),
userInbox("bob", "public"),
]);
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]);
});
// --- the collaborators awaited before any decision is taken -----------------
test("C — a session that cannot answer fails the call", async () => {
// The consumer's `getSession` is the other edge of the library. A wallet that cannot
// answer says nothing about which inbox a user owns, so there is nothing to conclude.
const faults = noFaults();
inject(faults);
adoptCurrentUser("bob");
faults.session = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/the session cannot answer/);
});
test("D — a doc-shim that cannot be reached fails the call", async () => {
// No pointer yet (a first login), so resolving the doc-shim mints it. A broker that
// cannot create it leaves the registry itself unresolved — every account record and every
// inbox association lives in that document.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.created()).toBe(0);
});
test("E — an account that cannot be provisioned fails the call", async () => {
// A user must exist before it can own an inbox. `ensureAccount` mints its three scope
// documents on first sight; a broker that cannot returns no record, and an inbox filed
// under a user with no stores is filed under nothing.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("alice");
await ensureAccount("alice"); // the doc-shim now exists, so the fault lands on the account
const createdBefore = ng.created();
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.associated("public")).toEqual([]);
expect(ng.created()).toBe(createdBefore);
});
// --- the read: an answer, an absence, or a failure --------------------------
test("F — a lookup that could not answer is not an absent inbox: no second one is minted", async () => {
// The shipped defect. The read that asks *which inbox does this user own* was wrapped in a
// catch that only logged, and execution fell through to the mint — so a broker that could
// not answer produced a SECOND document for one (user, scope). Which of the two later wins
// is then decided by `canonicalDoc` picking among the triples: the owner drains one, a
// depositor may write to the other.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
freshSession();
const createdBefore = ng.created();
faults.inboxLookup = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/RepoNotFound/);
// The durable consequence: the wallet still holds exactly ONE inbox for (bob, public),
// and no document was minted behind the failure.
expect(ng.associated("public")).toEqual([inbox]);
expect(ng.created()).toBe(createdBefore);
// …so once the broker answers again, it is that same inbox that comes back.
faults.inboxLookup = false;
freshSession();
expect(await userInbox("bob", "public")).toBe(inbox);
});
test("G — an inbox the shim already records is resolved, never re-minted", async () => {
const ng = inject(noFaults());
adoptCurrentUser("bob");
const inbox = await userInbox("bob", "public");
freshSession(); // a later session over the same wallet: nothing warm
const createdBefore = ng.created();
const resolved = await userInbox("bob", "public");
expect(resolved).toBe(inbox);
expect(ng.created()).toBe(createdBefore);
expect(ng.associated("public")).toEqual([inbox]);
});
test("H — a first ask on a VERIFIED absence mints the inbox, records it, and associates it", async () => {
// The normal case, and the only one entitled to write: the broker answered, and it
// answered nothing. The three writes that make the answer usable all have to land — the
// document, the shim's "this IS an inbox", and the (user, scope) association.
const ng = inject(noFaults());
adoptCurrentUser("bob");
const publicInbox = await userInbox("bob", "public");
const protectedInbox = await userInbox("bob", "protected");
expect(protectedInbox).not.toBe(publicInbox); // two store repos upstream, two documents
expect(ng.associated("public")).toEqual([publicInbox]);
expect(ng.associated("protected")).toEqual([protectedInbox]);
// Durably an inbox, not merely one this session happens to remember.
freshSession();
expect(await isKnownInbox(publicInbox)).toBe(true);
expect(await isKnownInbox(protectedInbox)).toBe(true);
});
// --- the mint: three writes, and none of them may fail in silence -----------
test("I — a broker that cannot mint the document fails the call", async () => {
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob"); // exists, and has never opened an inbox
faults.docCreate = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
expect(ng.associated("public")).toEqual([]);
});
test("J — an inbox the shim could not record as one is not handed back", async () => {
// `recordInbox` is what stands in for the broker's `inboxes: PubKey → RepoId` table: it is
// how a depositor learns that a NURI is an inbox at all, and `inbox.post` refuses anything
// it cannot confirm. It swallowed its own write failure, so the call went on to associate a
// document that no depositor will ever be allowed to write to — the owner's inbox, silently
// closed to everyone, forever.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxRecord = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox index/);
expect(ng.associated("public")).toEqual([]); // nothing points at a box deposits bounce off
});
test("K — an association that could not be persisted is not returned as an inbox", async () => {
// The other shipped defect. The `INSERT DATA` that records WHICH inbox belongs to (user,
// scope) was wrapped in a catch that only logged, and the function cached and returned the
// document anyway. The owner then holds a reference whose triple was never written: nobody
// else resolves it, so a depositor finding nothing mints yet another. The owner reads a box
// nobody writes to, depositors write to a box nobody reads.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
faults.inboxPersist = true;
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox association/);
expect(ng.associated("public")).toEqual([]);
// …and the reference a healthy resolution DOES hand back is one everybody resolves the
// same. Alice obtains Bob's inbox the way `inbox.share` does — by asking the registry
// under her own identity — rather than being handed a value across the boundary.
faults.inboxPersist = false;
freshSession();
const ownersView: Nuri = await userInbox("bob", "protected");
freshSession();
adoptCurrentUser("alice");
const depositorsView: Nuri = await userInbox("bob", "protected");
expect(depositorsView).toBe(ownersView);
expect(ng.associated("protected")).toEqual([ownersView]);
});
// --- the same shape, swept out of the rest of the registry -------------------
//
// `userInbox` was the third member of this family found by accident, so the sweep that
// followed the fix looked for every other `catch` that logs and lets execution carry on as
// though the thing looked for was absent — especially where what follows creates or writes.
// These are the ones whose consequence was a DURABLE false state; each is pinned here,
// because a swallow that comes back is invisible again by construction.
test("SWEEP — a pointer read that never answered does not mint a second registry root", async () => {
// `resolvePointer` retries a bounded number of times and answered `""` when the budget ran
// out — the same value it uses for "no pointer yet", which is what makes `resolveShimDoc`
// CREATE one. A store-root that could not be read therefore forked the registry itself: a
// second doc-shim, a second pointer, and every account record afterwards split between two
// documents, the loser's simply invisible.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("bob");
const pointersBefore = ng.written("shimDoc");
const createdBefore = ng.created();
freshSession();
faults.pointerRead = true;
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
expect(ng.written("shimDoc")).toEqual(pointersBefore); // still ONE registry root
expect(ng.created()).toBe(createdBefore); // and nothing minted behind the failure
});
test("SWEEP — a pointer that could not be written fails the first login", async () => {
// The pointer is the only NURI a fresh session can name without a lookup. Carrying on
// without it means writing every account into a doc-shim no later session can find — and
// the next login, seeing no pointer, mints another one and re-provisions everybody.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
faults.pointerWrite = true;
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the pointer/);
expect(ng.written("id")).toEqual([]); // no account was filed into an unreachable shim
});
test("SWEEP — an account read that could not answer does not provision a second account", async () => {
// `ensureAccount` asked through the TOLERANT resolver, which answers `null` for a read
// that failed exactly as for one that found nothing — and what follows an absence here is
// a PROVISION. So a broker hiccup minted a second set of three store documents and a
// second record: the account fork the doc-shim barrier was introduced to end, walking back
// in through the error path.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const first = await ensureAccount("bob");
const createdBefore = ng.created();
freshSession();
faults.accountLookup = true;
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
expect(ng.created()).toBe(createdBefore); // no second set of scope documents
faults.accountLookup = false;
freshSession();
expect(await ensureAccount("bob")).toEqual(first); // and the one account is intact
});
test("SWEEP — an account record that could not be persisted is not handed back", async () => {
// `ensureAccount` cached and returned the record right after the write. The session then
// wrote its entities into three documents the shim never heard of, and the next session,
// finding no record, provisioned the account again — everything created in between
// orphaned, with no error anywhere.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
await ensureAccount("alice"); // the doc-shim exists, so the fault lands on the record
faults.accountRecord = true;
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the account record/);
// Nothing remembers a user that was never filed: asking again re-provisions from scratch
// rather than handing back the set the first attempt abandoned.
faults.accountRecord = false;
const createdBefore = ng.created();
const record = await ensureAccount("bob");
expect(ng.created()).toBe(createdBefore + 3);
expect(record.docPrivate).not.toBe("");
});
test("SWEEP — a document inbox the owner's branch could not record is never published", async () => {
// `openDocumentInbox` writes twice: the pair on the owner's User branch (what makes the
// inbox DRAINABLE — `myInboxes` builds the connection's drain list from it) and the
// address on the document (what makes it REACHABLE). Swallowing the first went on to
// publish the second, inviting depositors into a queue its owner never enumerates: every
// message delivered, none ever applied, permanently.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const note = await createEntityDoc("bob", "protected");
faults.inboxCapPersist = true;
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot write the inbox cap/);
expect(ng.written("inboxAddress")).toEqual([]); // nobody was told where to deposit
expect(await documentInboxAddress(note)).toBeUndefined();
});
test("SWEEP — an address that could not be published fails opening the inbox", async () => {
// The other half. Publishing is the only way a third party learns where to deposit here,
// and the address is a REPLACEMENT: the `DELETE` lands, the `INSERT` does not, and the
// document is left with no address at all while its owner is handed an inbox and told
// nothing.
const faults = noFaults();
const ng = inject(faults);
adoptCurrentUser("bob");
const note = await createEntityDoc("bob", "protected");
faults.addressPublish = true;
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot publish the inbox address/);
expect(ng.written("inboxAddress")).toEqual([]);
});