298 lines
14 KiB
TypeScript
298 lines
14 KiB
TypeScript
/**
|
|
* The published `useShape` - what an identity may see of what the SDK handed back.
|
|
*
|
|
* -- Why this file exists, and what it is the answer to --------------------
|
|
* One shared wallet hosts every identity, so the hook's own answer is every identity's
|
|
* objects and the capability filter is the whole of the isolation. Until 2026-09-24 that
|
|
* filter was written for the collection `@ng-org/orm@0.1.2-alpha.18` returned, while the
|
|
* version this package builds against - `alpha.21` - returns a `UseShapeResult` ENVELOPE
|
|
* around it. Three plain members of that envelope (`data`, `promise`, `subscription`) each
|
|
* carried the items and each came back RAW, and no test in this repository could see it,
|
|
* because no fake returned the shape the SDK ships. `test/use-shape-fake.ts` now does, read
|
|
* off the installed package's own `.d.ts`, and this file is what it made possible.
|
|
*
|
|
* Every document below is OBTAINED through the published placement call under the identity
|
|
* that owns it - never named by the test - so "hers" and "his" are facts of the system.
|
|
* Nothing hands a `ReadCap` across the boundary: the stranger holds what a stranger holds.
|
|
*/
|
|
import { test, expect, afterEach } from "bun:test";
|
|
import { configure, storeRegistry, useShape } from "../src/index";
|
|
import {
|
|
configureStoreRegistry,
|
|
resetCaps,
|
|
resetConfig,
|
|
resetStoreRegistry,
|
|
setCurrentUser,
|
|
} from "../src/shared-wallet/bootstrap";
|
|
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
|
|
import { makeWallet, SESSION, type Quad } from "./wallet-fake";
|
|
import { makeUseShapeHook } from "./use-shape-fake";
|
|
import type { FakeShapeHook } from "./use-shape-fake";
|
|
import type { NgLike, Nuri, ShapeResult, 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 this line checks is
|
|
// the claim the injection type MAKES - that the SDK's OWN React hook is assignable to
|
|
// `UseShapeLike` as it stands, with no wrapper and no cast, so `configure({ useShape })`
|
|
// is the whole of what a caller writes. It is also what keeps that type honest: it was
|
|
// `(...args: any[]) => any`, which no shape could ever contradict.
|
|
import type { reactUseShape } from "@ng-org/orm/all";
|
|
type _TheSdkHookFits = typeof reactUseShape extends UseShapeLike ? true : never;
|
|
const _sdkFits: _TheSdkHookFits = true;
|
|
void _sdkFits;
|
|
|
|
const NoteShape = {
|
|
shape: "urn:use-shape-test:NoteShape",
|
|
schema: {
|
|
"urn:use-shape-test:NoteShape": {
|
|
iri: "urn:use-shape-test:NoteShape",
|
|
predicates: [
|
|
{
|
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
|
readablePredicate: "@type",
|
|
dataTypes: [{ valType: "iri", literals: ["urn:use-shape-test:Note"] }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies ShapeType<BaseType>;
|
|
|
|
/** Everything the local dataset holds - the scope under which this filter is the only
|
|
* thing between the caller and every identity's objects. */
|
|
const EVERYTHING = { graphs: "did:ng:i" };
|
|
|
|
function inject(hook: FakeShapeHook): void {
|
|
const quads: Quad[] = [];
|
|
const ng: NgLike = makeWallet(quads);
|
|
configure({ ng, useShape: hook.hook });
|
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
|
resetRegistryCache();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
}
|
|
|
|
/** A document that really belongs to `who` - created by them, through the published call,
|
|
* which is also what files its cap in their keyring. */
|
|
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 RIGHTS CASE: the stranger ------------------------------------------
|
|
|
|
// The defect, stated as a test. Each of the three members below is one the `alpha.21`
|
|
// declaration gives the items to, and each used to hand back the unfiltered collection.
|
|
test("useShape: no member of the result reaches a document this identity holds no cap for", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
// `data` - what the SDK's own example destructures first.
|
|
expect([...(result.data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
expect((result.data as { size: number }).size).toBe(1);
|
|
// `promise` - the same collection, one await away.
|
|
expect([...((await result.promise) as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
// `subscription` - the SDK's own handle, which reads and writes with no filter at all.
|
|
expect(() => result.subscription).toThrow(/RdfOrmSubscription/);
|
|
|
|
// and the collection underneath really does hold both, so it is the filter that
|
|
// removed one rather than the fake never having offered it.
|
|
expect([...(hook.raw() as Iterable<BaseType>)]).toHaveLength(2);
|
|
});
|
|
|
|
// Iterating is not the only way in, and the members a reactive set puts forward are the
|
|
// ones a consumer reaches for first. Same rule as the collection view's own regression.
|
|
test("useShape: the item-yielding members of `data` are filtered too, not just iteration", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
const foreign = noteIn(hers, "a1");
|
|
hook.items.push(foreign, noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const data = useShape(NoteShape, EVERYTHING).data as {
|
|
values(): Iterable<BaseType>;
|
|
forEach(cb: (v: BaseType) => void): void;
|
|
has(v: BaseType): boolean;
|
|
};
|
|
expect([...data.values()].map((o) => o["@graph"])).toEqual([his]);
|
|
const seen: string[] = [];
|
|
data.forEach((o) => seen.push(o["@graph"]));
|
|
expect(seen).toEqual([his]);
|
|
expect(data.has(foreign)).toBe(false);
|
|
});
|
|
|
|
// -- THE OTHER DIRECTION: the holder is served ------------------------------
|
|
|
|
// A repair that closes a leak by making the call unusable has traded one defect for
|
|
// another. Every member the declaration offers a holder is exercised here on their OWN
|
|
// data, and the two page commands with it.
|
|
test("useShape: the holder reads their own items through every member the SDK offers", async () => {
|
|
const hook = makeUseShapeHook({ paginated: true });
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(hers, "a2"));
|
|
|
|
setCurrentUser("alice");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
expect([...(result.data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
|
|
expect((result.data as { size: number }).size).toBe(2);
|
|
expect([...((await result.promise) as Iterable<BaseType>)]).toHaveLength(2);
|
|
// The load state is a boolean and passes on its own, with no entry naming it.
|
|
expect(result.isLoading).toBe(false);
|
|
// The page commands forward: they return `void`, and what they load lands in a
|
|
// collection only ever read back through the filter.
|
|
result.nextPage?.();
|
|
result.previousPage?.();
|
|
expect(hook.pages).toEqual(["next", "previous"]);
|
|
// Reading `data` twice hands back the same view, so a caller may put it in a
|
|
// dependency array without it looking like new data on every read.
|
|
expect(result.data).toBe(result.data);
|
|
});
|
|
|
|
// Before the first cap exists the emulation is not in force, exactly as `getObjects` and
|
|
// `useShapeQuery` behave: a consumer that never touches caps keeps reading everything,
|
|
// and gets the SDK's own object back untouched.
|
|
test("useShape: passthrough while no cap has been issued", () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
hook.items.push(noteIn("did:ng:o:someone" as Nuri, "x"));
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
expect([...(result.data as Iterable<BaseType>)]).toHaveLength(1);
|
|
expect(result.subscription).not.toBeUndefined();
|
|
});
|
|
|
|
// Upstream: "If the conf is explicitly set to `undefined`, the returned data is
|
|
// `undefined` and `isLoading` is false". Nothing was opened, so there is nothing to
|
|
// refuse, and `subscription` answers the SDK's own value rather than throwing.
|
|
test("useShape: an undefined config answers undefined rather than refusing", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
await documentOf("alice");
|
|
setCurrentUser("alice");
|
|
const result: ShapeResult = useShape(NoteShape, undefined);
|
|
expect(result.data).toBeUndefined();
|
|
expect(result.promise).toBeUndefined();
|
|
expect(result.subscription).toBeUndefined();
|
|
expect(result.isLoading).toBe(false);
|
|
});
|
|
|
|
// -- SAFE FOR A SHAPE NOBODY HAS SEEN --------------------------------------
|
|
|
|
/** What a later `@ng-org/orm` might hand back: the six members of `alpha.21` plus two
|
|
* nobody has declared - one holding the collection, one holding a count. */
|
|
interface LaterResult extends ShapeResult {
|
|
readonly rows: unknown;
|
|
readonly totalCount: number;
|
|
readonly sample: () => unknown;
|
|
}
|
|
|
|
// The whole point of deciding by TYPE rather than by a list of names. A name list is what
|
|
// breaks silently on the next bump - which is exactly how this defect arrived - so the
|
|
// question asked of an unnamed member is what no version number can change: can the value
|
|
// behind it reach an item at all?
|
|
test("useShape: a member no version has declared yet cannot leak, while a scalar one still passes", async () => {
|
|
const base = makeUseShapeHook();
|
|
const later: FakeShapeHook = {
|
|
...base,
|
|
hook: ((shapeType, conf) => {
|
|
const result: LaterResult = {
|
|
...base.hook(shapeType, conf),
|
|
rows: base.raw(),
|
|
totalCount: 7,
|
|
sample: () => [...(base.raw() as Iterable<BaseType>)][0],
|
|
};
|
|
return result;
|
|
}) as UseShapeLike,
|
|
};
|
|
inject(later);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
base.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const result = useShape(NoteShape, EVERYTHING) as LaterResult;
|
|
|
|
// An OBJECT member nobody named holds the collection, so it is refused at the read -
|
|
// there is no later moment to refuse it at.
|
|
expect(() => result.rows).toThrow(/not filtered/i);
|
|
// A FUNCTION member nobody named is refused at the CALL, so reading it (a logger, a
|
|
// `typeof` guard) is not itself refused.
|
|
expect(typeof result.sample).toBe("function");
|
|
expect(() => result.sample()).toThrow(/not filtered/i);
|
|
// A PRIMITIVE cannot hold a reference to anything, so it passes with no entry of its
|
|
// own - which is why `isLoading` needs none either.
|
|
expect(result.totalCount).toBe(7);
|
|
});
|
|
|
|
// An `orderBy` config makes the collection an ARRAY (upstream `SubscriptionData`), whose
|
|
// count is spelled `length` and is a number - so it used to pass as a harmless primitive
|
|
// while publishing how many items the holder may NOT read.
|
|
test("useShape: an ordered result does not publish the unfiltered count", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(his, "b2"));
|
|
|
|
setCurrentUser("alice");
|
|
const data = useShape(NoteShape, { ...EVERYTHING, orderBy: { liveMark: "asc" } }).data;
|
|
expect(Array.isArray(hook.raw())).toBe(true);
|
|
expect((hook.raw() as BaseType[]).length).toBe(3);
|
|
expect((data as { length: number }).length).toBe(1);
|
|
expect([...(data as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([hers]);
|
|
// An index names a position in the READABLE items, so the holder reads their own by
|
|
// index and the positions past them do not exist at all. `Object.keys` and `in` have to
|
|
// agree with `length`, or the count comes back out through the other three.
|
|
expect((data as Record<number, BaseType>)[0]!["@graph"]).toBe(hers);
|
|
expect((data as Record<number, BaseType>)[1]).toBeUndefined();
|
|
expect(Object.keys(data as object)).toEqual(["0"]);
|
|
expect(1 in (data as object)).toBe(false);
|
|
expect(JSON.parse(JSON.stringify(data)) as BaseType[]).toHaveLength(1);
|
|
});
|
|
|
|
// REGRESSION, found by re-running the adversary on the first fix (2026-09-24).
|
|
// `Object.getOwnPropertyDescriptor` reads a property WITHOUT the `get` trap, so a view
|
|
// that filtered `get` alone handed the raw value straight back to whoever asked for the
|
|
// descriptor - including `subscription`, the one member `get` refuses outright. Everything
|
|
// built on it goes the same way: a spread, `Object.entries`, a console inspection.
|
|
test("useShape: the descriptor of a member answers exactly what reading it answers", async () => {
|
|
const hook = makeUseShapeHook();
|
|
inject(hook);
|
|
const hers = await documentOf("alice");
|
|
const his = await documentOf("bob");
|
|
hook.items.push(noteIn(hers, "a1"), noteIn(his, "b1"));
|
|
|
|
setCurrentUser("bob");
|
|
const result: ShapeResult = useShape(NoteShape, EVERYTHING);
|
|
|
|
const described = Object.getOwnPropertyDescriptor(result, "data");
|
|
expect(described?.value).toBe(result.data);
|
|
expect(described?.value).not.toBe(hook.raw());
|
|
expect([...(described?.value as Iterable<BaseType>)].map((o) => o["@graph"])).toEqual([his]);
|
|
expect(() => Object.getOwnPropertyDescriptor(result, "subscription")).toThrow(
|
|
/RdfOrmSubscription/,
|
|
);
|
|
});
|