75313f75a2
`fetchReadCap` rendait `false` pour trois situations sans rapport : l'emulation inerte, le document qui n'expose aucune capacite, et le reseau qu'on n'a pas pu interroger. Les trois appelants vivants lisaient ce `false` comme « pas de capacite » et transformaient une panne en affirmation sur le document : `ensureRepoOpen` renoncait en silence et laissait la lecture suivante accuser le lecteur de ne rien detenir, et `documentInboxAddress` repondait « ce document n'a pas d'inbox », ce qu'une application lit comme zero depot. Le telechargement rejette desormais jusqu'a l'appelant, avec le document nomme et la cause d'origine conservee. `false` ne veut plus dire qu'une chose : le document n'expose rien, et c'est une reponse. Le memo garde la meme asymetrie qu'avant. `documentInboxAddress` propage aussi l'echec de sa propre lecture d'adresse : le `catch` qui rendait `undefined` se justifiait par un refus que `mustNotAttempt` a deja ecarte une ligne plus haut, donc il n'avalait que des pannes. `useShapeQuery` franchit la barriere sur chaque document NOMME par `scope.graphs` avant d'ouvrir la requete : capacite demandee au store public, refus a voix haute si elle n'est pas detenue, ouverture et synchronisation. Un document nomme qu'on ne peut pas lire donnait `isSuccess` avec `data: []` apres que le filtre de capacites l'eut vide - la seule reponse que cette surface existe pour reserver a « ce scope ne contient rien ». Restent intacts et succes : `did:ng:i`, `graphs: []`, un document joignable et vide, et tout le cas inerte. `fetchReadCaps` supprime : aucun appelant, et une politique de tolerance par lot sans proprietaire au-dessus d'un appel qui rejette desormais.
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
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 <other-file> 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<void> {
|
|
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");
|
|
});
|