/** * Connecting a user either DID THE WORK or SAYS IT DID NOT — the whole case space. * * `connectedUser()` restores the caps a person was given (the Links already applied on * their User branch) and drains their inboxes. `ensureIdentity()` awaits it, and the * published contract says that call "completes the connection work it starts". So the one * thing it must never do is resolve after failing: an application then renders, shows empty * lists, and nothing anywhere says the restore never happened. The symptom a consumer sees * is *"the app works but the documents shared with me never appear"* — the worst kind, * because it looks like a permission decision and is a swallowed error. * * The rule this file pins, one line: **failing to ESTABLISH the session surfaces; failing * to apply one of its queues is reported and does not deny the session; only "there was * nothing to do" resolves quietly.** Nothing to do means exactly two things — no identity * is connected, or the identity has no account yet (connecting must never PROVISION one, * see `connect.ts`) — plus abandoning when the identity changed under the run, which is not * a failure either: the next connection picks it up. * * The queue clause was added 2026-08-16, and it is a correction rather than a softening. * Rejecting on an undrained inbox looked like the same rigour as the rest, and it was not: * a queue that cannot be applied is not consumed by failing, so it is still there at the * next connection and the one after — one unapplicable item denied a live application's * user their sign-in, permanently, three times out of three. Reporting it and carrying on * is what makes the failure recoverable instead of terminal; nothing about it is silent * (the branch below asserts the report, and that the OTHER queues still ran). * * ── Why every branch is here, not just the interesting ones ─────────────── * A swallowed failure is invisible by construction, so a suite that covers "the happy path * and one error" leaves exactly the places a bug hides. Every path through the function is * asserted below: no holder, no account, a lookup that could not answer, each of the three * identity checkpoints, each of the three collaborators failing, joining a run in flight * (both outcomes), the fire-and-forget entry, and success. * * ── The faults are the broker's, not the test's ─────────────────────────── * Every failure below 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/verifier.rs`, and what `cold-start-anchor.test.ts` models too), or a * `doc_create` that cannot reach the broker. Nothing here reaches into the library to make * one of its functions reject artificially: 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, getCaps, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, } from "../src/shared-wallet/bootstrap"; import { createEntityDoc, ensureAccount, resetRegistryCache, userInbox, } from "../src/shared-wallet/account-registry"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; import { connectedUser } from "../src/emulated-verifier/connect"; import { share } from "../src/surface/inbox"; import { sparqlUpdate } from "../src/surface/docs"; import type { Nuri } from "../src/model/types"; const SESSION: RegistrySession = { sessionId: "sid-connect", privateStoreId: "PRIV-CONNECT" }; const SHIM = "urn:ng-eventually:shim"; const INBOX = "urn:ng-eventually:inbox"; const SECRET = "urn:connect-test:secret"; 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; `null` means "answer normally". */ interface Faults { /** The doc-shim read that answers "does this account exist" throws. */ accountLookup: boolean; /** The User-branch read that answers "which Links has this user applied" throws. */ linksRead: boolean; /** `doc_create` throws — the broker cannot mint the document an inbox needs. */ docCreate: boolean; /** The User-branch read that answers "which document inboxes may I read" throws. */ inboxCapRead: boolean; /** Reading THIS inbox throws (a repo the session cannot resolve). */ inboxRead: Nuri | null; /** Called before every anchored read — the seam the identity-switch cases use. */ onQuery: ((query: string, anchor: string | undefined) => void) | null; } function noFaults(): Faults { return { accountLookup: false, linksRead: false, docCreate: false, inboxCapRead: false, inboxRead: null, onQuery: null, }; } /** 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 — the shim SPARQL, the User-branch * registers, the inbox SPARQL and the anchored per-doc read. Same shape as the one * `cross-user-access.test.ts` drives the model with, plus the fault switches above. * * No `doc_subscribe`: `ensureRepoOpen` is then a no-op by design (`open-repo.ts`), which * keeps every failure below attributable to the call that was armed. */ function makeFakeNg(faults: Faults) { const quads: Quad[] = []; /** The anchors an inbox READ was issued against, in order — what "was it drained" reads. */ const inboxReads: string[] = []; /** How many times the Links register was read — what "the work ran once" reads. */ let linksReads = 0; let docCounter = 0; const doc_create = mock(async () => { if (faults.docCreate) throw new Error("BrokerError: cannot create document"); return `did:ng:o:cdoc${++docCounter}`; }); const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[2] as string | undefined; if (!anchor) return undefined; // `INSERT DATA { GRAPH { … } }` — 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 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] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`); const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g: anchor, 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; faults.onQuery?.(query, anchor); // Store-root pointer → the doc-shim. if (query.includes(`<${SHIM}:shimDoc>`)) return rows(anchor, `${SHIM}:shimDoc`, "shimDoc"); // The account record — the read `lookupAccount` issues. if (query.includes(`<${SHIM}:id>`)) { if (faults.accountLookup) throw new Error("RepoNotFound"); const subjM = query.match(/<([^>]+)>\s+a\s+/); const only = subjM ? subjM[1]! : null; const bySubject = new Map>(); 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 ?? "" }, })), }, }; } // An inbox READ — the deposits queued for its owner. if (query.includes(`<${INBOX}:payload>`)) { inboxReads.push(anchor ?? ""); if (faults.inboxRead !== null && anchor === faults.inboxRead) throw new Error("RepoNotFound"); const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === `${INBOX}:payload`) rec.payload = q.o; if (q.p === `${INBOX}:ts`) rec.ts = q.o; if (q.p === `${INBOX}:from`) rec.from = q.o; bySubject.set(q.s, rec); } return { results: { bindings: [...bySubject.values()] .filter((r) => r.payload !== undefined && r.ts !== undefined) .map((r) => { const row: Record = { payload: { value: r.payload! }, ts: { value: r.ts! }, }; if (r.from !== undefined) row.from = { value: r.from }; return row; }), }, }; } // The User branch: the applied Links, and the inbox caps. if (query.includes(`<${SHIM}:link>`)) { linksReads += 1; if (faults.linksRead) throw new Error("RepoNotFound"); return rows(anchor, `${SHIM}:link`, "c"); } if (query.includes(`<${SHIM}:inboxCap>`)) { if (faults.inboxCapRead) throw new Error("RepoNotFound"); return rows(anchor, `${SHIM}:inboxCap`, "c"); } if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a"); if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c"); if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c"); if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e"); if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i"); if (query.includes(`${SHIM}:docInbox`)) { const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/); const pred = pm ? pm[1]! : ""; const sm = query.match(/<([^>]+)>\s+ q.g === anchor && q.p === pred && (subj === null || q.s === subj)) .map((q) => ({ d: { value: q.o } })), }, }; } // 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 } })), }, }; }); return { doc_create, sparql_query, sparql_update, inboxReads, linksReadCount: (): number => linksReads, }; } /** 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 => SESSION, normalizeId: (id: string) => id.trim().toLowerCase(), }); resetRegistryCache(); resetOpenedRepos(); resetCaps(); setCurrentUser(null); return ng; } afterEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); resetCaps(); }); /** Write one triple into `doc`, as a consumer's write path would. */ async function write(doc: Nuri, p: string, o: string): Promise { await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test"); } /** * Alice owns a protected note and shares it with Bob — the ordinary way a cap reaches * someone. Bob's account and inbox come into existence through the system (he signs in * once), never through a value the test hands across the identity boundary. * * Returns the note Alice shared, so a later assertion can ask "does Bob hold it". */ async function aliceSharesANoteWithBob(): Promise { adoptCurrentUser("bob"); await ensureAccount("bob"); // Bob has signed in before: he exists, so he can be shared with. adoptCurrentUser("alice"); const note = await createEntityDoc("alice", "protected"); await write(note, SECRET, "the-protected-content"); await share(note, "bob"); adoptCurrentUser(null); return note; } /** Does the connected identity hold this document's cap? */ function holds(doc: Nuri): boolean { return getCaps().capFor(doc) !== undefined; } // --- nothing to do: the only two silences that are legitimate --------------- test("no identity connected: nothing to restore, and the call resolves", async () => { // Anonymous holds nothing and owns no inbox, so there is genuinely no work. This is the // one branch `ensureIdentity` can never reach — it has settled an identity by then — and // it exists for the internal callers (`startConnect` is gated on a non-null id; a test or // the e2e harness may call in before signing in). const ng = inject(noFaults()); await expect(connectedUser()).resolves.toBeUndefined(); expect(ng.linksReadCount()).toBe(0); expect(ng.inboxReads).toEqual([]); }); test("an identity with no account yet: nothing to restore, and the call resolves", async () => { // Connecting must not PROVISION (see `connect.ts`): an account that does not exist has no // Links to restore and no inbox to drain. A genuine absence is therefore silence, and the // ONLY silence a failed read may not borrow — see the next test. const ng = inject(noFaults()); adoptCurrentUser("nobody-has-signed-in-as-this"); await expect(connectedUser()).resolves.toBeUndefined(); expect(ng.linksReadCount()).toBe(0); expect(ng.inboxReads).toEqual([]); }); // --- the failures, one per collaborator ------------------------------------- test("an account lookup that could not answer is not an absent account: the call rejects", async () => { // The shipped shape of this defect. The tolerant resolver answers `null` for a read that // FAILED exactly as for one that found nothing, so a broker that cannot answer looks like // "you have no account" — which connecting is entitled to pass over in silence. The whole // restore is then skipped and the promise resolves like a success. const faults = noFaults(); inject(faults); await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); resetRegistryCache(); // a fresh session: nothing is answered from a warm cache faults.accountLookup = true; await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); }); test("a Links register that cannot be read fails the connection", async () => { // The restore step. Its failure is the one with no other symptom at all: every document // ever shared with this person stays invisible, and an application has no way to tell // that from "nobody has shared anything with me". const faults = noFaults(); inject(faults); const note = await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); await connectedUser(); // a first, healthy connection: the cap is applied durably resetCaps(); resetRegistryCache(); faults.linksRead = true; await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); expect(holds(note)).toBe(false); // and it really did not restore }); test("an inbox list that cannot be built fails the connection", async () => { // Enumerating the inboxes resolves the user's own two, minting the document on first // ask. A broker that cannot create it leaves the drain list unknowable — not empty. const faults = noFaults(); inject(faults); adoptCurrentUser("bob"); await ensureAccount("bob"); // exists, but has never opened an inbox faults.docCreate = true; await expect(connectedUser()).rejects.toThrow(/cannot create document/); }); test("a document-inbox list that cannot be read fails the connection", async () => { // The other half of the drain list: the inboxes this user opened on its own documents. // Unreadable is not empty — a queue skipped for want of knowing it exists holds a share // that was delivered and will never be applied, with nothing to see anywhere. const faults = noFaults(); inject(faults); adoptCurrentUser("bob"); await userInbox("bob", "public"); // so the OWN inboxes resolve and the fault lands later await userInbox("bob", "protected"); faults.inboxCapRead = true; await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); }); /** * The one branch where a failure does NOT reject — and the four things that have to hold * at once for that to be honest. Rewritten 2026-08-16, when the previous contract * ("stop at the first failure, reject") was reported doing this to a live application: * one queue it could not apply denied its user the application, at every sign-in, because * an inbox that fails is not consumed and is still there next time. See `connect.ts`. * * A queue is not the session. Failing to REACH the queues still rejects — that case is * the test above, and it is untouched. */ test("an inbox that cannot be drained is reported, the rest are drained, and the session stands", async () => { const faults = noFaults(); const ng = inject(faults); const note = await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); const publicInbox = await userInbox("bob", "public"); // the first of the two drained const protectedInbox = await userInbox("bob", "protected"); // where Alice's share landed faults.inboxRead = publicInbox; const reported: string[] = []; const realError = console.error; console.error = (...args: unknown[]): void => void reported.push(args.map(String).join(" ")); try { // 1. The person gets their session. This is what the previous contract denied them. await connectedUser(); } finally { console.error = realError; } // 2. The queue after the failing one was still drained — a failure that took the others // down with it lost shares that had nothing to do with it. expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]); // 3. …so the share this reconnection was for actually arrived. expect(holds(note)).toBe(true); // 4. And it is NOT silence: the failure names the queue and carries the broker's error. // Ungated — this suite never turns the access log on. expect(reported.some((line) => line.includes("RepoNotFound"))).toBe(true); expect(reported.some((line) => line.includes("could not be drained"))).toBe(true); }); // The property the live report was actually about: not one refused sign-in, but EVERY one // of them. An inbox is not consumed by failing to be read, so a fault that persists is // still on the drain list at the next connection — which, under the previous contract, made // the first refusal permanent rather than transient. Signing in twice over the same // standing fault is the cheapest way to pin that it no longer is. test("a queue that keeps failing does not deny the session at the NEXT connection either", async () => { const faults = noFaults(); const ng = inject(faults); await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); const publicInbox = await userInbox("bob", "public"); faults.inboxRead = publicInbox; // a standing fault: nothing about it heals const realError = console.error; console.error = (): void => undefined; try { await connectedUser(); // A second connection, the way a reload produces one: the caches go, the wallet stays. resetRegistryCache(); resetCaps(); adoptCurrentUser("bob"); await connectedUser(); } finally { console.error = realError; } // Both connections went all the way through the list rather than stopping at the fault. expect(ng.inboxReads.filter((r) => r === publicInbox).length).toBe(2); }); // --- abandoning on an identity switch: not a failure ------------------------ test("the identity changes right after the account resolved: abandons, and resolves", async () => { // Everything below the checkpoint resolves the CURRENT holder when it reads a register, // so after a switch it would read the WRONG user's. Abandoning loses nothing: the new // identity's own connection does its own work. const faults = noFaults(); const ng = inject(faults); await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); resetRegistryCache(); faults.onQuery = (query) => { if (query.includes(`<${SHIM}:id>`)) adoptCurrentUser("carol"); }; await expect(connectedUser()).resolves.toBeUndefined(); expect(ng.linksReadCount()).toBe(0); expect(ng.inboxReads).toEqual([]); }); test("the identity changes while the Links are being read: abandons, and resolves", async () => { const faults = noFaults(); const ng = inject(faults); const note = await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); await connectedUser(); // apply the cap durably, so there IS something to restore resetCaps(); resetRegistryCache(); const drainedBefore = ng.inboxReads.length; // the healthy connection above drained them faults.onQuery = (query) => { if (query.includes(`<${SHIM}:link>`)) adoptCurrentUser("carol"); }; await expect(connectedUser()).resolves.toBeUndefined(); expect(holds(note)).toBe(false); // nothing was filed under the wrong holder expect(ng.inboxReads.length).toBe(drainedBefore); // and it never reached the queues }); test("the identity changes between two inboxes: abandons, and resolves", async () => { const faults = noFaults(); const ng = inject(faults); adoptCurrentUser("bob"); const publicInbox = await userInbox("bob", "public"); const protectedInbox = await userInbox("bob", "protected"); expect(protectedInbox).not.toBe(publicInbox); faults.onQuery = (query, anchor) => { if (query.includes(`<${INBOX}:payload>`) && anchor === publicInbox) adoptCurrentUser("carol"); }; await expect(connectedUser()).resolves.toBeUndefined(); expect(ng.inboxReads).toEqual([publicInbox]); // the checkpoint stopped the loop }); // --- joining a run already in flight ---------------------------------------- test("a concurrent caller joins the run in flight and inherits its success", async () => { const ng = inject(noFaults()); await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); const publicInbox = await userInbox("bob", "public"); const protectedInbox = await userInbox("bob", "protected"); resetRegistryCache(); const first = connectedUser(); const second = connectedUser(); await expect(first).resolves.toBeUndefined(); await expect(second).resolves.toBeUndefined(); // ONE run, two callers: each inbox was drained exactly once. Two independent runs // would show each anchor twice — the memoisation is what the joiner depends on. expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]); }); test("a concurrent caller joins the run in flight and inherits its FAILURE", async () => { // The sibling defect: a run that gives up registers itself as the run in flight FIRST, so // a caller that did nothing wrong joins it and resolves having done nothing. Inheriting // the outcome — failure included — is what makes joining safe. const faults = noFaults(); inject(faults); await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); resetRegistryCache(); faults.accountLookup = 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([connectedUser(), connectedUser()]); expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]); const reasons = outcomes.flatMap((o) => (o.status === "rejected" ? [String(o.reason)] : [])); expect(reasons).toEqual([expect.stringContaining("RepoNotFound"), expect.stringContaining("RepoNotFound")]); }); // --- the fire-and-forget entry ---------------------------------------------- test("the fire-and-forget entry reports the failure instead of dropping it", async () => { // `setCurrentUser` fires the work un-awaited, so there is no caller to reject at. The // failure must still leave a trace rather than vanish — and it must not surface as an // unhandled rejection, which would take down whatever runtime the consumer is in. const faults = noFaults(); inject(faults); await aliceSharesANoteWithBob(); resetRegistryCache(); faults.accountLookup = true; const logged: string[] = []; const original = console.error; console.error = (...args: unknown[]): void => void logged.push(args.map(String).join(" ")); try { setCurrentUser("bob"); await expect(connectedUser()).rejects.toThrow(/RepoNotFound/); } finally { console.error = original; } expect(logged.some((line) => /connect(ing)? failed/i.test(line))).toBe(true); }); // --- success ---------------------------------------------------------------- test("a healthy connection restores the applied Links and drains every inbox", async () => { // The normal case, and the reason all of the above matters: this is what an application // is entitled to assume happened when `ensureIdentity()` came back. const faults = noFaults(); const ng = inject(faults); const note = await aliceSharesANoteWithBob(); adoptCurrentUser("bob"); const publicInbox = await userInbox("bob", "public"); const protectedInbox = await userInbox("bob", "protected"); // First connection: the cap is in the inbox, draining APPLIES it (durably, as a Link). await expect(connectedUser()).resolves.toBeUndefined(); expect(holds(note)).toBe(true); expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]); // Second connection, as a later session would: nothing left in the queue, and the cap // comes back from the durable register alone. resetCaps(); resetRegistryCache(); await expect(connectedUser()).resolves.toBeUndefined(); expect(holds(note)).toBe(true); expect(ng.linksReadCount()).toBeGreaterThanOrEqual(1); });