Files
ng-eventually/packages/polyfill/test/app-surface.test.ts
T

228 lines
11 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,
listMyEntityDocs as registryListMyEntityDocs,
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()).rejects.toThrow(/ensureIdentity/i);
await expect(storeRegistry.protectedStore()).rejects.toThrow(/no identity/i);
});
test("placement acts as the connected user, with nothing passed", async () => {
inject();
setCurrentUser("alice");
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
expect(await registryListMyEntityDocs("alice", "protected")).toContain(doc);
setCurrentUser("bob");
expect(await registryListMyEntityDocs("bob", "protected")).not.toContain(doc);
});
// A store reference that is not one of the connected identity's three is a caller error,
// and silence here would be the worst answer available: the document would be created,
// successfully, somewhere its author did not ask for — and only a later listing that comes
// back short would say so.
test("createEntityDoc refuses a store that is not one of this identity's", async () => {
inject();
setCurrentUser("alice");
const hers = await storeRegistry.protectedStore();
setCurrentUser("bob");
await expect(storeRegistry.createEntityDoc(hers)).rejects.toThrow(/not one of this identity/i);
});
// ── 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(await storeRegistry.protectedStore());
// "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(await storeRegistry.protectedStore());
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(await storeRegistry.protectedStore())).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(await storeRegistry.protectedStore()).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(await storeRegistry.protectedStore())).rejects.toThrow(/key is not recorded/i);
});
// The third write a PUBLIC creation makes, and the one that was still silent: exposing the
// cap on the document is what lets anyone else read it (`public-store.exposeReadCap`, the
// emulated `expose_outer`), it happens once at creation and nothing ever redoes it. A
// swallowed failure therefore left a document sitting in a public store serving nothing —
// unreadable by everyone but its creator, permanently, with a reference handed back as
// though it had worked.
test("a public document whose cap could not be exposed is reported, not returned", async () => {
inject(/shim:exposedReadCap/);
setCurrentUser("alice");
await expect(storeRegistry.createEntityDoc(await storeRegistry.publicStore())).rejects.toThrow(/broker refused/i);
});