Files
ng-eventually/packages/polyfill/test/use-shape.test.ts
T
2026-09-24 18:42:32 +02:00

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/,
);
});