Files
ng-eventually/packages/client/test/reach.test.ts
T
Sylvain Duchesne 107f9d1633 refactor(vocabulary): les noms publiés parlent la langue de la cible, et un test le tient
La correction de nomenclature du 2026-07-30 — en amont un *wallet* n'est qu'un
trousseau, ce qui possède des stores est un **user** (un *site*) — s'était faite
à la main. `walletInbox` y a échappé et a vécu des semaines, en faisant des
dégâts : le nom rendait « une inbox par wallet » évident, masquant qu'un user en
a **deux** en amont (repos de store public et protected, les deux seuls
`AddInboxCap` du moteur). Une discipline appliquée à la main en oublie un ; un
test non.

D'où `test/vocabulary.test.ts` : tout nom publié est bâti sur des mots que la
CIBLE emploie — vérifiés dans `nextgraph-rs` — ou porte un marqueur disant
POURQUOI il n'existe qu'ici (`virtual`, `physical`, `shim`, `emulated`,
`polyfill`), ce qui dit aussi quand il disparaît. Un échec n'est pas « renommer
pour faire passer le test », c'est une question : la cible a-t-elle un mot pour
ça ? la chose n'existe-t-elle qu'ici ? le mot est-il vraiment de la glue ?

Ce que le test a trouvé, et les réponses :

- `walletInbox` → `userInbox`, avec l'écart de cardinalité écrit noir sur blanc
  plutôt que caché par le nom.
- `accounts` / `AccountRecord` / `AccountStorage` → `virtualUsers` /
  `VirtualUserRecord` / `VirtualUserStorage`, module `accounts.ts` →
  `virtual-users.ts`. « account » n'est pas de la cible : c'est notre mot pour
  l'utilisateur virtuel, et le marqueur le dit désormais.
- `readModel` → la fonction `readUnion`, exposée directement. « model » n'était
  ni de la cible ni de la glue, et le namespace ne tenait qu'une fonction.
- Le reste était du vocabulaire légitime à déclarer (`subject`, `base`,
  `schema`, `connected`, le modèle réactif de l'ORM).

Corrigé au passage, sur signalement du contrat interne : l'en-tête d'`open-repo`
justifiait son correctif par un mécanisme que le source contredit. Un repo absent
de `self.repos` lève bien `RepoNotFound`
(`engine/verifier/src/request_processor.rs:264,269`). Les 0 lignes observées
viennent d'ailleurs — `Verifier::load` repeuple `self.repos` depuis le stockage
sur un profil persistant (`verifier.rs:535-560`), et notre propre `readDoc`
attrape toute erreur et rend `[]`. Le correctif est bon, le diagnostic écrit à
côté ne l'était pas.

159 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
2026-08-04 14:35:01 +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");
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");
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();
}
});