3257afe8c0
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.
220 lines
9.0 KiB
TypeScript
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();
|
|
}
|
|
});
|