44a9b6ee04
Lot D de la revue adverse. Aucun changement de comportement de la bibliothèque : ce sont
les tests qui mentaient, et deux faux `ng` qui fabriquaient un état que le vrai broker ne
produit pas.
**« Un tiers résout l'inbox d'un autre utilisateur » prouvait le CACHE.** `userInbox`
indexe par (compte, portée) sans regarder qui demande, donc Bob tombait sur l'entrée que
la session d'Alice venait de chauffer. Rien de la persistance n'était exercé — le faux ne
servait même pas la requête `docInbox` — si bien que dans une seconde SESSION, ou une
seconde page de navigateur comme en pilote la suite applicative, Bob aurait obtenu une
inbox DIFFÉRENTE et son dépôt serait parti où personne ne lit. C'est la panne que cette
bibliothèque a déjà payée une fois.
Deux causes, toutes deux dans les faux : la requête `docInbox` n'était servie nulle part,
et le faux de `cross-user-access` prenait le **nom de graphe** pour le sujet dans un
`INSERT DATA { GRAPH <g> { … } }` — donc le pointeur du shim n'était jamais retrouvé et
chaque résolution à froid créait un nouveau shim. Les deux corrigées, et les tests qui
franchissent une frontière d'identité purgent maintenant le cache à la frontière.
**Mon propre index d'inbox avait le même défaut**, découvert en faisant ça : `isKnownInbox`
ne répondait que par sa mémoire, parce qu'aucun faux ne servait la requête. La moitié
durable n'était pas exercée — exactement la faute que ce lot corrigeait ailleurs.
**« Connecting drains BOTH levels » n'observait pas le second niveau.** Rejoué contre un
`connectedUser` qui ne draine que les inbox de l'utilisateur, il restait vert. La raison
n'est pas un test faible : le second niveau n'a **aucun producteur**. Le seul appel qui
dépose un cap est `inbox.share(doc, toUser)`, qui résout l'inbox d'un UTILISATEUR, jamais
celle d'un document. Drainer une inbox de document n'applique donc rien. Le test dit
désormais ce qu'il prouve, et l'anticipation est nommée comme telle : en amont
`AddInboxCap` est générique sur les repos et `InboxMsgContent::Link` existe, donc viser
cela est légitime — annoncer que c'est exercé ne l'était pas.
**« La liste de Bob ne contient pas la note d'Alice » n'avait pas de contrôle positif.**
Bob n'écrivait jamais de note publique : sa liste était vide quoi qu'il arrive. Il en
écrit une maintenant, et la vérification symétrique est ajoutée. Au passage, `showScope`
lisait le DOM avant le rendu — le gestionnaire `change` de l'application lance
`refresh()` sans l'attendre.
189 tests unitaires, e2e 40/40 et applicatif 12/12.
441 lines
19 KiB
TypeScript
441 lines
19 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) the read-filtered VIEW decides on possession alone — it is synchronous, so it
|
|
* asks no store anything (a public store WOULD serve its cap; that is proven on
|
|
* the read paths, in `cross-user-access.test.ts`);
|
|
* (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 type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import type { Nuri, ReadCap } from "../src/model/types";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import { share } from "../src/surface/inbox";
|
|
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";
|
|
|
|
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
|
|
* stopped publishing it. */
|
|
function hasCap(nuri: Nuri): boolean {
|
|
return getCaps().capFor(nuri) !== undefined;
|
|
}
|
|
|
|
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 } };
|
|
}
|
|
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
|
|
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
|
|
// never exercised, which is the very fault this pass was fixing elsewhere.
|
|
if (query.includes(`${SHIM}:isInbox`)) {
|
|
return { results: { bindings: quads
|
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:isInbox`)
|
|
.map((q) => ({ i: { value: q.o } })) } };
|
|
}
|
|
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Absent until
|
|
// 2026-08-10, so `userInbox` never found a persisted address and answered from the
|
|
// module cache alone: two actors in one JS realm agreed, two SESSIONS would not have.
|
|
// The suite's "a third party resolves another user's inbox" was proving the cache.
|
|
if (query.includes(`${SHIM}:docInbox`)) {
|
|
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
|
|
const pred = pm ? pm[1]! : "";
|
|
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
|
|
const subj = sm ? sm[1]! : null;
|
|
return { results: { bindings: quads
|
|
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
|
|
.map((q) => ({ d: { value: q.o } })) } };
|
|
}
|
|
// 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) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
|
|
//
|
|
// Note what this does NOT say: that a bare reference to a public document is
|
|
// unreadable. It is readable, through the read paths, because a public store serves
|
|
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
|
|
// it). This filter sits below that: it is synchronous, it decides from what the holder
|
|
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
|
|
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
|
|
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
|
|
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
const items = [item(pub, "u1")];
|
|
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
|
const cap = getCaps().capFor(pub)!;
|
|
|
|
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
|
|
// it, and the view drops it — no question asked of any store.
|
|
setCurrentUser("bob");
|
|
expect(view(items)).toEqual([]);
|
|
|
|
// Once the cap IS among what he holds — however it got there — the same view yields it.
|
|
getCaps().learn(cap);
|
|
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
|
|
});
|