0455a408b6
L'objectif acté était deux appels spécifiques au polyfill, voire un. Il en publiait
quatre. Chacun des trois de trop était une raison que la BIBLIOTHÈQUE a, pas un besoin
qu'une application a :
- **`configureStoreRegistry`** existait parce qu'il y a deux internes à câbler — le SDK
injecté d'un côté, la session de l'autre. Vu de l'appelant, les deux disent « voici ce
qu'il te faut pour tourner ». Replié dans `configure`, qui prend désormais
`getSession` / `normalizeId` / `pointerGuard`.
- **`setCurrentUser`** n'a plus lieu d'être publié depuis que le portail d'accès est
passé dans le polyfill : c'est lui qui pose l'identité. Et une application qui nomme
sa propre identité est exactement le geste qui inverse le modèle — il ne doit pas
exister d'appel publié vers lequel se tourner. Le harnais e2e, lui, joue plusieurs
identités sur une même page ; il y accède par le chemin interne, ce qu'un harnais a
le droit de faire et une application non.
- **`connectedUser`** est maintenant attendu DANS `ensureIdentity`. Ce n'était pas une
commodité : la suite applicative avait montré qu'une app devait l'attendre elle-même,
sinon une note qu'on venait de lui partager se lisait comme illisible. J'avais traité
le symptôme dans l'app d'exemple ; le défaut était côté bibliothèque. En amont, ouvrir
la session EST la connexion — aucune application n'attend un second appel.
Reste donc `configure({ … })`, plus `await ensureIdentity()` dont le site d'appel
survit à la migration : une application attendra toujours une session avant de rendre.
Le test étendu hier a fait son travail : les deux contrôles de contrat sont passés au
rouge sur `configureStoreRegistry`, `connectedUser` et `StoreRegistryDeps` dès que la
surface a bougé.
180 tests unitaires, e2e 40/40 (3,4 min) et applicatif 10/10 (0,8 min).
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||
import {
|
||
ensureAccount,
|
||
resolveWriteGraph,
|
||
resolveAccount,
|
||
listMyEntityDocs,
|
||
resolveScopeGraph,
|
||
userInbox,
|
||
createEntityDoc,
|
||
resetRegistryCache,
|
||
} from "../src/shared-wallet/account-registry";
|
||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||
import { configure } from "../src/index";
|
||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||
|
||
// This suite injects a fake `ng` via configure(); bun runs test files in a
|
||
// shared process with a single module singleton, and may run this file BEFORE
|
||
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
|
||
// configured state when we're done so that guard still sees a null config.
|
||
afterAll(() => {
|
||
resetConfig();
|
||
resetStoreRegistry();
|
||
});
|
||
|
||
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
|
||
// sets module-level singletons and this suite never fully un-injects the real
|
||
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
|
||
|
||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
||
resetStoreRegistry();
|
||
resetRegistryCache();
|
||
await expect(ensureAccount("alice")).rejects.toThrow(
|
||
/configureStoreRegistry\(\) must be called before use/,
|
||
);
|
||
});
|
||
|
||
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
|
||
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
|
||
|
||
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;
|
||
}
|
||
|
||
function makeFakeNg() {
|
||
const quads: Quad[] = [];
|
||
let docCounter = 0;
|
||
|
||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||
|
||
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
|
||
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
|
||
// inside a value does NOT terminate the literal — this is what proves the
|
||
// injection escaping keeps the query well-formed.
|
||
const sparql_update = mock(async (...a: unknown[]) => {
|
||
const query = a[1] as string;
|
||
const anchor = a[2] as string | undefined;
|
||
// TWO shapes coexist here:
|
||
// - the shim account write STILL uses `GRAPH <${privateStore}>` — the
|
||
// private STORE repo's graph name equals the plain store NURI, so it
|
||
// round-trips (login must not regress). Key it by that GRAPH IRI.
|
||
// - the per-entity INDEX write has NO explicit GRAPH — the real broker keys
|
||
// it by the ANCHORED repo's default graph (repo_graph_name(id, overlay)),
|
||
// so key it by the ANCHOR arg (a[2]). An explicit `GRAPH <indexDoc>`
|
||
// would target a phantom graph → must NOT round-trip.
|
||
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*$/, "");
|
||
}
|
||
// Subject is the first <...> token in the body.
|
||
const sm = body.match(/<([^>]+)>/);
|
||
if (!sm) return undefined;
|
||
const s = sm[1]!;
|
||
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
|
||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||
let m: RegExpExecArray | null;
|
||
// Skip the subject token so we don't treat it as a predicate.
|
||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||
while ((m = pairRe.exec(after)) !== null) {
|
||
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
|
||
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: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||
const bindings = quads
|
||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||
return { results: { bindings } };
|
||
}
|
||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||
// Account SELECT, anchored to the doc-shim's default graph (records live in the
|
||
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
|
||
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
|
||
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 === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||
if (q.p === "urn:ng-eventually: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 } };
|
||
}
|
||
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
|
||
const bindings = quads
|
||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||
.map((q) => ({ e: { value: q.o } }));
|
||
return { results: { bindings } };
|
||
});
|
||
|
||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||
}
|
||
|
||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
||
|
||
function inject() {
|
||
const ng = makeFakeNg();
|
||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||
configureStoreRegistry({
|
||
getSession: async () => SESSION,
|
||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||
});
|
||
resetRegistryCache();
|
||
return ng;
|
||
}
|
||
|
||
let fake: ReturnType<typeof makeFakeNg>;
|
||
beforeEach(() => {
|
||
fake = inject();
|
||
});
|
||
|
||
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
|
||
const rec = await ensureAccount("Alice");
|
||
expect(rec.id).toBe("Alice");
|
||
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
|
||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||
// The pointer (store-root -> doc-shim) was written into the store-root graph.
|
||
const pointerWrite = fake.sparql_update.mock.calls.find(
|
||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
|
||
);
|
||
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
|
||
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
|
||
const recordWrite = fake.sparql_update.mock.calls.find(
|
||
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
|
||
);
|
||
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
|
||
});
|
||
|
||
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
||
const a = await ensureAccount("Alice");
|
||
const b = await ensureAccount("@alice");
|
||
expect(b).toEqual(a);
|
||
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
|
||
});
|
||
|
||
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
|
||
// The reconnection FORK: several callers (watchShape public+protected, the
|
||
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
|
||
// the shim has synced, so each reads 0 rows and independently provisions a new
|
||
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
|
||
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
|
||
// The in-flight de-dup collapses N concurrent provisions into ONE.
|
||
const results = await Promise.all([
|
||
ensureAccount("Bob"),
|
||
ensureAccount("Bob"),
|
||
ensureAccount("@bob"),
|
||
ensureAccount("BOB"),
|
||
ensureAccount("bob"),
|
||
]);
|
||
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
|
||
// resolution too) — 4 total, not 5×3.
|
||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||
// Every caller got the SAME record (same docs), so writer/reader can never
|
||
// disagree on the canonical scope doc.
|
||
for (const r of results) expect(r).toEqual(results[0]!);
|
||
});
|
||
|
||
|
||
test("resolveWriteGraph returns the per-scope index doc", async () => {
|
||
const rec = await ensureAccount("Carol");
|
||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||
});
|
||
|
||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||
// Session with all three store ids: private → private store; public+protected
|
||
// co-locate on the protected native store (the polyfill's Axis-A placement).
|
||
const ng = makeFakeNg();
|
||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||
configureStoreRegistry({
|
||
getSession: async () => ({
|
||
sessionId: "sid-2",
|
||
privateStoreId: "PRIV",
|
||
protectedStoreId: "PROT",
|
||
publicStoreId: "PUB",
|
||
}),
|
||
});
|
||
resetRegistryCache();
|
||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
||
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
||
// An inbox belongs to ONE virtual user — it is a dedicated document (from
|
||
// docCreate), not the private-store root, so deposits never bloat the shim graph.
|
||
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
|
||
// would collect the caps addressed to them (see inbox.ts's read guard).
|
||
const mine = await userInbox("@alice", "protected");
|
||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||
expect(mine).not.toBe("did:ng:PRIV");
|
||
expect(await userInbox("@alice", "protected")).toBe(mine); // stable
|
||
expect(await userInbox("@bob", "protected")).not.toBe(mine); // another wallet, another inbox
|
||
});
|
||
|
||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||
// The default SESSION carries only privateStoreId — non-private scopes fall
|
||
// back to the private store rather than emitting a broken NURI.
|
||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
|
||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||
});
|
||
|
||
test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
|
||
const rec = await ensureAccount("Dave");
|
||
const e1 = await createEntityDoc("dave", "public");
|
||
const e2 = await createEntityDoc("dave", "public");
|
||
const other = await createEntityDoc("dave", "protected");
|
||
// Public listing unions dave's public entities only.
|
||
const pub = await listMyEntityDocs("dave", "public");
|
||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||
const prot = await listMyEntityDocs("dave", "protected");
|
||
expect(prot).toEqual([other]);
|
||
// The index append targets the account's public index doc.
|
||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||
});
|
||
|
||
|
||
|
||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||
//
|
||
// A malicious id must NOT be able to break out of the literal / IRI it
|
||
// lands in and inject arbitrary triples into the shim (the account→doc trust
|
||
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
|
||
|
||
/** The raw INSERT DATA string produced by ensureAccount for `id`. */
|
||
async function insertFor(id: string): Promise<string> {
|
||
await ensureAccount(id);
|
||
const calls = fake.sparql_update.mock.calls;
|
||
return calls[calls.length - 1]![1] as string;
|
||
}
|
||
|
||
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
|
||
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
|
||
function rawQuoteCount(s: string): number {
|
||
const withoutEscapes = s.replace(/\\./g, "");
|
||
return (withoutEscapes.match(/"/g) ?? []).length;
|
||
}
|
||
|
||
test("injection: id with a quote cannot open extra literals", async () => {
|
||
const evil = 'x" ; <urn:evil> "pwn';
|
||
const update = await insertFor(evil);
|
||
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
|
||
// quotes (the delimiters). The injected `"` must have been escaped, so the
|
||
// count stays 8 — no extra literal was opened.
|
||
expect(rawQuoteCount(update)).toBe(8);
|
||
// The escaped id is present as a single literal value — the injected
|
||
// `<urn:evil>` survives only as INERT text inside that literal (its
|
||
// surrounding quotes are escaped `\"`), never as query syntax.
|
||
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
|
||
});
|
||
|
||
test("injection: id with '>' cannot break out of the account-subject IRI", async () => {
|
||
const evil = "x> <urn:evil";
|
||
const update = await insertFor(evil);
|
||
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
|
||
// id must NOT contain a raw `>` that would close the IRI early.
|
||
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
||
expect(subjMatch).not.toBeNull();
|
||
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
|
||
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
|
||
});
|
||
|
||
test("injection: newline / control chars in id are neutralised", async () => {
|
||
const evil = "a\nb\tc";
|
||
const update = await insertFor(evil);
|
||
// In the literal: escaped to \n / \t (no raw control char).
|
||
expect(update).toContain('"a\\nb\\tc"');
|
||
// In the IRI subject: percent-encoded.
|
||
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
|
||
expect(subjMatch[1]).toContain("%0A");
|
||
expect(subjMatch[1]).toContain("%09");
|
||
});
|
||
|
||
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
|
||
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
|
||
const update = await insertFor(evil);
|
||
// The whole attack survives, escaped, as ONE literal value — the injected
|
||
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
|
||
expect(update).toContain(escapeLiteralRef(evil));
|
||
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
|
||
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
|
||
expect(rawQuoteCount(update)).toBe(8);
|
||
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
|
||
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
|
||
});
|
||
|
||
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
|
||
function escapeLiteralRef(v: string): string {
|
||
return `"${v
|
||
.replace(/\\/g, "\\\\")
|
||
.replace(/"/g, '\\"')
|
||
.replace(/\n/g, "\\n")
|
||
.replace(/\r/g, "\\r")
|
||
.replace(/\t/g, "\\t")}"`;
|
||
}
|
||
|
||
test("injection: a malicious id still round-trips through the shim", async () => {
|
||
const evil = 'eve" ; <urn:evil> "x';
|
||
const rec = await ensureAccount(evil);
|
||
expect(rec.id).toBe(evil);
|
||
resetRegistryCache();
|
||
// The stored id comes back verbatim (escaping is lossless) when resolved by its
|
||
// own key — and no injected extra subject answers in its place.
|
||
const back = await resolveAccount(evil);
|
||
expect(back?.id).toBe(evil);
|
||
expect(back?.docPublic).toBe(rec.docPublic);
|
||
});
|
||
|
||
test("normalizeId defaults to trim when not provided", async () => {
|
||
const ng = makeFakeNg();
|
||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||
configureStoreRegistry({ getSession: async () => SESSION });
|
||
resetRegistryCache();
|
||
const a = await ensureAccount(" Ivy ");
|
||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||
expect(b).toEqual(a);
|
||
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||
});
|
||
|
||
test("a user has TWO inboxes — public and protected — and they are distinct documents", async () => {
|
||
// Upstream a site carries an inbox on its public store repo and another on its
|
||
// protected one (`engine/verifier/src/site.rs:127-152`), addressed separately down to
|
||
// the contact predicates (`ng:site_inbox` vs `ng:protected_inbox`). Exposing one was a
|
||
// cardinality this library invented; neither the ORM nor the wasm binding says
|
||
// anything about inboxes, so the engine's model is what decides.
|
||
resetRegistryCache();
|
||
const pub = await userInbox("@dana", "public");
|
||
const prot = await userInbox("@dana", "protected");
|
||
expect(pub).not.toBe(prot);
|
||
// …and each is stable for its own scope.
|
||
expect(await userInbox("@dana", "public")).toBe(pub);
|
||
expect(await userInbox("@dana", "protected")).toBe(prot);
|
||
});
|