Files
ng-eventually/packages/client/test/isolation-active.test.ts
T
Sylvain Duchesne 3c981ffadb docs: le nommage de NextGraph l'emporte toujours — y compris sur nos propres règles
La règle que je venais d'écrire bannissait « publish » sans réserve. Or le
moteur emploie `publisher` 126 fois — `as_publisher`, `publisher_advert` — pour
un rôle pub/sub sur un topic. Une règle appliquée à la lettre aurait fait
rejeter du vocabulaire amont, ce qui est exactement l'inverse du but.

Le principe est donc posé au-dessus, et il prime sur tout : là où la cible a un
mot, c'est le mot, point. Ce qui reste banni est NOTRE « publier un document »,
qui n'a aucun référent en amont et recouvre trois actes distincts — placer dans
un store public, rendre trouvable, remettre une clé. Ça n'autorise jamais à
renommer ce que l'amont appelle `publisher`.

`publisher`, `topic` et `advert` rejoignent le vocabulaire déclaré du contrôle
de noms, avec la raison en commentaire.

Le renommage `publishRepoLink` → `recordInPublicStore` reste justifié : cette
méthode n'a pas de pendant amont, elle enregistre un document en store public
(et, artefact d'émulation, lui frappe une clé).
2026-08-06 15:50:54 +02:00

402 lines
16 KiB
TypeScript

/**
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + KEY POSSESSION.
*
* Mirrors what the app does: create an entity document through the REAL registry
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
* needs no dedicated operation: processing their inbox absorbs it.
*
* What the read filter then shows:
* (a) a document nobody shared is unreadable, and stays unreadable for a third
* party after a share to someone else — sharing is per-document, per-inbox;
* (b) a bare reference grants NOTHING (naming is not reading), while the repo
* link of a published document opens it for whoever receives it;
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import { linkTo } from "../src/surface/placement";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { ReadCap } from "../src/model/types";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single 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` serving BOTH the shim SPARQL and the inbox SPARQL. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
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, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root → doc-shim).
if (query.includes(`<${SHIM}:shimDoc>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) 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);
}
const 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 ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT.
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
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);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
// Scope-index `contains` SELECT.
if (query.includes(`<${SHIM}:contains>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** The items an ORM set would carry, one per document. */
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
/** What the current holder reads out of `items`. */
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
test("a created document is readable by its creator and by nobody else", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
setCurrentUser("alice");
expect(view(items)).toEqual(["a1"]);
setCurrentUser("bob");
expect(view(items)).toEqual(["b1"]);
setCurrentUser(null);
expect(view(items)).toEqual([]); // anonymous holds nothing
expect(getCaps().isEnforcing()).toBe(true);
});
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
inject();
setCurrentUser("alice");
const shared = await createEntityDoc("alice", "protected");
const kept = await createEntityDoc("alice", "protected");
const items = [item(shared, "s1"), item(kept, "k1")];
// BEFORE the share: bob reads nothing of alice's.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// The app decides alice↔bob are related: alice shares ONE document's cap into
// bob's OWN inbox — the only cross-wallet act there is.
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(shared, "bob");
// bob processes his inbox — no dedicated "receive" operation exists.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
// carol, who was not shared with, still reads nothing.
setCurrentUser("carol");
await readInbox(await userInbox("carol", "protected"));
expect(view(items)).toEqual([]);
});
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
await share(doc, "bob");
setCurrentUser("bob");
const deposits = await readInbox(bobInbox);
expect(deposits).toEqual([]); // infrastructure, not consumer data
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
});
// (b) A bare reference grants nothing; the repo link of a published document does.
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
inject();
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
const items = [item(pub, "u1")];
expect(getCaps().isInPublicStore(pub)).toBe(true);
const link = linkTo(pub);
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// Receiving the repo link — what a discovery entry actually carries — opens it.
getCaps().learn(link);
expect(view(items)).toEqual(["u1"]);
});
// (c) Identity change switches heldByHolder; it does not wipe them.
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
expect(hasCap(doc)).toBe(true);
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true); // durable across the switch — nothing re-declared
});
// A virtual user IS a shim account, and the shim keys accounts through the
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
// that spells its own identity differently between two calls ("@Alice" at login,
// "alice" later) gets a second held caps and stops reading its own documents.
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
setCurrentUser("@Alice");
const doc = await createEntityDoc("@Alice", "protected");
expect(hasCap(doc)).toBe(true);
// Same account, spelled differently — same shim account, so the same held caps.
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true);
setCurrentUser(" ALICE ");
expect(hasCap(doc)).toBe(true);
// A genuinely different account still holds nothing.
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
});
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
// defeating directed sharing entirely. Depositing stays open (it is the only way a
// link crosses between wallets at all); reading does not.
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
inject();
setCurrentUser("alice");
const secret = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await share(secret, "bob");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(true); // still hers, obviously
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
setCurrentUser("mallory");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(false); // nothing was absorbed
// Anonymous owns no inbox at all.
setCurrentUser(null);
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
// Bob reads his own, and only then does the cap land.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(hasCap(secret)).toBe(true);
});
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const items = [item(doc, "p1")];
// Simulate a new session over the same wallet: caps are in memory, so they go —
// the registry cache too. Only the persisted documents remain.
resetCaps();
resetRegistryCache();
expect(view(items)).toEqual([]);
// Listing my own documents refiles their caps: this is the store branch that
// carries `AddRepo { read_cap }` upstream.
const { listMyEntityDocs } = await import("../src/shared-wallet/account-registry");
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
expect(view(items)).toEqual(["p1"]);
});
// The Store branch exists so a cap is READ back, not recomputed. Without this test
// the two are indistinguishable: with a stand-in value, re-minting happens to give
// the same string. So corrupt the stored cap and check the corruption wins — proof
// the value comes from the store, and proof that P1b's real key will too.
test("a document's cap is READ from the Store branch, never recomputed", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
expect(stored.length).toBe(1);
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
// Rewrite it to a DIFFERENT value, then start a fresh session.
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
resetCaps();
resetRegistryCache();
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
// Recomputing would have produced `:r:OK`; this is what was stored.
expect(getCaps().capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
});
// The listing and the keys are separate upstream (Main vs Store branch), and the
// separation has to survive here or a document could be listed without its cap.
test("the listing and the caps are two separate records", async () => {
const ng = inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private");
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
});
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
// any path that mints a SECOND cap instead of using the stored one breaks: the
// creator would hold a key that does not open its own document. This pins that the
// creation path mints exactly once.
test("creation mints the cap ONCE — the stored value is the one held", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
expect(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
});