Files
ng-eventually/packages/client/test/reach.test.ts
T
Sylvain Duchesne 3257afe8c0 feat(inbox): un utilisateur a DEUX inboxes, comme en amont
Tranché par la cascade plutôt qu'en attendant une réponse : le niveau 3 (ORM) ne
dit rien des inbox, le niveau 2 non plus — `@ng-org/web` n'expose aucune méthode
contenant « inbox » et la session n'en publie aucune. C'est donc le modèle du
moteur qui décide, et il dit DEUX : un site porte une inbox sur son repo de store
public et une autre sur son protégé (`engine/verifier/src/site.rs:127-152`), les
seuls `AddInboxCap` du moteur, `new_store_default` n'en posant une que
`if !private`. Elles sont adressées séparément jusque dans les enregistrements de
contact, qui choisissent leur prédicat selon le profil visé — `ng:site_inbox` pour
un profil public, `ng:protected_inbox` sinon
(`engine/verifier/src/inbox_processor.rs:787,823-824`).

`userInbox(id)` en exposait une : une cardinalité que cette bibliothèque avait
inventée, et que le nom `walletInbox` avait contribué à masquer. Elle prend
désormais le scope, et le store PRIVÉ n'en a pas — d'où `InboxScope` plutôt que
`Scope` : demander l'inbox privée n'est pas une recherche qui ne rend rien, c'est
une question sans référent dans le modèle, et le type l'interdit.

`myInboxes` énumère les deux, `isOwnInbox` reconnaît les deux. Le shim garde un
triple par (user, scope).

160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 16:19:49 +02:00

220 lines
9.0 KiB
TypeScript

/**
* reach.test.ts — the virtual user boundary, at the passage points.
*
* A virtual user must simulate the boundary of the future single-user wallet: the
* access functions are confined to the user currently connected, and no cross-user
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
* exported from the SDK entry — reached ANY document of ANY identity given a
* session id and a NURI.
*
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
* how a link travels between users at all, and it gives the depositor nothing back.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs";
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { mayReach, mustNotAttempt } from "../src/emulated-verifier/reach";
import { hasReadCap } from "../src/model/nuri";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
function inject() {
let n = 0;
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
const ng = {
doc_create: mock(async () => `did:ng:o:reach${++n}`),
sparql_update: mock(async (...a: unknown[]) => {
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
return undefined;
}),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { ng, quads };
}
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
const { ng } = inject();
// Nothing has been created, so no cap has been issued: everything flows.
expect(mayReach("did:ng:o:anything")).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
inject();
setCurrentUser("alice");
const mine = await createEntityDoc("alice", "private");
// Mine: reachable.
expect(mayReach(mine)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
const theirs = "did:ng:o:someone-elses-doc" as const;
expect(mayReach(theirs)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document.s cap/i,
);
await expect(
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
).rejects.toThrow(/does not hold this document.s cap/i);
});
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
expect(mayReach(bobDoc)).toBe(true);
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
setCurrentUser("alice");
expect(mayReach(aliceDoc)).toBe(true);
expect(mayReach(bobDoc)).toBe(false);
});
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "protected"); // provisions alice's account
const inbox = await userInbox("alice", "protected");
expect(mayReach(inbox)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
// …and not another user's inbox.
setCurrentUser("bob");
expect(mayReach(inbox)).toBe(false);
});
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
const { ng } = inject();
setCurrentUser("bob");
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
// The deposit goes through anyway — it is the one legitimate cross-user act.
const before = ng.sparql_update.mock.calls.length;
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
// …and it grants her nothing: she still cannot read that inbox.
expect(mayReach(bobInbox)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
/does not hold this document.s cap/i,
);
});
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
// The store-root and the doc-shim are NOT reachable through the virtual-user
// surface — there is no exemption list any more. The machinery reaches them
// through its own primitives (`physical.ts`), which the boundary never sees and
// which are never exported from the package.
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
await expect(
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
).rejects.toThrow(/does not hold this document's cap/i);
// …yet the registry works, because it never asked through that door.
const doc = await createEntityDoc("alice", "protected");
expect(mayReach(doc)).toBe(true);
});
// The two rules are deliberately redundant, and this is what that buys.
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation
const theirs = "did:ng:o:not-mine" as const;
// RULE 2 — a caller that checks first simply does not issue the operation.
expect(mustNotAttempt(theirs)).toBe(true);
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
// point of implementing the same criterion in two places: rule 2 is where the
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
// a lapse in rule 2 fail loudly instead of quietly succeeding.
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document's cap/i,
);
});
// Possession decides, not the shape of the reference the caller happens to hold.
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "private");
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
// bare through content and indexes while the cap sits in what the user holds.
expect(hasReadCap(doc)).toBe(false);
expect(mayReach(doc)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
// The cap-bearing form of the same document answers alike.
expect(mayReach(`${doc}:r:OK`)).toBe(true);
// And bob, holding neither, cannot reach it in either form.
setCurrentUser("bob");
expect(mayReach(doc)).toBe(false);
expect(mayReach(`${doc}:r:OK`)).toBe(false);
});
// The whole point of splitting the machinery out: one API is the app's, the other
// must never be. A regression here is silent and total — an app holding the
// machinery reaches every virtual user's documents.
test("the machinery is NOT part of the package's public surface", async () => {
const entry: Record<string, unknown> = await import("../src/index");
const polyfill: Record<string, unknown> = await import("../src/polyfill");
for (const surface of [entry, polyfill]) {
for (const name of Object.keys(surface)) {
expect(name).not.toMatch(/^physical/);
}
}
// Named explicitly, so adding one and forgetting the rule fails here.
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
expect(entry[forbidden]).toBeUndefined();
expect(polyfill[forbidden]).toBeUndefined();
}
// The cross-account fan-out is gone from the registry entirely.
const registry = entry.storeRegistry as Record<string, unknown>;
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
expect(registry[gone]).toBeUndefined();
}
});