139d21843a
Recherche menee sur les 94 citations vers engine/ et sdk/, en comparant l'arbre cite aux artefacts publies. Onze citations de niveau 2 (doc_subscribe, sparql_update, sparql_query, doc_create) sont identiques de part et d'autre, ainsi que ShapeType, Schema et DeepSignalSet. Trois ne l'etaient pas. - Scope : la revision citee declare `graphs?` OPTIONNEL, @ng-org/orm@0.1.2-alpha.21 le rend requis - et c'est le paquet installe qui est reexporte ici. - BaseType : la revision citee garde "@graph" en commentaire derriere un TODO, @ng-org/shex-orm@0.1.2-alpha.9 le declare. - NG : le compte de membres disait 88 en neuf endroits, dont un marque VERIFIED et un autre annoncant une mesure contre alpha.14. Le paquet installe en declare 90 - 84 fonctions et 6 classes RDF - et deux des quatre-vingt-dix, graph_orm_next_page et graph_orm_previous_page, sont absents de l'arbre cite. Le compte vit desormais a un seul endroit, avec la version a laquelle il appartient ; ailleurs il disparait, parce que la phrase disait "tous les membres" et que le chiffre n'y ajoutait qu'une occasion de perimer. Les deux premieres reprennent la forme que RdfOrmConfig et OrmSubscriptionHandle portaient deja : une clause finale nommant la version publiee la ou elle diverge de la revision citee.
416 lines
17 KiB
TypeScript
416 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 { 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: (() => {}) as unknown as UseShapeLike,
|
|
...(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: (() => {}) as unknown as UseShapeLike,
|
|
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" });
|
|
});
|