/** * 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; 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: (): Promise> => Promise.resolve(d.ordered ? ([...d.stock] as T[]) : (new Set(d.stock) as Set)), insertObject: (shapeType: ShapeType, object: T): Promise => { 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+/); const only = subjM ? subjM[1]! : null; const bySubject = new Map>(); 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 { 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)].map((o) => o["@graph"])).toEqual([hers, hers]); setCurrentUser("bob"); expect([...((await getObjects(NoteShape, { graphs: "did:ng:i" })) as Set)]).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)]).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 = ((...a: unknown[]): Promise> => { seen = a; return Promise.resolve(new Set()); }) 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, // `|`, 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; 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" }); });