/** * public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation. * * `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's * public document from a bare reference). This file pins the primitive itself: what it * asks, what it refuses, and when it says nothing at all. */ import { test, expect, mock, afterEach } from "bun:test"; import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store"; import { mintCap } from "../src/emulated-verifier/caps"; import { getCaps } from "../src/shared-wallet/bootstrap"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import type { Nuri } from "../src/model/types"; const SHIM = "urn:ng-eventually:shim"; const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" }; interface Quad { g: string; s: string; p: string; o: string } /** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */ function inject() { const quads: Quad[] = []; const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[2] as string; if (/^\s*DELETE WHERE/.test(query)) { for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1); return undefined; } const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/); if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! }); return undefined; }); // Documents the emulated broker cannot be asked about right now — an outage, a repo // this session has not synced, a rejected read. Scoped to ONE anchor on purpose: the // connection work `setCurrentUser` fires reads on its own account, and an outage that // took those down would be testing something else. const unreachable = new Set(); const sparql_query = mock(async (...a: unknown[]) => { if (unreachable.has(a[3] as string)) throw new Error("emulated broker: cannot serve that overlay"); return { results: { bindings: quads .filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`) .map((q) => ({ c: { value: q.o } })), }, }; }); configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION }); resetCaps(); resetPublicStoreFetches(); setCurrentUser(null); return { sparql_query, quads, unreachable }; } afterEach(() => { resetConfig(); resetStoreRegistry(); resetCaps(); setCurrentUser(null); }); /** * Alice creates her note and exposes its cap — the two halves of what `createEntityDoc` * does for a `public` scope, in that order. * * The `mint` is not decoration: exposing writes to the document, and writing needs to * reach it. Without it this file only passed while the emulation happened to be * DISARMED, which made its results depend on which test file ran first — it went red in * `bun test test/public-store.test.ts`. A fixture that exposes a cap for a * document nobody holds describes a state the library never produces. */ async function aliceExposesHerNote(): Promise { getCaps().mint(PUB); await exposeReadCap(PUB, mintCap(PUB)); } /** Arm the emulation without giving the current holder anything: some OTHER document. */ function armEmulation(): void { setCurrentUser("someone-else"); getCaps().mint("did:ng:o:unrelated"); } const PUB = "did:ng:o:pub" as Nuri; test("a cap exposed on a document is downloaded by a holder that has nothing", async () => { inject(); setCurrentUser("alice"); await aliceExposesHerNote(); setCurrentUser("bob"); armEmulation(); setCurrentUser("bob"); expect(getCaps().capFor(PUB)).toBeUndefined(); expect(await fetchReadCap(PUB)).toBe(true); expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); expect(getCaps().isInPublicStore(PUB)).toBe(true); }); test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => { inject(); armEmulation(); setCurrentUser("bob"); expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false); expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined(); }); // A document speaks for itself and for nothing else. Without this, whoever can write // into one public document could file caps for every document they care to name. test("a cap naming ANOTHER document is refused, not filed", async () => { const { quads } = inject(); setCurrentUser("alice"); await aliceExposesHerNote(); // Forge the exposed value so it names a different document. quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri); armEmulation(); setCurrentUser("bob"); expect(await fetchReadCap(PUB)).toBe(false); expect(getCaps().capFor(PUB)).toBeUndefined(); expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined(); }); test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => { const { sparql_query } = inject(); setCurrentUser("bob"); expect(await fetchReadCap(PUB)).toBe(false); expect(sparql_query).toHaveBeenCalledTimes(0); }); // REGRESSION (2026-08-07, found adversarially). The memo used to cache a BOOLEAN, so the // first holder to ask triggered the download, the cap was filed for THEM, and every later // holder got `true` while holding nothing — their next read was refused. Upstream a broker // serving a pinned outer overlay answers EVERY asker. test("a public store serves every asker, not only the first", async () => { inject(); setCurrentUser("alice"); await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); expect(await fetchReadCap(PUB)).toBe(true); expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); setCurrentUser("carol"); expect(await fetchReadCap(PUB)).toBe(true); expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // …and she HOLDS it, not just "true" }); test("asked once per document: the outcome is memoised, in both directions", async () => { const { sparql_query } = inject(); // Counted PER DOCUMENT (the read is anchored on the one being asked about), not over // every read the process makes. `setCurrentUser` fires the connection work, which reads // on its own account in the background — none of it about this document — so a total // makes the memo's arithmetic depend on whatever else happens to be in flight. This is // the same assertion, about the read it is actually about. const asks = (nuri: string): number => sparql_query.mock.calls.filter((c) => c[3] === nuri).length; setCurrentUser("alice"); await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); await fetchReadCap(PUB); const afterHit = asks(PUB); await fetchReadCap(PUB); // held now → not even the memo is consulted expect(asks(PUB)).toBe(afterHit); const absent = "did:ng:o:nothing-here" as Nuri; await fetchReadCap(absent); const afterMiss = asks(absent); expect(afterMiss).toBe(1); // it WAS asked once — a memo over nothing proves nothing await fetchReadCap(absent); // a miss is remembered too expect(asks(absent)).toBe(afterMiss); }); test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => { const { sparql_query } = inject(); setCurrentUser("alice"); await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); await fetchReadCap(PUB); resetCaps(); // also calls resetPublicStoreFetches armEmulation(); setCurrentUser("bob"); const before = sparql_query.mock.calls.length; expect(await fetchReadCap(PUB)).toBe(true); expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again }); // ── A failure is not an answer ─────────────────────────────────────────────── // REGRESSION (2026-09-15). `downloadReadCap` caught its own errors and returned the same // `null` as a genuine absence, which the memo then held for the whole session: ONE // transient outage recorded "no capability" for that document until the session ended. // Deposits being consumed, the deposit was retired before its arm read anything — and // every re-deposit sent to repair it met the same memoised `null` and was destroyed too. // // SECOND HALF (`1.0.0-dev.9`). That repair kept the two negatives apart in the MEMO and // still handed the caller one value for both: a failure and an absence were both `false`. // The tests below said so, and what they were really asserting was the memo's arithmetic // - which is why every `asksAbout` count here is unchanged. What moved is the ANSWER: a // download that could not happen now rejects, because three call sites read `false` as // "this document exposes no cap" and turned a breakdown into a statement about the // document. A genuine absence still answers `false`, and still only asks once. /** How many times the network was asked ABOUT `nuri` — anchored reads only, so the * connection work happening in the background on other documents does not count. */ function asksAbout(sparql_query: { mock: { calls: unknown[][] } }, nuri: string): number { return sparql_query.mock.calls.filter((c) => c[3] === nuri).length; } test("a fetch that FAILED is asked again, and the cap arrives once the broker recovers", async () => { const { sparql_query, unreachable } = inject(); setCurrentUser("alice"); await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); unreachable.add(PUB); // the broker cannot answer for this document // NOT `false`: "the store could not be asked" is not "the document exposes no cap". await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, PUB)).toBe(1); expect(getCaps().capFor(PUB)).toBeUndefined(); unreachable.delete(PUB); // …and recovers expect(await fetchReadCap(PUB)).toBe(true); expect(asksAbout(sparql_query, PUB)).toBe(2); // asked AGAIN — the outage was not an answer expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); }); // The two negatives in one test, so neither half can be "fixed" by sacrificing the other: // an absence was READ off the Header branch and is kept; a failure was never read at all. test("an absence is remembered, a failure is not — the two negatives are not the same", async () => { const { sparql_query, unreachable } = inject(); armEmulation(); setCurrentUser("bob"); const absent = "did:ng:o:exposes-nothing" as Nuri; expect(await fetchReadCap(absent)).toBe(false); // an ANSWER, and `false` is what it is expect(await fetchReadCap(absent)).toBe(false); expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands const unreached = "did:ng:o:cannot-ask" as Nuri; unreachable.add(unreached); // Not an answer at all, so it does not get to look like one. The two negatives now // differ in what the caller SEES, not only in what the memo keeps. await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/); await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time }); // The memo's OTHER job. Losing it would turn one cold read by N holders into N round-trips. test("concurrent askers share ONE failing round-trip, and exactly one re-ask follows", async () => { const { sparql_query, unreachable } = inject(); armEmulation(); setCurrentUser("bob"); unreachable.add(PUB); // `allSettled`, so all three outcomes are asserted rather than only the first: a // failure reaching one asker and not the others would be a worse bug than the one this // test was written for. const answers = await Promise.allSettled([ fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB), ]); expect(answers.map((a) => a.status)).toEqual(["rejected", "rejected", "rejected"]); expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind }); // `fetchReadCap` files for whoever is connected NOW, on EVERY call — the memo spares the // round-trip, not the filing. A re-attempt after a failure must obey that too, or the // recovered cap would land in the ring of whoever happened to hit the outage. test("a cap obtained after a failure is filed for whoever is connected THEN", async () => { const { unreachable } = inject(); setCurrentUser("alice"); await aliceExposesHerNote(); armEmulation(); setCurrentUser("bob"); unreachable.add(PUB); await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/); unreachable.delete(PUB); setCurrentUser("carol"); expect(await fetchReadCap(PUB)).toBe(true); expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // carol asked, carol holds it setCurrentUser("bob"); expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage }); // The rejection has to be USABLE, or a caller meeting it learns no more than it did from // `false`: it must name the document it is about, and it must not lose what actually went // wrong underneath. `cause` is where the broker's own words survive. test("the rejection names the document and keeps the original failure on `cause`", async () => { const { unreachable } = inject(); armEmulation(); setCurrentUser("bob"); const doc = "did:ng:o:outage" as Nuri; unreachable.add(doc); const error = await fetchReadCap(doc).then( () => null, (e: unknown) => e, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain(doc); expect(String((error as Error).cause)).toContain("cannot serve that overlay"); });