Files
ng-eventually/packages/polyfill/test/inbox.test.ts
Sylvain Duchesne 12eba6eea6 fix: quatre écarts entre la surface publiée et ce que NextGraph déclare
Un audit de la surface contre la source amont en a trouvé cinq ; voici les
quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles
— relève du dessin et reste ouverte.

L'identifiant de session bloquait. Amont le déclare string | number
(sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions
une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK
venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et
jamais converti : une chaîne échoue pour de vrai (Deserialization error of
session_id JsValue("1"), observé).

sparqlUpdate annonçait Promise<void> alors qu'il relayait DÉJÀ les commits.
C'était donc un mensonge de typage, pas un comportement — et la doublure de test
qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui
l'a laissé sans contradicteur.

ng était publié en Record<string, any>, ce qui perdait les 88 membres typés
d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux.

Et materialize, second nom publié de read, sans appelant ni contrepartie amont,
est retiré.

docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur
les deux premiers points. Corrigé, pas complété : un document qui se déclare
vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller
voir.

Une déviation assumée : amont type le retour en any, interdit ici ; on rend
unknown, comme sparqlQuery le fait déjà pour le même any amont.
2026-08-14 10:00:40 +02:00

308 lines
14 KiB
TypeScript

import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, watch } from "../src/surface/inbox";
import * as polyfill from "../src/index";
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { Deposit } from "../src/surface/inbox";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
// This suite injects a fake `ng` via configure() and reuses the storeRegistry's
// injected session provider (inbox docs live in the shared wallet). Restore the
// un-configured state at the end so docs.test.ts's guard still sees null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
});
// NOTE ORDER: the "not configured → throw" case runs first — it exercises the
// registry-deps guard before any configureStoreRegistry() call.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
await expect(read("did:ng:o:inbox")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read
// SELECT over an in-memory quad store.
interface Quad { g: string; s: string; p: string; o: string }
const INBOX = "urn:ng-eventually:inbox";
/** 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[] = [];
// Reactive subscriptions: doc_subscribe registers a callback per anchor and
// fires an initial State push; a matching sparql_update pushes a Patch to that
// anchor's subscribers. This mirrors the real broker's local-push behaviour so
// inbox.watch (now event-driven, no polling) can be tested without a timer.
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } })); // initial push
return () => set!.delete(cb);
});
const pushTo = (anchor: string): void => {
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
};
// Distinct NURIs, one per creation — as a real broker does. It returned the CONSTANT
// `"did:ng:o:new"` until 2026-08-07, so every document the library made was the same
// one: two users' inboxes collided, and the ownership guard could not fire because
// there was nothing to tell apart. An adversarial review measured it. A fake that
// produces a state the real system never produces makes its suite green and blind.
let created = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:new${++created}`);
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
//
// The REAL broker keys triples by the ANCHORED repo's default graph, not by an
// explicit `GRAPH <…>` IRI (repo_graph_name(repo_id, overlay_id)). So this mock
// keys stored quads by the ANCHOR arg (a[2]) — the default graph of the anchored
// repo — and REJECTS any explicit `GRAPH <…>` wrapper, so the old wrong shape
// does NOT round-trip and can never regress silently.
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
if (!anchor) return undefined;
const g = anchor;
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
// predicate/object pairs: `a <type>` or `<p> "literal"`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish
// Un-escape the SPARQL literal so payload JSON round-trips. Single pass
// over `\x` sequences (reverses the lib's escapeLiteral without the
// double-processing that chained .replace() would cause).
const rawLit = m[2];
const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
// A write to `g` (the anchored default graph) pushes a Patch to that doc's
// subscribers — the local-push the real broker performs on a verified commit.
pushTo(g);
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
// never exercised, which is the very fault this pass was fixing elsewhere.
if (query.includes("urn:ng-eventually:shim:isInbox")) {
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:isInbox")
.map((q) => ({ i: { value: q.o } })) } };
}
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Without it
// `userInbox` never finds a persisted address and answers from the module cache, so
// a test comparing two actors compares one cached value with itself.
if (query.includes("urn:ng-eventually:shim:docInbox")) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return { results: { bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })) } };
}
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
// rdf:type marker — ensure the subject exists.
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
});
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/** Resolved per test: an inbox BELONGS to a wallet, and only its owner may read it. */
let TARGET: `did:ng:${string}`;
function inject() {
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 });
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(async () => {
fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await userInbox("alice", "protected");
});
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
// Count from HERE: resolving this wallet's own inbox already wrote to the shim.
const before = fake.sparql_update.mock.calls.length;
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
expect(fake.sparql_update.mock.calls.length).toBe(before + 1);
const call = fake.sparql_update.mock.calls[before]!;
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
expect(call[2]).toBe(TARGET); // anchored to the target inbox
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
// wrapper (which the real broker would route to a phantom graph).
expect(call[1] as string).not.toContain("GRAPH <");
});
test("post → read round-trips payload, from and ts", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
const deposits = await read(TARGET);
expect(deposits).toHaveLength(1);
expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
});
// (c) `from` is BOUND to the current identity — a spoof (naming another
// principal) is REJECTED; identifying as self or anonymous (null) is allowed.
test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => {
setCurrentUser("alice");
// SPOOF: alice tries to deposit AS bob → rejected.
await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow(
/spoof|current identity/i,
);
// Identifying as self → allowed.
await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 });
// Explicit anonymous → allowed.
await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 });
const froms = (await read(TARGET)).map((d) => d.from);
expect(froms).toEqual(["alice", null]);
});
// Bob DEPOSITS, alice READS. The asymmetry is the model — anyone deposits, only the
// owner reads — so a test that reads back under the depositor is testing a path no
// application has. It passed until 2026-08-07 only because the fake `doc_create` handed
// out one NURI for every document, so the ownership guard had nothing to tell apart.
test("from is optional — omitting it defaults to the depositor", async () => {
setCurrentUser("bob");
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
setCurrentUser("alice");
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBe("bob");
});
test("from: null makes an anonymous deposit even when a current user is set", async () => {
setCurrentUser("bob");
await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 });
setCurrentUser("alice");
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBeNull();
});
test("read returns deposits sorted by ts ascending", async () => {
await post(TARGET, { from: null, payload: "second", ts: 300 });
await post(TARGET, { from: null, payload: "first", ts: 100 });
await post(TARGET, { from: null, payload: "third", ts: 500 });
const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
});
test("the published inbox surface exposes `read` and no `materialize` alias", () => {
// `materialize` was a second published name for `read` — a symbol an application could
// learn and would have to unlearn, since upstream has no such member. This asserts the
// PUBLISHED entry (`src/index.ts`'s `inbox` namespace), which is what an app imports,
// not merely the module it re-exports.
expect(typeof polyfill.inbox.read).toBe("function");
expect("materialize" in polyfill.inbox).toBe(false);
});
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
// The OTHER inbox is obtained from the system, not invented. A made-up NURI would be
// a target no deposit can legitimately reach (`inbox.post` refuses what is not an
// inbox), so the test would have been proving something the model does not allow.
const otherInbox = await userInbox("bob", "protected");
expect(otherInbox).not.toBe(TARGET);
await post(TARGET, { from: null, payload: "mine", ts: 1 });
await post(otherInbox, { from: null, payload: "theirs", ts: 2 });
const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["mine"]);
});
test("payload with quotes/newlines/backslashes survives the round-trip", async () => {
const payload = { text: 'a "quoted"\nline\\path\ttab' };
await post(TARGET, { from: null, payload, ts: 1 });
const deposits = await read(TARGET);
expect(deposits[0]!.payload).toEqual(payload);
});
test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => {
const seen: Deposit[][] = [];
const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 });
// Give the immediate tick a chance to run (empty inbox → still fires once).
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await post(TARGET, { from: null, payload: "x", ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((d) => d.payload)).toEqual(["x"]);
stop();
const countAfterStop = seen.length;
await post(TARGET, { from: null, payload: "y", ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe
});