fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide
`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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* document-inbox-surfaces-failure.test.ts - "this document has no inbox" must be an ANSWER.
|
||||
*
|
||||
* -- The defect this pins, and why it is worse than it looks ---------------
|
||||
* `documentInboxAddress` reads a document's Header branch for the address to deposit at,
|
||||
* and answers `undefined` when there is none. Two published calls turn that value into a
|
||||
* statement an application acts on: `inbox.postToDocument` says "its owner never opened
|
||||
* one", and `inbox.readForDocument` says zero deposits.
|
||||
*
|
||||
* Until `1.0.0-dev.9` it answered `undefined` for two things that are not that. The public
|
||||
* store could not be asked for the document's cap, and `fetchReadCap` reported a breakdown
|
||||
* with the same `false` a document exposing no cap answers with; or the address read itself
|
||||
* broke, and a `catch` returned `undefined`. Either way a depositor was told the document
|
||||
* has no inbox, and a reader was handed an empty list - for a document whose inbox is full.
|
||||
* That is the failure-as-absence this package has closed roughly eight times.
|
||||
*
|
||||
* -- What is under test, and what must NOT move ---------------------------
|
||||
* The pair is the point, and neither half proves anything alone. A document nobody opened
|
||||
* an inbox on still answers empty, and still without an error: **an absence is an answer**,
|
||||
* and a repair that turned it into a failure would only have moved the lie. The tests below
|
||||
* run BOTH over the same harness, so one cannot be satisfied at the other's expense.
|
||||
*
|
||||
* -- The fault is the broker's --------------------------------------------
|
||||
* Nothing here reaches into the library to make one of its functions throw. The world is
|
||||
* built through the surface - Alice writes a public note and opens it for messages - and
|
||||
* then the fake broker stops answering for one named repo (`wallet-fake._failReadsOn`),
|
||||
* which is what `resolve_target_for_sparql` does upstream for a repo the verifier does not
|
||||
* hold: it rejects, and never answers 0 rows.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { inbox as inboxSurface, storeRegistry } from "../src/index";
|
||||
import { bootPage, forgetEverything, signIn, type Quad } from "./wallet-fake";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
/** The broker's own cold start, so the barrier is a real one here too. */
|
||||
const COLD = { unsyncedUntilSubscribed: true } as const;
|
||||
|
||||
beforeEach(() => {
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
describe("a document's inbox address says nothing it does not know", () => {
|
||||
test("a broker that cannot answer for the document is a REJECTION, not 'no inbox'", async () => {
|
||||
const quads: Quad[] = [];
|
||||
const fake = bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
|
||||
// Bob holds nothing: the public store is what would serve him the note's cap, and it
|
||||
// is exactly that round-trip the broker now cannot answer.
|
||||
await signIn("bob");
|
||||
fake._failReadsOn.add(note);
|
||||
|
||||
// Before `1.0.0-dev.9` this resolved to `undefined` and `postToDocument` turned it
|
||||
// into "its owner never opened one" - a statement about Alice's note, made out of a
|
||||
// broker outage. Bob would have gone away believing there was nowhere to write.
|
||||
await expect(
|
||||
inboxSurface.postToDocument(note, { payload: { text: "j'apporte le cafe" } }),
|
||||
).rejects.toThrow(/UNKNOWN/);
|
||||
});
|
||||
|
||||
test("the reader is told too: a failed address read does not read as zero deposits", async () => {
|
||||
const quads: Quad[] = [];
|
||||
const fake = bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
|
||||
// Alice's own page, so the cap question never arises and `fetchReadCap` is a memo hit:
|
||||
// what breaks is the ADDRESS read itself, which used to be caught and answered
|
||||
// `undefined`. This is the second of the two routes, and it needs its own test because
|
||||
// repairing the first one leaves it untouched.
|
||||
fake._failReadsOn.add(note);
|
||||
await expect(inboxSurface.readForDocument(note)).rejects.toThrow(/RepoNotFound/);
|
||||
await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(/RepoNotFound/);
|
||||
});
|
||||
|
||||
test("and a document nobody opened an inbox on still answers EMPTY, and succeeds", async () => {
|
||||
const quads: Quad[] = [];
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
// Created through the surface and never opened for messages: a genuine absence, on a
|
||||
// broker answering perfectly well. This must stay a success, or the repair above
|
||||
// would have swapped one lie for another.
|
||||
const bare: Nuri = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
|
||||
expect(await inboxSurface.readForDocument(bare)).toEqual([]);
|
||||
expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]);
|
||||
// And the deposit side says the same thing in its own words - a refusal that names
|
||||
// the real state, not an outage.
|
||||
await expect(
|
||||
inboxSurface.postToDocument(bare, { payload: { text: "hello" } }),
|
||||
).rejects.toThrow(/has no inbox/);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,14 @@ test("resetting the caps forgets the memo — a stale yes would hand back what i
|
||||
// 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. */
|
||||
@@ -216,7 +224,8 @@ test("a fetch that FAILED is asked again, and the cap arrives once the broker re
|
||||
setCurrentUser("bob");
|
||||
|
||||
unreachable.add(PUB); // the broker cannot answer for this document
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
// 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();
|
||||
|
||||
@@ -234,14 +243,16 @@ test("an absence is remembered, a failure is not — the two negatives are not t
|
||||
setCurrentUser("bob");
|
||||
|
||||
const absent = "did:ng:o:exposes-nothing" as Nuri;
|
||||
expect(await fetchReadCap(absent)).toBe(false);
|
||||
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);
|
||||
expect(await fetchReadCap(unreached)).toBe(false);
|
||||
expect(await fetchReadCap(unreached)).toBe(false);
|
||||
// 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
|
||||
});
|
||||
|
||||
@@ -252,11 +263,18 @@ test("concurrent askers share ONE failing round-trip, and exactly one re-ask fol
|
||||
setCurrentUser("bob");
|
||||
|
||||
unreachable.add(PUB);
|
||||
const answers = await Promise.all([fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB)]);
|
||||
expect(answers).toEqual([false, false, false]);
|
||||
// `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
|
||||
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
|
||||
expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind
|
||||
});
|
||||
|
||||
@@ -271,7 +289,7 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
|
||||
|
||||
setCurrentUser("bob");
|
||||
unreachable.add(PUB);
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
|
||||
|
||||
unreachable.delete(PUB);
|
||||
setCurrentUser("carol");
|
||||
@@ -281,3 +299,22 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -498,6 +498,117 @@ describe("useShapeQuery", () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` ---
|
||||
//
|
||||
// `graphs` does not only narrow a search: each entry NAMES a document, and this surface
|
||||
// used to name one, hand it to the engine, and empty the result with the capability
|
||||
// filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists
|
||||
// to reserve for "this scope holds nothing". The four cases below are the distinct ones:
|
||||
// the document that cannot be reached, the one that can and is empty, the list where
|
||||
// only one member fails, and the emulation not yet in force.
|
||||
|
||||
it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("kate");
|
||||
// Creating one document is what puts the emulation in force - the cap registry is
|
||||
// inert until a first cap exists, and (o) below is the case where it still is.
|
||||
await createEntityDoc("kate", "protected");
|
||||
const theirs = "did:ng:o:someone-elses";
|
||||
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [theirs] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(String((snap.error as Error).message)).toContain("does not hold this document");
|
||||
// RULE 2, and it is the half a filter could never give: the question was not put at
|
||||
// all. Kate's session never asked the engine about a document she cannot open.
|
||||
expect(orm.openCount()).toBe(0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("liam");
|
||||
// His own, so the barrier passes; nothing of the shape is ever seeded into it.
|
||||
const mine = await createEntityDoc("liam", "protected");
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
// THE line that must not move. An empty document is a document that answered, and
|
||||
// the repair above would only have relocated the lie if this had become an error.
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.data).toEqual([]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(n) one unreachable document in a named list refuses the whole scope", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("maya");
|
||||
const mine = await createEntityDoc("maya", "protected");
|
||||
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
||||
const theirs = "did:ng:o:not-mayas";
|
||||
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
// Not a short list. Answering "Mine" alone would say the scope holds one object when
|
||||
// the truth is that half of it was never asked - the tolerance `readUnion` was
|
||||
// removed for in `1.0.0-dev.8`, arriving by another door.
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(true);
|
||||
expect(snap.isSuccess).toBe(false);
|
||||
expect(snap.data).toEqual([]);
|
||||
expect(orm.openCount()).toBe(0);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => {
|
||||
const ng = makeFake();
|
||||
const orm = makeOrm();
|
||||
inject(ng, orm);
|
||||
setCurrentUser("nora");
|
||||
// No document created here, so no cap was ever minted: the emulation is not in force,
|
||||
// which is the state every early path runs in. A barrier that fired here would break
|
||||
// all of them.
|
||||
const early = "did:ng:o:early-doc";
|
||||
seedEvent(orm, early, `${early}:q:one`, "Early");
|
||||
expect(getCaps().isEnforcing()).toBe(false);
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(titles(snap.data)).toEqual(["Early"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
// -- teardown ------------------------------------------------------------
|
||||
|
||||
it("(k) the last unsubscribe closes the shape query", async () => {
|
||||
|
||||
Reference in New Issue
Block a user