737729c9ce
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
196 lines
8.8 KiB
TypeScript
196 lines
8.8 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 {
|
|
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,
|
|
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);
|
|
});
|
|
|