Files
ng-eventually/packages/polyfill/test/app-surface.test.ts
T
Sylvain Duchesne cc8a95d303 feat: le polyfill possède la session et la normalisation des identités
Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.

En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.

Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.

getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.

Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.

Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
2026-08-12 17:39:12 +02:00

200 lines
9.1 KiB
TypeScript

/**
* The app-facing surface, pinned where it changed shape on 2026-08-10.
*
* Every test here exists because behaviour shipped without one and an adversarial pass
* had to find it: an application that cannot learn its own identity, a share that invents
* its recipient, a creation that reports success on a half-written document. They are
* about what a CALLER sees, not about the emulation's internals.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure, ensureIdentity, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { createEntityDoc as registryCreateEntityDoc, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { share } from "../src/surface/inbox";
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-app", privateStoreId: "PRIV-APP" };
interface Quad { g: string; s: string; p: string; o: string }
/** A stateful fake `ng`, with a switch that makes a chosen register write fail. */
function inject(failWriteMatching?: RegExp) {
const quads: Quad[] = [];
let created = 0;
const doc_create = mock(async () => `did:ng:o:doc${++created}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (failWriteMatching && failWriteMatching.test(query)) throw new Error("broker refused");
if (!anchor) return undefined;
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const subj = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
quads.push({ g: anchor, s: subj, p: m[1] ?? `${SHIM}:Account`, o: m[2] ?? m[3] ?? "" });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
const byPred = (pred: string, v: string) => ({
results: { bindings: quads.filter((q) => q.g === anchor && q.p === pred).map((q) => ({ [v]: { value: q.o } })) },
});
if (query.includes(`${SHIM}:shimDoc`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
if (query.includes(`${SHIM}:readCap`)) return byPred(`${SHIM}:readCap`, "c");
if (query.includes(`${SHIM}:contains`)) return byPred(`${SHIM}:contains`, "e");
if (query.includes(`${SHIM}:id`)) {
// Filter by SUBJECT when the query names one — the account read asks about ONE
// account. A fake that ignores it hands back somebody else's record, and then
// "bob does not see alice's document" and "share refuses an unknown name" both
// fail for a reason that has nothing to do with the code. (Made that mistake here
// first; it is the same one this review found in the other fakes.)
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
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: [] } };
});
configure({
ng: { doc_create, sparql_update, sparql_query } as never,
useShape: (() => {}) as never,
});
// The session comes from `init()` upstream and from the package's capture here, so a
// suite with no browser and no broker substitutes one through the internal wiring path —
// the same one the e2e harness uses. AFTER `configure`, which wires the package's own.
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { quads };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
// ── identity ───────────────────────────────────────────────────────────────
// An application has to know which user it is — to display it, at least. Upstream it
// does: it passes `user_id` to `session_start`, having got it from the wallet it opened.
// Here the gate chooses, so the gate returns. Without this the example application read
// the gate's own private storage key, which is a boundary no consumer should see.
test("ensureIdentity returns the identity it settled", async () => {
inject();
setCurrentUser("alice");
expect(await ensureIdentity()).toBe("alice");
});
// The other half of the same decision: no placement call TAKES an identity, because a
// session belongs to one user and the target's `doc_create` carries none. Calling one
// before signing in is a caller error worth naming, not an empty result.
test("a placement call before signing in names the mistake", async () => {
inject();
setCurrentUser(null);
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.listMyEntityDocs("protected")).rejects.toThrow(/no identity/i);
});
test("placement acts as the connected user, with nothing passed", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
expect(await storeRegistry.listMyEntityDocs("protected")).toContain(doc);
setCurrentUser("bob");
expect(await storeRegistry.listMyEntityDocs("protected")).not.toContain(doc);
});
// ── sharing names someone who exists ───────────────────────────────────────
test("share refuses a recipient nobody has signed in as, instead of creating them", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
// "bpb" is a typo for "bob". It used to mint that name's three stores and an inbox,
// and the cap landed where nobody will ever look — with no error at all.
await expect(share(doc, "bpb")).rejects.toThrow(/no such recipient/i);
});
// …and the refusal must rest on ABSENCE, never on ignorance: `resolveAccount` answers
// `null` for a failed read as well as for a missing one, so a refusal built on it would
// tell a user "nobody has signed in as bob" because a query timed out.
test("a failed lookup surfaces as a failure, not as 'no such recipient'", async () => {
const { quads } = inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc("protected");
setCurrentUser("bob");
await registryCreateEntityDoc("bob", "private"); // bob genuinely exists
setCurrentUser("alice");
resetRegistryCache(); // force a read rather than the cache
const { ng } = (await import("../src/shared-wallet/bootstrap")).getConfig();
const realQuery = ng.sparql_query;
(ng as { sparql_query: unknown }).sparql_query = async () => {
throw new Error("broker unreachable");
};
await expect(share(doc, "bob")).rejects.toThrow(/broker unreachable/i);
(ng as { sparql_query: unknown }).sparql_query = realQuery;
void quads;
});
// ── a creation that half-worked is a failure, and says which half ──────────
test("createEntityDoc reports a half-written document instead of returning its reference", async () => {
inject(/shim:contains/); // the LISTING write fails
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/not listed in its store/i);
});
// Both writes are attempted before the failure is raised — the cap must land even when
// the listing did not, because either is worth having without the other. Throwing on the
// first one (2026-08-07) skipped the second and orphaned the document entirely.
test("a failed listing does not cost the document its key", async () => {
const { quads } = inject(/shim:contains/);
setCurrentUser("alice");
await storeRegistry.createEntityDoc("protected").catch(() => {});
expect(quads.some((q) => q.p === `${SHIM}:readCap`)).toBe(true);
});
test("a failed key write is reported too, and names that half", async () => {
inject(/shim:readCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
});