feat: un document en store public sert son ReadCap, une référence nue suffit

Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch
will be downloaded from the outerOverlay, if the peer brokers listed below allow
it […] the public site are served differently by brokers"*
(engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-06 19:55:32 +02:00
parent 32ef756b0b
commit 0832338201
28 changed files with 764 additions and 162 deletions
+15
View File
@@ -160,6 +160,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
@@ -178,6 +183,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
@@ -194,6 +204,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await docCreate("sid-log", "Graph", "data:graph", "store");
+38 -10
View File
@@ -7,7 +7,7 @@
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test";
import { CapRegistry } from "../src/emulated-verifier/caps";
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
import { hasReadCap, targetOf } from "../src/model/nuri";
import type { ReadCap } from "../src/model/types";
@@ -87,24 +87,52 @@ test("a cap received (learn) reads, exactly like one minted", () => {
expect(bob.caps.capFor(doc)).toBe(cap);
});
test("recordInPublicStore returns a cap-bearing link; reading it still means HOLDING it", () => {
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
// This registry is one level below that: it records WHERE a document sits, and it
// files a served cap apart from one that was minted or deposited — because the two
// grant different things.
test("markInPublicStore records where a document sits, and mints nothing", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const link = caps.recordInPublicStore(doc);
caps.markInPublicStore(doc);
expect(hasReadCap(link)).toBe(true);
expect(targetOf(link)).toBe(doc);
expect(caps.isInPublicStore(doc)).toBe(true);
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
// Marking is not holding: the fact is about the document, the cap is about a holder.
expect(caps.capFor(doc)).toBeUndefined();
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
caps.learn(link); // bob received the link (e.g. from the discovery index)
expect(caps.capFor(doc)).toBe(link);
});
test("open(): a public document is published as a link, a private one is not", () => {
test("a cap SERVED by a public store reads, and is refused a write", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const served = mintCap(doc);
become("bob");
caps.learnFromPublicStore(served);
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
caps.learn(served);
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
});
test("the owner of a public document is never read-only on it", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:mine";
caps.open(doc, "public"); // alice created it
// A third party fetching the same document must not affect her claim on it.
become("bob");
caps.learnFromPublicStore(mintCap(doc));
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
become("alice");
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
});
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");
+52 -34
View File
@@ -26,12 +26,22 @@ import {
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
* Do I hold this document's cap? Possession, asked of the internal registry — the
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
* question it reads like "may I read this?" and a public store's document answers
* `false` until something has asked for its cap.
*/
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
afterAll(() => {
resetConfig();
resetStoreRegistry();
@@ -168,6 +178,10 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
}
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
@@ -207,7 +221,13 @@ async function readValues(docs: Nuri[], p: string): Promise<string[]> {
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI. Returns what each actor could plausibly come to hold.
* REFERS to it by bare NURI.
*
* What crosses to the other actors is **the bare reference of the public document and
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
* this file: an application circulates references, and if a test had to hand a key
* across an identity boundary through a JS variable, the feature it claims to prove
* would have no path in any real application.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
@@ -219,8 +239,7 @@ async function aliceSetsUpHerDocuments() {
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc
return { protDoc, pubDoc, pubLink };
return { protDoc, pubDoc };
}
/** Follow the reference found in the public document — what a reader actually does. */
@@ -232,11 +251,11 @@ function referenceFoundIn(values: string[]): Nuri {
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob was given the public document's link — "whoever has the URL reads it".
getCaps().learn(pubLink);
// Bob holds the BARE reference and nothing else. The document sits in a public
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
@@ -250,7 +269,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
@@ -259,7 +278,6 @@ test("Charlie: same public document, same reference — and he reads through it"
await share(protDoc, "charlie");
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
@@ -270,18 +288,16 @@ test("Charlie: same public document, same reference — and he reads through it"
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("bob");
getCaps().learn(pubLink);
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
@@ -293,11 +309,10 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
getCaps().learn(pubLink);
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable.
@@ -331,16 +346,24 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
unsub();
});
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
// The property this whole batch exists for, stated on its own: WHERE a document sits
// decides whether a bare reference is enough. Upstream a public store's repos are
// served on the outer overlay and their ReadCap is downloaded from it
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link.
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
getCaps().learn(pubLink);
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
@@ -369,7 +392,10 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob");
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
// itself, and that work is precisely what restores the cap. An awaited check here
// would be asserting who won a race, not what the library does.
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
@@ -396,13 +422,12 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
const link = getCaps().capFor(doc)!; // the repo link alice circulates — links DO travel
// Bob RESOLVES the address himself, from the document. The only thing he is handed
// is the link, which is the one thing the model says circulates. The address is not
// passed to him — if it had to be, there would be no way for an app to get it.
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
// handed, and the only thing an application circulates. The document is in a public
// store, so the store serves him its read cap; the address is not passed to him,
// because if it had to be there would be no way for an app to get it.
setCurrentUser("bob");
getCaps().learn(link);
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
@@ -422,11 +447,8 @@ test("opening an inbox on someone else's document is refused, not silently forke
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
const link = getCaps().capFor(doc)!;
// Bob holds the document — that is a READ right, and it is not ownership.
// Bob can READ the document (it is in a public store) — and reading is not ownership.
setCurrentUser("bob");
getCaps().learn(link);
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
@@ -436,13 +458,11 @@ test("a fresh document has NO inbox — one belongs to one document, and only it
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const link = getCaps().capFor(doc)!;
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
@@ -456,9 +476,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
const link = getCaps().capFor(doc)!;
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
+26 -10
View File
@@ -11,16 +11,17 @@
* 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;
* (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 { ReadCap } from "../src/model/types";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
import type { Nuri, ReadCap } from "../src/model/types";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter";
@@ -35,6 +36,12 @@ 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`. */
@@ -247,21 +254,30 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () =
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 () => {
// (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 link = getCaps().capFor(pub)!;
const cap = getCaps().capFor(pub)!;
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
// 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([]);
// Receiving the repo link — what a discovery entry actually carries — opens it.
getCaps().learn(link);
// 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"]);
});
+152
View File
@@ -0,0 +1,152 @@
/**
* 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,
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
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;
});
const sparql_query = mock(async (...a: unknown[]) => ({
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 };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
/** 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 exposeReadCap(PUB, mintCap(PUB));
setCurrentUser("bob");
armEmulation();
setCurrentUser("bob");
expect(getCaps().capFor(PUB)).toBeUndefined();
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
// …and what he got is a READ grant, recorded as such.
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
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 exposeReadCap(PUB, mintCap(PUB));
// 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);
});
test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
armEmulation();
setCurrentUser("bob");
await fetchReadCap(PUB);
const afterHit = sparql_query.mock.calls.length;
await fetchReadCap(PUB); // held now → not even the memo is consulted
expect(sparql_query.mock.calls.length).toBe(afterHit);
const absent = "did:ng:o:nothing-here" as Nuri;
await fetchReadCap(absent);
const afterMiss = sparql_query.mock.calls.length;
await fetchReadCap(absent); // a miss is remembered too
expect(sparql_query.mock.calls.length).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 exposeReadCap(PUB, mintCap(PUB));
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
});
+1 -1
View File
@@ -18,7 +18,7 @@ function setup(initial: string | null = "alice") {
const before = holder;
holder = "alice";
caps.mint("did:ng:o:alice");
const link = caps.recordInPublicStore("did:ng:o:public");
const link = caps.open("did:ng:o:public", "public");
holder = before;
return { caps, link, become: (id: string | null) => (holder = id) };
}