Files
ng-eventually/packages/polyfill/test/orm.test.ts
Sylvain Duchesne d4b392a119 chore: les dix injections de useShape passent par le faux partage
Dix sites, pas les deux que le suivi annoncait : `(() => {}) as unknown as
UseShapeLike` survivait la ou le type etait encore `any` et ou le cast ne
disait donc rien. Ils prennent le faux partage, sans cast.
2026-09-24 19:00:25 +02:00

417 lines
17 KiB
TypeScript

/**
* The ORM family this package publishes - `getObjects`, `insertObject` - and the door
* the rest of the ORM writes through, `ng.graph_orm_update`.
*
* -- What these prove, and why each case is here --------------------------
* One shared wallet hosts every identity, so an UNWRAPPED ORM call is not merely
* unfiltered, it is every identity's data and every identity's documents. The three
* questions:
*
* (a) a READ comes back filtered by what the holder holds - a second identity's
* objects are not in it, and the collection keeps its shape (a `Set` in is a
* `Set` out, an ordered array stays an array);
* (b) a WRITE is refused BEFORE it is attempted when the connected identity does not
* own the document it names - and it is refused on the ORM's own door too, not
* only at the call this package publishes, because a mutation of a live signal
* object never passes through anything else;
* (c) `initNg` hands the ORM this package's wrapped `ng`, which is what puts (b) on a
* door the ORM actually knocks on.
*
* Every document here is OBTAINED through the published placement call under the
* identity that owns it - never named by the test - so what one identity may do to
* another's document is decided by the system rather than by a variable in this file.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { makeUseShapeHook } from "./use-shape-fake";
import { configure, getObjects, insertObject, initNg, storeRegistry } from "../src/index";
import { makeNg } from "../src/surface/ng-proxy";
import {
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { GetObjectsLike, InsertObjectLike, NgLike, Nuri, UseShapeLike } from "../src/model/types";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
// TYPE-only: no `@ng-org/orm` module is loaded at runtime here. What these two lines
// check is the claim the published signatures MAKE - that the SDK's own functions are
// assignable to the injection types as they stand, with no wrapper and no cast, so
// `configure({ getObjects, insertObject })` is the whole of what a caller writes. A
// signature that drifted from upstream's would fail to compile right here.
import type { getObjects as SdkGetObjects, insertObject as SdkInsertObject } from "@ng-org/orm";
type _TheSdkReadFits = typeof SdkGetObjects extends GetObjectsLike ? true : never;
type _TheSdkWriteFits = typeof SdkInsertObject extends InsertObjectLike ? true : never;
const _sdkFits: [_TheSdkReadFits, _TheSdkWriteFits] = [true, true];
void _sdkFits;
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-orm", privateStoreId: "PRIV-ORM" };
/**
* A shape object, hand-written rather than generated: `rdf-orm build` turns a `.shex`
* file into exactly this, and the generator is not what is under test. Nothing here
* reads it - the injected fakes stand in for what would match it - so one predicate is
* enough to make it a well-formed `ShapeType`.
*/
const NoteShape = {
shape: "urn:orm-test:NoteShape",
schema: {
"urn:orm-test:NoteShape": {
iri: "urn:orm-test:NoteShape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "@type",
dataTypes: [{ valType: "iri", literals: ["urn:orm-test:Note"] }],
minCardinality: 1,
maxCardinality: 1,
},
],
},
},
} satisfies ShapeType<BaseType>;
interface Quad { g: string; s: string; p: string; o: string }
/** What the injected SDK calls saw, and what the read one will answer with. */
interface OrmDouble {
/** The objects the SDK's `getObjects` hands back, before this package filters them. */
stock: BaseType[];
/** Hand back an ordered ARRAY instead of a `Set` - what an `orderBy` config yields. */
ordered: boolean;
/** `[shapeType, object]` per call reaching the SDK's `insertObject`. */
inserted: unknown[][];
getObjects: GetObjectsLike;
insertObject: InsertObjectLike;
}
function makeOrmDouble(): OrmDouble {
const d: OrmDouble = {
stock: [],
ordered: false,
inserted: [],
// The one cast in this file, and it is the SDK's own shape of the problem: upstream
// `getObjects` returns `structuredClone(signalObject[RAW_KEY])`, untyped, and it is
// `shapeType` that says the items are `T`. A fake stands where that cast stands.
getObjects: <T extends BaseType>(): Promise<T[] | Set<T>> =>
Promise.resolve(d.ordered ? ([...d.stock] as T[]) : (new Set(d.stock) as Set<T>)),
insertObject: <T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T> => {
d.inserted.push([shapeType, object]);
return Promise.resolve(object);
},
};
return d;
}
/**
* A stateful fake `ng` - the shim account records and the per-store index, which is
* what makes a document created here really belong to the identity that created it
* (`assertMayWrite` reads that index back). Copied in spirit from `app-surface.test.ts`;
* nothing of the ORM goes through it except `graph_orm_update`, tested below.
*/
function makeFakeNg() {
const quads: Quad[] = [];
let created = 0;
const doc_create = mock(async () => `did:ng:o:orm${++created}`);
const graph_orm_update = mock(async (..._a: unknown[]) => undefined);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
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`)) {
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: [] } };
});
return { doc_create, sparql_update, sparql_query, graph_orm_update };
}
function inject(orm?: OrmDouble) {
const ng = makeFakeNg();
configure({
ng: ng as unknown as NgLike,
useShape: makeUseShapeHook().hook,
...(orm ? { getObjects: orm.getObjects, insertObject: orm.insertObject } : {}),
});
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** A document that really belongs to `who` - created by them, through the published call. */
async function documentOf(who: string): Promise<Nuri> {
setCurrentUser(who);
return storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
}
/** One ORM object placed in `doc`, as the ORM itself carries it. */
function noteIn(doc: Nuri, id: string): BaseType {
return { "@id": `${doc}:q:${id}`, "@graph": doc } as BaseType;
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
// -- the read: what comes back is what the holder holds ---------------------
// THE rights case. The SDK answers with every identity's objects, because one wallet
// holds every identity's documents; what the caller gets is only her own. Both
// documents are obtained under their own owner, so "hers" and "his" are facts of the
// system rather than labels this test applied.
test("getObjects: a second identity's objects do not come back", async () => {
const orm = makeOrmDouble();
inject(orm);
const hers = await documentOf("alice");
const his = await documentOf("bob");
orm.stock = [noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(hers, "a2")];
setCurrentUser("alice");
const read = await getObjects(NoteShape, { graphs: "did:ng:i" });
expect(read).toBeInstanceOf(Set);
expect([...(read as Set<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
setCurrentUser("bob");
expect([...((await getObjects(NoteShape, { graphs: "did:ng:i" })) as Set<BaseType>)]).toHaveLength(1);
});
// The collection's SHAPE is upstream's answer to the config, not this package's to
// choose: an `orderBy` makes the signal object an array, and an array filtered into a
// Set would silently lose the ordering the caller asked for.
test("getObjects: an ordered result stays an array, an unordered one stays a Set", async () => {
const orm = makeOrmDouble();
inject(orm);
const hers = await documentOf("alice");
orm.stock = [noteIn(hers, "a1")];
setCurrentUser("alice");
orm.ordered = true;
expect(Array.isArray(await getObjects(NoteShape, { graphs: "did:ng:i" }))).toBe(true);
orm.ordered = false;
expect(await getObjects(NoteShape, { graphs: "did:ng:i" })).toBeInstanceOf(Set);
});
// Before the first cap exists the emulation is not in force, exactly as `useShape` and
// `useShapeQuery` behave: a consumer that never touches caps keeps reading everything.
test("getObjects: passthrough while no cap has been issued", async () => {
const orm = makeOrmDouble();
inject(orm);
orm.stock = [noteIn("did:ng:o:someone" as Nuri, "x")];
const read = await getObjects(NoteShape, { graphs: "did:ng:i" });
expect([...(read as Set<BaseType>)]).toHaveLength(1);
});
// Identical signature means passed through: whatever config the caller wrote is the
// config the SDK receives, with nothing interpreted on the way.
test("getObjects: the shape and the config reach the SDK untouched", async () => {
const orm = makeOrmDouble();
let seen: unknown[] = [];
const config = { graphs: ["did:ng:o:one"], subjects: ["urn:s"] };
orm.getObjects = (<T extends BaseType>(...a: unknown[]): Promise<T[] | Set<T>> => {
seen = a;
return Promise.resolve(new Set<T>());
}) as GetObjectsLike;
inject(orm);
await getObjects(NoteShape, config);
expect(seen[0]).toBe(NoteShape);
expect(seen[1]).toBe(config);
});
// -- the write: refused before it is attempted ------------------------------
test("insertObject: an object in a document this identity does not own is REFUSED", async () => {
const orm = makeOrmDouble();
inject(orm);
await documentOf("alice");
const his = await documentOf("bob");
setCurrentUser("alice");
await expect(insertObject(NoteShape, noteIn(his, "a1"))).rejects.toThrow(/refused/i);
// Not attempted, rather than attempted and caught - the difference between a caller
// that knows what it holds and one that hoped.
expect(orm.inserted).toHaveLength(0);
});
test("insertObject: the owner's own document goes through, object unchanged", async () => {
const orm = makeOrmDouble();
inject(orm);
const hers = await documentOf("alice");
setCurrentUser("alice");
const note = noteIn(hers, "a1");
expect(await insertObject(NoteShape, note)).toBe(note);
expect(orm.inserted).toHaveLength(1);
expect(orm.inserted[0]![1]).toBe(note);
});
// An object naming no document is upstream's error to report, not ours to pre-empt:
// the ORM throws "you must specify the @graph", and a refusal invented here would
// answer a different question in a message nobody could act on.
test("insertObject: an object with no @graph is forwarded, and upstream refuses it", async () => {
const orm = makeOrmDouble();
orm.insertObject = (() => Promise.reject(new Error("you must specify the @graph"))) as InsertObjectLike;
inject(orm);
await documentOf("alice");
setCurrentUser("alice");
await expect(insertObject(NoteShape, { "@id": "" } as BaseType)).rejects.toThrow(/@graph/);
});
// -- neither call invents an answer when it was never injected --------------
test("both name the field to inject when configure() was not given them", async () => {
inject();
await expect(getObjects(NoteShape, { graphs: "did:ng:i" })).rejects.toThrow(/getObjects.*not injected/s);
await expect(insertObject(NoteShape, { "@id": "" } as BaseType)).rejects.toThrow(
/insertObject.*not injected/s,
);
});
// -- the ORM's own write door -----------------------------------------------
// The hole this closes: a mutation of a live signal object - `set.add(...)`,
// `set.delete(...)` - never passes through `sparql_update`. It is batched into patches
// and sent through `graph_orm_update`, so the guard on the other member saw no ORM
// write at all. The path carries the document in its first segment,
// `<graph>|<subject>`, which is the synthetic id the ORM itself minted.
test("ng.graph_orm_update: a patch into somebody else's document is REFUSED", async () => {
const ng = inject();
await documentOf("alice");
const his = await documentOf("bob");
setCurrentUser("alice");
const proxy = makeNg();
await expect(
proxy.graph_orm_update!("sub-1", [{ op: "remove", path: `/${his}|${his}:q:b1` }], "sid"),
).rejects.toThrow(/refused/i);
expect(ng.graph_orm_update).toHaveBeenCalledTimes(0);
});
test("ng.graph_orm_update: the owner's own patches go through", async () => {
const ng = inject();
const hers = await documentOf("alice");
setCurrentUser("alice");
const proxy = makeNg();
await proxy.graph_orm_update!(
"sub-1",
[
{ op: "add", path: `/${hers}|${hers}:q:a1` },
{ op: "add", path: `/${hers}|${hers}:q:a1/title`, value: "x" },
],
"sid",
);
expect(ng.graph_orm_update).toHaveBeenCalledTimes(1);
});
// ONE unauthorized document in the batch is enough: the patches of one flush are sent
// together, so letting the call through because the first one was fine would write
// every other one too.
test("ng.graph_orm_update: one refused document refuses the whole batch", async () => {
const ng = inject();
const hers = await documentOf("alice");
const his = await documentOf("bob");
setCurrentUser("alice");
const proxy = makeNg();
await expect(
proxy.graph_orm_update!(
"sub-1",
[{ op: "add", path: `/${hers}|${hers}:q:a1` }, { op: "add", path: `/${his}|${his}:q:b1` }],
"sid",
),
).rejects.toThrow(/refused/i);
expect(ng.graph_orm_update).toHaveBeenCalledTimes(0);
});
// A patch naming no NextGraph reference is forwarded rather than refused. The emulation
// cannot say whose document such a write lands in, and upstream does not have to guess:
// `verify_permission` fires on the commit itself. Inventing a refusal the target does
// not have would be the divergence.
test("ng.graph_orm_update: a patch naming no document passes through", async () => {
const ng = inject();
await documentOf("alice");
setCurrentUser("alice");
const proxy = makeNg();
await proxy.graph_orm_update!("sub-1", [{ op: "add", path: "/0/title" }], "sid");
expect(ng.graph_orm_update).toHaveBeenCalledTimes(1);
});
// -- and the door is one the ORM actually knocks on -------------------------
// The guard above only ever fires if the ORM is holding THIS package's `ng`. An
// application hands `initNg` the raw SDK object - both consumers in this repository
// did - so the substitution is what turns the guard from a claim into a mechanism.
test("initNg hands the ORM this package's wrapped ng, not the argument", async () => {
const seen: unknown[] = [];
const ng = makeFakeNg();
const raw = { marker: "the raw SDK object" };
configure({
ng: ng as unknown as NgLike,
useShape: makeUseShapeHook().hook,
initNg: (...a: unknown[]) => void seen.push(...a),
});
configureStoreRegistry({ getSession: async () => SESSION });
initNg(raw, { session_id: "sid" });
expect(seen[0]).not.toBe(raw);
// ...and what it IS forwards to the object `configure()` received, so nothing is taken
// from the caller: its members answer exactly as the real `ng`'s do.
const handed = seen[0] as Record<string, unknown>;
expect(typeof handed["graph_orm_update"]).toBe("function");
expect(handed["doc_create"]).toBeDefined();
// The session, and everything after it, passes straight through.
expect(seen[1]).toEqual({ session_id: "sid" });
});