d4b392a119
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.
417 lines
17 KiB
TypeScript
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" });
|
|
});
|