fix: un abonnement en échec n'empoisonne plus le document, et un dépôt ne change plus de destinataire

This commit is contained in:
Sylvain Duchesne
2026-08-17 11:47:21 +02:00
parent 520c8c59a8
commit 33212a8b00
7 changed files with 493 additions and 48 deletions
@@ -34,6 +34,8 @@ import { getCaps, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resolveAccount, userInbox } from "../src/shared-wallet/account-registry";
import { observationSettled } from "../src/emulated-verifier/inbox-observer";
import { cancelScheduledInboxProcessing } from "../src/emulated-verifier/inbox-processor";
import { connectedUser } from "../src/emulated-verifier/connect";
import { setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { bootPage, forgetEverything, signIn, type FakeWallet, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
@@ -268,6 +270,42 @@ describe("switching identity", () => {
expect(await documentsGivenTo("alice")).toEqual([]);
});
test("MID-DRAIN files nothing for the identity that arrives", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
// Test-side inspection only: the address is used to recognise the read in flight, and
// is never handed to an actor.
const aliceInbox = await userInbox("alice", "protected");
await signIn("alice");
// The broker takes its time over the deposits read, and the page switches user INSIDE
// that window — a person clicking "sign in as Bob" while a push is being applied. The
// ownership guard has already passed by then; it ran at the start of the read.
const answering = fake.sparql_query.getMockImplementation()!;
let switched = false;
fake.sparql_query.mockImplementation(async (...args: unknown[]) => {
const answer = await answering(...args);
if (!switched && args[3] === aliceInbox && String(args[1]).includes(`${INBOX}:payload`)) {
switched = true;
setCurrentUser("bob");
}
return answer;
});
fake._deliver(inTransit);
await converge();
// Bob was GIVEN nothing. (His own cap on the document is not evidence either way — he
// made it; what would be evidence is a Link, and there must be none.) Filed here, the
// cap addressed to Alice becomes a capability Bob holds at his next sign-in, and its
// real recipient is left with nothing at all.
expect(await documentsGivenTo("bob")).toEqual([]);
// …and Alice has lost nothing: an inbox is not consumed by a drain that abandoned it,
// so what was deposited for her is still there when she is the one connected.
await signIn("alice");
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
});
test("and Alice coming back finds the deposit still there to apply", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
await signIn("alice");
@@ -315,6 +353,50 @@ describe("a deposit that cannot be applied", () => {
expect(getCaps().capForHolder("alice", first.doc)).toBeDefined();
});
test("because its inbox could not be WATCHED is reported, and attempted again", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// A repo that never pushes its initial `State` is what a refused subscription looks like
// from the bootstrap open's side, and it waits out its bounded fallback before giving up.
// Eight seconds of it, twice, is the production wait and not a unit test's.
setOpenTimeoutForTests(20);
// The broker will not open a channel on Alice's inbox. Everything else about her session
// works — which is the point: the only symptom of a watch that was never established is
// that shares stop arriving.
const opening = fake.doc_subscribe!.getMockImplementation()!;
let refusing = true;
fake.doc_subscribe!.mockImplementation(async (...args: unknown[]) => {
if (refusing && args[0] === aliceInbox) throw new Error(`RepoNotFound: ${String(args[0])}`);
return opening(...args);
});
const reported = await whileWatchingTheLog(async () => {
await signIn("alice");
await converge();
});
// Said out loud, in this package's own words and under the connected identity — not left
// as the absence of a push.
expect(reported.filter((l) => /could not be watched/.test(l)).length).toBeGreaterThan(0);
expect(reported.find((l) => /could not be watched/.test(l))).toContain("[alice][polyfill]");
// Nothing is watching, so the deposit that lands now cannot be applied — and is not.
fake._deliver(inTransit);
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeUndefined();
// The broker recovers and Alice does something ordinary. The inbox that could not be
// opened was not written off for the session: it is subscribed to on the next
// enumeration, and its initial push finds the deposit still waiting.
refusing = false;
await storeRegistry.createEntityDoc("protected");
await converge();
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
});
test("never rejects into the application — nobody asked for this work", async () => {
const { inTransit } = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
@@ -338,3 +420,41 @@ describe("a deposit that cannot be applied", () => {
expect(unhandled).toEqual([]);
});
});
describe("a connection whose own work FAILED", () => {
test("still leaves the identity watched — being connected is what is observed", async () => {
const { doc, inTransit } = await bobSharesWithAlice();
// The broker cannot answer for one of Alice's own stores, so the RESTORE fails and the
// connection rejects. She is connected regardless: `setCurrentUser` is synchronous and
// took effect before any of this ran, and nothing signs her back out.
const store = (await resolveAccount("alice"))?.docPublic;
if (store === undefined) throw new Error("the fixture did not give Alice a public store");
fake._failReadsOn.add(store);
setCurrentUser("alice");
let rejected = false;
await whileWatchingTheLog(async () => {
try {
await connectedUser();
} catch {
rejected = true;
}
await converge();
});
// The caller is still TOLD, and that rule is not what changes here: failing to reach the
// registers rejects, exactly as before.
expect(rejected).toBe(true);
// The hiccup passes. Alice never touched the page.
fake._failReadsOn.delete(store);
fake._deliver(inTransit);
await converge();
// What she is owed is not the restore she lost — it is that a deposit made while she sits
// there converges. Watching used to be the LAST line of the connection work, so a restore
// that rejected skipped it and left her connected with nothing observing her inboxes: one
// hiccup at sign-in, and every share made afterwards was lost to her for the session.
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
expect(await documentsGivenTo("alice")).toContain(doc);
});
});