f875047099
Troisieme tour de revue adversariale, trois constats, tous reproduits avant correction. 1. La garde etait CIRCULAIRE, et c'etait structurel. `namedDocumentsReachable` demandait si la capacite est DETENUE. Pour un document en store public, le seul canal qui puisse jamais la deposer est `fetchReadCap` - l'appel meme que la garde retenait. Elle attendait une reponse que seule l'action qu'elle bloquait pouvait produire, et toute cette classe restait irrecuperable. La bonne question n'est pas une meilleure garde, c'est de RETENTER. Les deux facons d'etre refuse se reparent par des choses opposees, et le memo les distingue deja : un echec n'est deliberement pas memoise, donc une nouvelle tentative redemande vraiment au reseau - c'est la reparation de cette classe, pas un cout a eviter ; une absence est memoise, donc une nouvelle tentative ne demande a personne et coute un instantane, jusqu'au jour ou la capacite arrive d'ailleurs, seule chose qui repare celle-la. La garde est supprimee : le module est plus petit qu'au tour precedent. 2. La reouverture faisait un ACCES sous la mauvaise identite. Republier REFILTRE une requete existante ; rouvrir EMET un abonnement et une requete au store public pour un document que cet abonne a nomme. C'est un acte neuf, pas un ancien qui ressort, et l'etat final etait `isSuccess` avec `data: []` pour un document illisible - le mensonge que cette tache supprime, par la porte du correctif. `refusedFor` retient QUI a ete refuse ; on ne retente que tant que c'est encore lui le connecte. Etabli plutot qu'affirme : une application ne peut pas changer d'identite en cours de session. `ensureIdentity` est le seul appel publie qui en etablit une, et `resolveIdentity` commence par `getCurrentUser()`. Corrige quand meme. 3. `Promise.all` court-circuite a la premiere rejection ARRIVEE, et un fetch qui rejette n'attend pas la barriere de synchronisation : il double un refus portant sur un document nomme avant lui. Le comportement est corrige plutot que le commentaire - `allSettled` puis parcours dans l'ordre du scope. Et le quatrieme controle e2e est retire, pas repare : il passait avec toute la machinerie de reprise supprimee. Un controle qui ne peut echouer pour aucune regression de ce qu'il garde se lit comme de la couverture et vaut moins que rien.
939 lines
38 KiB
TypeScript
939 lines
38 KiB
TypeScript
/**
|
|
* use-shape-query.test.ts - behavioural tests for `useShapeQuery`
|
|
* (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by
|
|
* `orm-fake.ts` and a stateful fake `ng` for everything else the page does.
|
|
*
|
|
* -- The montage, and why it takes TWO fakes -------------------------------
|
|
* The read goes through the SDK now, so the thing that answers it is the injected
|
|
* shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way
|
|
* `schema_shape_to_sparql` does). Everything AROUND the read is still this package's:
|
|
* signing in, creating a document, filing its capability. That is the fake `ng` - and
|
|
* keeping it is what makes the documents here real ones, obtained from
|
|
* `createEntityDoc` rather than named by the test.
|
|
*
|
|
* These prove the distinctions the surface exists for:
|
|
* (a) isPending until the initial payload arrives, isSuccess once it has;
|
|
* (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction);
|
|
* (c) an engine patch updates data (reactivity, no polling);
|
|
* (d) a query that never delivers stays isPending - it never becomes "nothing";
|
|
* plus the three readings of the SDK `Scope`, which this module no longer interprets
|
|
* and therefore has to hand over VERBATIM, and the capability filter over what comes
|
|
* back.
|
|
*/
|
|
|
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
|
import { useShapeQuery } from "../src/surface/use-shape-query";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
|
|
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
|
|
import { mintCap } from "../src/emulated-verifier/caps";
|
|
import { makeOrm, type FakeOrm } from "./orm-fake";
|
|
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
|
import type { NgLike, UseShapeLike } from "../src/model/types";
|
|
|
|
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
|
const EVERYTHING = { graphs: "did:ng:i" };
|
|
const FP = "http://festipod.org/";
|
|
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
|
} else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
|
|
|
/**
|
|
* The stateful fake `ng` - the shim account, the per-scope index and the document
|
|
* creation the page does around the read. It is NOT what answers the read any more.
|
|
*/
|
|
function makeFake(options: { pushState?: boolean } = {}) {
|
|
/** Anchors whose anchored read throws. Mutable after boot, so a suite builds a healthy
|
|
* world first and breaks only the one call it is about. */
|
|
const unreachable = new Set<string>();
|
|
// A broker that never pushes the first `State` is the pathological case the bootstrap
|
|
// open's bounded fallback exists for - not the ordinary one, which CONTRACT 3 in
|
|
// `e2e/` measures at about a millisecond. It is what makes the barrier's cost visible
|
|
// without waiting for it.
|
|
const pushState = options.pushState !== false;
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
const subs: SubRec[] = [];
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
|
let g: string;
|
|
let body: string;
|
|
if (gm) {
|
|
g = gm[1]!;
|
|
body = gm[2]!;
|
|
} else {
|
|
if (!anchor) return undefined;
|
|
g = anchor;
|
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
}
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = 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) {
|
|
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
for (const sub of subs) {
|
|
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
// The broker cannot answer for this repo right now - an outage, a repo this session
|
|
// has not synced, a rejected read. It REJECTS, as `resolve_target_for_sparql` does
|
|
// for a repo the verifier does not hold, and never answers 0 rows.
|
|
if (unreachable.has(a[3] as string)) throw new Error("RepoNotFound (emulated outage)");
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
// Pointer SELECT (store-root -> doc-shim).
|
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
const onlySubject = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
const 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 } };
|
|
}
|
|
for (const marker of ["inboxCap", "readCap", "contains", "exposedReadCap"] as const) {
|
|
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
|
|
const key = marker === "contains" ? "e" : "c";
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`)
|
|
.map((q) => ({ [key]: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
}
|
|
}
|
|
const bindings = quads
|
|
.filter((q) => q.g === anchor)
|
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
|
return { results: { bindings } };
|
|
});
|
|
|
|
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
|
subs.push({ nuri, cb });
|
|
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
|
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
|
|
return () => {};
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, _unreachable: unreachable };
|
|
}
|
|
|
|
function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
|
|
configure({
|
|
ng: ng as unknown as NgLike,
|
|
useShape: (() => {}) as unknown as UseShapeLike,
|
|
ormSubscription: orm.query,
|
|
});
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
}
|
|
|
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
|
|
/**
|
|
* A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title.
|
|
*
|
|
* Both predicates are `minCardinality: 1`, because that is what
|
|
* `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of
|
|
* them would match every subject in the dataset. `rdf-orm build` generates exactly this
|
|
* object from a `.shex` file, and the generator is not what is under test here.
|
|
*/
|
|
const EventShape = {
|
|
shape: `${FP}EventShape`,
|
|
schema: {
|
|
[`${FP}EventShape`]: {
|
|
iri: `${FP}EventShape`,
|
|
predicates: [
|
|
{
|
|
iri: TYPE,
|
|
readablePredicate: "@type",
|
|
dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
{
|
|
iri: `${FP}title`,
|
|
readablePredicate: "title",
|
|
dataTypes: [{ valType: "string" }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies ShapeType<BaseType>;
|
|
|
|
/** The titles a snapshot carries, sorted - what a view would render. */
|
|
function titles(data: BaseType[]): string[] {
|
|
return data.map((d) => String(d.title)).sort();
|
|
}
|
|
|
|
/** Seed one shape-matching object into a document's graph. */
|
|
function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void {
|
|
orm.seed(doc, subject, TYPE, `${FP}Event`);
|
|
orm.seed(doc, subject, `${FP}title`, title);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetRegistryCache();
|
|
});
|
|
afterEach(() => {
|
|
setCurrentUser(null);
|
|
});
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
// The cap registry is process-wide: leaving caps behind would put the possession
|
|
// gate in force for a suite that never declares any.
|
|
resetCaps();
|
|
});
|
|
|
|
describe("useShapeQuery", () => {
|
|
it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm({ autoDeliver: false });
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
|
|
// Before the engine answers: pending, no data.
|
|
await tick();
|
|
expect(obs.getSnapshot().isPending).toBe(true);
|
|
expect(obs.getSnapshot().isSuccess).toBe(false);
|
|
expect(obs.getSnapshot().data).toEqual([]);
|
|
|
|
orm.deliver();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Alpha"]);
|
|
expect(notes).toBeGreaterThan(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("bob");
|
|
// bob has no document of this shape anywhere - the scope is genuinely empty.
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
|
|
unsub();
|
|
});
|
|
|
|
it("(c) an engine patch updates data (reactivity, no polling)", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("carol");
|
|
const doc = await createEntityDoc("carol", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "One");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
|
|
|
|
// A second object lands in a second document - the engine pushes it into the live
|
|
// subscription, and the snapshot follows. No document subscription of ours, no timer.
|
|
const doc2 = await createEntityDoc("carol", "protected");
|
|
seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => {
|
|
// The state the real ORM produces when the engine refuses a start: `orm_start_graph`
|
|
// is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness
|
|
// promise is built with `resolve` alone, so nothing ever settles it. The surface must
|
|
// keep saying "still asking" - the one thing it must never do is publish the
|
|
// empty-and-answered snapshot, which is what an application renders as "you have
|
|
// created nothing".
|
|
const ng = makeFake();
|
|
const orm = makeOrm({ autoDeliver: false });
|
|
inject(ng, orm);
|
|
setCurrentUser("dave");
|
|
const doc = await createEntityDoc("dave", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(e) opening the shape query threw -> isError, and never an empty answer", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("erin");
|
|
orm.refuse("the shape could not be subscribed");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/);
|
|
unsub();
|
|
});
|
|
|
|
it("(f) `configure` without a shape query says so, rather than answering empty", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
// ...and then a bootstrap that forgot the injection. The read has nothing to go
|
|
// through, and an empty set would be the one lie this surface exists to prevent.
|
|
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
|
configureStoreRegistry({
|
|
getSession: async () => SESSION,
|
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
|
});
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(String((snap.error as Error).message)).toMatch(/ormSubscription/);
|
|
unsub();
|
|
});
|
|
|
|
// -- the scope axis: the three readings upstream gives it ------------------
|
|
//
|
|
// These are not variants of one case. `graphs` names ONE document's graph per entry;
|
|
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
|
|
// `undefined` returns no objects at all. This module no longer interprets any of it -
|
|
// the scope is handed to the SDK verbatim - so each case pins BOTH that the object
|
|
// arrived unchanged and what the engine then answers.
|
|
|
|
it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("frank");
|
|
const kept = await createEntityDoc("frank", "protected");
|
|
seedEvent(orm, kept, `${kept}:q:one`, "Named");
|
|
const other = await createEntityDoc("frank", "protected");
|
|
seedEvent(orm, other, `${other}:q:one`, "Unnamed");
|
|
|
|
// The whole dataset sees both - that is the reading the named list departs from.
|
|
const all = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsubAll = all.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
|
|
unsubAll();
|
|
|
|
const scope = { graphs: [kept] };
|
|
const obs = useShapeQuery(EventShape, scope);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(obs.getSnapshot().isSuccess).toBe(true);
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Named"]);
|
|
// The object the caller wrote is the object the SDK received: no translation, no
|
|
// copy, nothing added. That is what removing the pipeline bought.
|
|
expect(orm.asked.at(-1)!.scope).toBe(scope);
|
|
unsub();
|
|
});
|
|
|
|
it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("gina");
|
|
const doc = await createEntityDoc("gina", "protected");
|
|
seedEvent(orm, doc, `${doc}:q:one`, "Unreachable");
|
|
|
|
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
|
|
// It is an ANSWER, not a failure - and not the pending state either, which is what an
|
|
// application would otherwise be left staring at.
|
|
// `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it
|
|
// `undefined`" half of that sentence is no longer reachable by omitting the key. `[]`
|
|
// is the other half, and it is the one that still spells "empty".
|
|
const obs = useShapeQuery(EventShape, { graphs: [] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("hank");
|
|
const doc = await createEntityDoc("hank", "protected");
|
|
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and
|
|
// `graphs` cannot - both carry the same `@graph`.
|
|
seedEvent(orm, doc, `${FP}one`, "First");
|
|
seedEvent(orm, doc, `${FP}two`, "Second");
|
|
|
|
const both = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsubBoth = both.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
|
|
unsubBoth();
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Second"]);
|
|
unsub();
|
|
});
|
|
|
|
// -- the capability filter, which this module still owns -------------------
|
|
|
|
it("(j) an object whose document the holder has no capability for does not come back", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("iris");
|
|
// Hers: created through the surface, so its capability is filed as a by-product.
|
|
const mine = await createEntityDoc("iris", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
// Somebody else's, sitting in the same local dataset - which is exactly what
|
|
// `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader
|
|
// may open. The capability filter is the only thing between it and the caller.
|
|
seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]);
|
|
unsub();
|
|
});
|
|
|
|
// -- a NAMED document is reached before the query opens, since `1.0.0-dev.9` ---
|
|
//
|
|
// `graphs` does not only narrow a search: each entry NAMES a document, and this surface
|
|
// used to name one, hand it to the engine, and empty the result with the capability
|
|
// filter - publishing `isSuccess` with `data: []`, which is the one snapshot it exists
|
|
// to reserve for "this scope holds nothing". The four cases below are the distinct ones:
|
|
// the document that cannot be reached, the one that can and is empty, the list where
|
|
// only one member fails, and the emulation not yet in force.
|
|
|
|
it("(l) a named document the holder holds no capability for is isError, not an empty success", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("kate");
|
|
// Creating one document is what puts the emulation in force - the cap registry is
|
|
// inert until a first cap exists, and (o) below is the case where it still is.
|
|
await createEntityDoc("kate", "protected");
|
|
const theirs = "did:ng:o:someone-elses";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(String((snap.error as Error).message)).toContain("does not hold this document");
|
|
// RULE 2, and it is the half a filter could never give: the question was not put at
|
|
// all. Kate's session never asked the engine about a document she cannot open.
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(m) a named document that IS reachable and holds nothing of the shape is isSuccess with data []", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("liam");
|
|
// His own, so the barrier passes; nothing of the shape is ever seeded into it.
|
|
const mine = await createEntityDoc("liam", "protected");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// THE line that must not move. An empty document is a document that answered, and
|
|
// the repair above would only have relocated the lie if this had become an error.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isPending).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
unsub();
|
|
});
|
|
|
|
it("(n) one unreachable document in a named list refuses the whole scope", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("maya");
|
|
const mine = await createEntityDoc("maya", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
const theirs = "did:ng:o:not-mayas";
|
|
seedEvent(orm, theirs, `${theirs}:q:one`, "Theirs");
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine, theirs] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// Not a short list. Answering "Mine" alone would say the scope holds one object when
|
|
// the truth is that half of it was never asked - the tolerance `readUnion` was
|
|
// removed for in `1.0.0-dev.8`, arriving by another door.
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(true);
|
|
expect(snap.isSuccess).toBe(false);
|
|
expect(snap.data).toEqual([]);
|
|
expect(orm.openCount()).toBe(0);
|
|
unsub();
|
|
});
|
|
|
|
it("(o) INERT: before the first cap exists a named document reads, and nothing refuses", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("nora");
|
|
// No document created here, so no cap was ever minted: the emulation is not in force,
|
|
// which is the state every early path runs in. A barrier that fired here would break
|
|
// all of them.
|
|
const early = "did:ng:o:early-doc";
|
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Early"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(p) the empty-string `graphs` is the whole dataset too, and is not guarded either", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("omar");
|
|
const mine = await createEntityDoc("omar", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
// Somebody else's, in the same local dataset: the filter is what keeps it out, and
|
|
// must stay the one that decides here - `""` names no document to refuse.
|
|
seedEvent(orm, "did:ng:o:not-omars", "did:ng:o:not-omars:q:one", "Theirs");
|
|
|
|
// `normalizeScope` maps `""` onto `did:ng:i` (`@ng-org/orm`'s `types.ts`), so the two
|
|
// spellings are one marker. A barrier that treated `""` as a document name would
|
|
// refuse the whole dataset for a reference nobody wrote.
|
|
const obs = useShapeQuery(EventShape, { graphs: "" });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Mine"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(q) unsubscribing while the barrier is still crossing leaves no query open", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("pia");
|
|
const mine = await createEntityDoc("pia", "protected");
|
|
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
|
|
|
|
// The barrier is asynchronous now, so there is a window in which the observable has
|
|
// subscribers, no query, and an open on its way. A teardown landing in that window
|
|
// must not be overtaken by the open it superseded - otherwise the shape query is
|
|
// created after the last listener left and nothing is ever going to close it.
|
|
const obs = useShapeQuery(EventShape, { graphs: [mine] });
|
|
const unsub = obs.subscribe(() => {});
|
|
unsub(); // synchronously, while `ensureRepoOpen` is still awaiting the first `State`
|
|
await tick();
|
|
await tick();
|
|
await tick();
|
|
|
|
expect(orm.openCount()).toBe(0);
|
|
});
|
|
|
|
// -- the cap that arrives LATE, which is the ordinary case --------------
|
|
//
|
|
// A page reload, a `settle` still in flight, an inbox delivery landing while the
|
|
// person is looking at the screen: in all three the view is subscribed BEFORE the
|
|
// keyring holds what it needs. The contract promises that such a view converges by
|
|
// itself - "a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and
|
|
// publishes the document it now opens" - and `CapRegistry.onChange` is the channel.
|
|
// A refusal that cannot be re-tried on that signal is not a stricter answer, it is a
|
|
// dead view.
|
|
|
|
it("(r) a NAMED document whose cap arrives after the subscribe converges, it does not stay isError", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
// Alice writes the note. Bob is the one reading, and he holds nothing yet - the
|
|
// state a page has while its connection work is still restoring the keyring.
|
|
setCurrentUser("alice");
|
|
const note = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
|
|
|
setCurrentUser("bob");
|
|
expect(getCaps().isEnforcing()).toBe(true);
|
|
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
// Honest for now: he cannot read it, and being told so beats an empty success.
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// ...and now the cap lands, exactly as the applied share does it
|
|
// (`branch-registers.addLink` for the connected holder, `inbox-dispatch` for a named
|
|
// one): filed into the keyring, which fires `CapRegistry.onChange`.
|
|
getCaps().learn(mintCap(note));
|
|
await tick();
|
|
await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Shared"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(s) a re-attempt that cannot succeed spends NO round-trip: an absence is memoised", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const note = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, note, `${note}:q:one`, "Shared");
|
|
|
|
setCurrentUser("bob");
|
|
const obs = useShapeQuery(EventShape, { graphs: [note] });
|
|
let notes = 0;
|
|
const unsub = obs.subscribe(() => {
|
|
notes += 1;
|
|
});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
/** How many times the network was asked ABOUT this document - anchored reads only. */
|
|
const asks = (): number =>
|
|
ng.sparql_query.mock.calls.filter((c) => c[3] === note).length;
|
|
const afterRefusal = asks();
|
|
const notesAfterRefusal = notes;
|
|
|
|
// What this replaced, and why. An earlier version gated the re-attempt on "does the
|
|
// holder hold every named document's cap yet", so that an unrelated cap would not
|
|
// buy a round-trip. That gate was CIRCULAR for a document in a public store - the
|
|
// only channel that can ever file its cap is the very fetch the gate was blocking -
|
|
// and an adversarial pass proved the class unrecoverable. The gate is gone: every
|
|
// keyring change re-attempts, for the holder that was refused.
|
|
//
|
|
// What makes that affordable is the memo, and it is worth pinning rather than
|
|
// assuming: the network ANSWERED here (this document exposes no cap), and an answer
|
|
// is remembered. So the re-attempts below re-run the barrier and ask nobody. A
|
|
// failure is the other case - deliberately not memoised, so it IS re-asked, which is
|
|
// exactly the repair for it (see (v)).
|
|
for (let round = 0; round < 3; round++) {
|
|
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
|
|
for (let i = 0; i < 5; i++) await tick();
|
|
}
|
|
|
|
// BOTH halves, or this test is vacuous: a re-attempt that never ran would also leave
|
|
// the count untouched. The republication is what proves the barrier was re-run.
|
|
expect(notes).toBeGreaterThan(notesAfterRefusal);
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
expect(asks()).toBe(afterRefusal);
|
|
unsub();
|
|
});
|
|
|
|
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
|
|
const ng = makeFake({ pushState: false });
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setOpenTimeoutForTests(40); // after `inject`, which resets it to the 8s default
|
|
setCurrentUser("quinn");
|
|
const a = await createEntityDoc("quinn", "protected");
|
|
const b = await createEntityDoc("quinn", "protected");
|
|
const c = await createEntityDoc("quinn", "protected");
|
|
seedEvent(orm, a, `${a}:q:one`, "A");
|
|
seedEvent(orm, b, `${b}:q:one`, "B");
|
|
seedEvent(orm, c, `${c}:q:one`, "C");
|
|
const named: string[] = [a, b, c];
|
|
const subscribesTo = (): number =>
|
|
ng.doc_subscribe.mock.calls.filter((call) => named.includes(String(call[0]))).length;
|
|
expect(subscribesTo()).toBe(0);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [a, b, c] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
|
|
// The deterministic form of "the waits overlap": all three opens are in flight
|
|
// before any of them has answered. A serial barrier would show exactly one here and
|
|
// pay the bounded fallback three times over before publishing anything - which on
|
|
// the production timeout is 24 seconds of `isPending` for a scope naming three
|
|
// documents that never push.
|
|
expect(subscribesTo()).toBe(3);
|
|
|
|
// ...and it still answers, once the fallbacks have fired.
|
|
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
expect(titles(obs.getSnapshot().data)).toEqual(["A", "B", "C"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(u) INERT is not untouched: no refusal, but the cold-start open is real and is paid", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("rex");
|
|
const early = "did:ng:o:inert-named";
|
|
seedEvent(orm, early, `${early}:q:one`, "Early");
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
|
|
const obs = useShapeQuery(EventShape, { graphs: [early] });
|
|
const unsub = obs.subscribe(() => {});
|
|
await tick();
|
|
await tick();
|
|
|
|
// Nothing REFUSES - that half is genuinely inert, since `mayReach` answers true for
|
|
// everything before the first cap exists. But the open is not: `ensureRepoOpen` runs
|
|
// whatever the registry is enforcing, and issues a real `doc_subscribe` held for the
|
|
// session. That is deliberate and it is the cold-start heal - without it a named
|
|
// document this session has never synced answers `isSuccess` with `data: []`, the
|
|
// very lie the barrier is here to remove - but it is a CHANGE, and a scope naming a
|
|
// document now waits on that barrier where it used to open the query at once.
|
|
expect(ng.doc_subscribe.mock.calls.some((call) => call[0] === early)).toBe(true);
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(titles(snap.data)).toEqual(["Early"]);
|
|
unsub();
|
|
});
|
|
|
|
// -- the three the second adversarial pass found -------------------------
|
|
|
|
it("(v) a PUBLIC document whose FETCH failed is asked again when the keyring moves", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const pub = await createEntityDoc("alice", "public");
|
|
seedEvent(orm, pub, `${pub}:q:one`, "Public");
|
|
|
|
setCurrentUser("bob");
|
|
// Bob holds nothing, and the public store is the ONLY channel that can ever hand him
|
|
// this document's cap - which is exactly what the broker now cannot answer.
|
|
ng._unreachable.add(pub);
|
|
const obs = useShapeQuery(EventShape, { graphs: [pub] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// The broker heals, and an ordinary cap arrival follows - a connection restoring a
|
|
// key, an inbox delivery for some other document. The first gate written for this
|
|
// asked "does the holder hold every named document's cap yet", and for a PUBLIC
|
|
// document only the failed fetch can ever make that true: the gate waited on an
|
|
// answer that only the action it gated could produce.
|
|
ng._unreachable.delete(pub);
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
|
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
|
|
|
|
const snap = obs.getSnapshot();
|
|
expect(snap.isError).toBe(false);
|
|
expect(snap.isSuccess).toBe(true);
|
|
expect(titles(snap.data)).toEqual(["Public"]);
|
|
unsub();
|
|
});
|
|
|
|
it("(w) a refused scope never re-opens under a DIFFERENT identity", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const hers = await createEntityDoc("alice", "protected");
|
|
seedEvent(orm, hers, `${hers}:q:one`, "Hers");
|
|
|
|
setCurrentUser("bob");
|
|
const obs = useShapeQuery(EventShape, { graphs: [hers] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
|
|
// The page becomes Alice. No application can do this - `ensureIdentity` is the only
|
|
// published call that settles an identity and it returns the one already set
|
|
// (`access-gate.resolveIdentity` reads `getCurrentUser()` first) - but the re-open is
|
|
// a NEW access, not a re-filtering of an existing one, and a new access must never be
|
|
// issued on behalf of somebody who did not ask for it.
|
|
setCurrentUser("alice");
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
|
|
for (let i = 0; i < 10; i++) await tick();
|
|
expect(orm.openCount()).toBe(0); // nothing was subscribed or queried as Alice
|
|
|
|
// ...and Bob's view must not come back as a SUCCESS holding nothing, which is the lie
|
|
// this whole task exists to remove, arriving by the repair's own door.
|
|
setCurrentUser("bob");
|
|
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
|
|
for (let i = 0; i < 10; i++) await tick();
|
|
expect(obs.getSnapshot().isSuccess).toBe(false);
|
|
expect(obs.getSnapshot().isError).toBe(true);
|
|
unsub();
|
|
});
|
|
|
|
it("(x) the failure reported is the FIRST named document that failed, in scope order", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("alice");
|
|
const hers = await createEntityDoc("alice", "protected");
|
|
const alsoHers = await createEntityDoc("alice", "public");
|
|
|
|
setCurrentUser("bob");
|
|
// Two named documents failing for two DIFFERENT reasons. The first answers and holds
|
|
// no cap for Bob (a refusal); the second cannot be asked at all (a rejection, which
|
|
// arrives first because it does not wait for the sync barrier). Running the opens
|
|
// together and letting the first rejection win would report the SECOND document.
|
|
ng._unreachable.add(alsoHers);
|
|
const obs = useShapeQuery(EventShape, { graphs: [hers, alsoHers] });
|
|
const unsub = obs.subscribe(() => {});
|
|
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
|
|
|
|
const message = String((obs.getSnapshot().error as Error).message);
|
|
expect(message).toContain(hers);
|
|
expect(message).not.toContain(alsoHers);
|
|
unsub();
|
|
});
|
|
|
|
// -- teardown ------------------------------------------------------------
|
|
|
|
it("(k) the last unsubscribe closes the shape query", async () => {
|
|
const ng = makeFake();
|
|
const orm = makeOrm();
|
|
inject(ng, orm);
|
|
setCurrentUser("jane");
|
|
|
|
const obs = useShapeQuery(EventShape, EVERYTHING);
|
|
const first = obs.subscribe(() => {});
|
|
const second = obs.subscribe(() => {});
|
|
await tick();
|
|
expect(orm.openCount()).toBe(1);
|
|
|
|
first();
|
|
expect(orm.openCount()).toBe(1); // still a subscriber
|
|
second();
|
|
expect(orm.openCount()).toBe(0); // the query is closed, not leaked
|
|
});
|
|
});
|