Files

856 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `@ng-helpers/indexing` against the REAL broker.
*
* What this suite is for
* The unit suite proves the indexing rules are consistent with a fake this repository
* wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake
* is the very thing in question. Two claims in particular had never met a broker:
*
* 1. **The write form.** An entry is a triple whose SUBJECT is another document — the
* indexed object — written into the index document's anchored default graph. The
* polyfill's own suites only ever write a document's own subject into itself, so
* nothing had ever asked oxigraph whether a FOREIGN subject survives the round trip.
* `publishObject` here writes the self-subject form with the same primitive, which
* makes it the control: if one round-trips and the other does not, the difference is
* the foreign subject and nothing else.
*
* 2. **A half-created index.** `create` creates a document, writes its descriptor,
* then opens its inbox. If the last step fails the caller gets an exception and no
* reference — but the document exists. The last journey injects that failure and
* asks the broker what was left behind.
*
* Two identities, and how the index reference reaches the second
* The whole point of an index is that STRANGERS contribute to it. So Bob must reach
* Alice's index — and he must reach it the way an application would, not through a
* variable in this file. An index is an ordinary document whose NURI an application
* references in its own source (`src/indexing.ts`), so Bob's page is CONFIGURED with it,
* through its URL: one build step earlier, that is a compiled-in constant. What must
* never happen — and does not happen here — is an inbox address crossing the identity
* boundary through a channel no deployment has.
*
* Waiting, because nothing here says when processing is done
* The two acts are `create` and `add`; what becomes of what is added is the business of
* the layer below, which applies a deposit when the owner's session is PUSHED one. There
* is no call that forces it, no receipt, and no `await` that covers it — by design. So a
* journey that has deposited WAITS for the index to hold the entry, reading it the way an
* application reads one (`settled`), and only then asserts. Before that wait existed the
* checks read the instant after the deposit and seven of them reported an index that was
* merely not written YET.
*
* The one claim that wait cannot carry: "an object carrying nothing for the field is not
* indexed". A deposit that is deliberately not indexed writes nothing, so no reading tells
* "examined and skipped" from "not examined yet", and there is nothing to wait for that is
* not a sleep. That journey reads straight away and its check is therefore weaker than its
* name — recorded here rather than hidden. What partly holds it up is order: that deposit
* precedes the hostile one, and the hostile one IS waited for, so by the end of the run the
* queue holding it has demonstrably been applied at least once.
*
* Reading a failure
* A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame
* failure, is the HOST. A failed check carrying an unexpected value is this code. The
* report says which, and the run is repeated rather than anything being loosened.
*/
import {
BROKER_ROUND_TRIP_MS,
NEW_PAGE_MS,
armSuiteDeadline,
browserTrouble,
closeContext,
closeQuietly,
declareSuite,
firstLine,
launchWatchedContext,
measured,
newPage,
setupBrokerPage,
within,
type Prerequisite,
type RunProfile,
} from "ng-e2e-helpers";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/index";
import { WALLET, buildApp, mintRunWallet, serveApp } from "./harness-page";
/**
* The browser types, taken from the helpers that RETURN them rather than imported from
* `playwright` directly.
*
* `ng-e2e-helpers` declares Playwright a PEER dependency — the consumer owns the version,
* because browser binaries have to match the driver. Its files reach this repository as
* symlinks, so TypeScript resolves its `playwright` from where those files really live,
* and importing the driver here as well produced two structurally different copies of
* `BrowserContext`: a context this file had opened could not be handed back to the helper
* that opens contexts. Derived, there is exactly one set of these types — whichever copy
* the helpers speak — and a version skew can no longer express itself as a type error in
* code that is correct.
*/
type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
type Page = Awaited<ReturnType<typeof newPage>>;
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
// the domain this suite indexes by
//
// A date, so the suite exercises the case the package is built around: an index "by a
// date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string.
const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
/** A predicate an index is NOT built on — for the object that carries nothing usable. */
const UNRELATED = "urn:ng-helpers-e2e:unrelated";
// bounds
//
// Sized to be generous rather than tight. A bound exists to turn a hang into a named
// failure; sized to the median it would instead fail on a slow-but-healthy broker, which
// is the one thing it must never do. The wall clocks of the three reported runs are the
// measurement these should be re-sized from.
/** The bridge appearing on the page — a bundle evaluating, no broker involved. */
const BRIDGE_UP_MS = 60_000;
/** `ensureIdentity` + the session: an identity settled and the connection work run. */
const READY_MS = 180_000;
/** One sign-in: a page, the broker round trip, and the application booting behind it. */
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS;
/** One call across the bridge. The slowest cross the broker once per deposit. */
const BRIDGE_MS = 4 * 60_000;
/**
* How often the index is READ while waiting for a deposit to have become an entry.
*
* Not a sleep standing in for the wait: it is the interval at which the CONDITION is asked,
* and the wait ends on the first reading that holds. `indexing-app.ts` polls its own store
* at the same interval, for the same reason.
*/
const SETTLE_POLL_MS = 500;
/**
* How long a deposit has to become an entry before the wait gives up and says what the
* index held instead.
*
* MEASURED on a green run (`E2E_TIMINGS=1`, 2026-08-21, one sample each): 0.6s for Bob's
* first deposit becoming an entry, 0.7s for the hostile one, and 0.0s for the entry
* reaching a stranger's own session — that last one had already converged by the time it
* was asked. The layer below is push-driven, so what is waited on is one push and one small
* write, not a broker crossing; that is why these are sub-second while a deposit or a
* publish measures 0.10.9s.
*
* Bounded at 60s ≈ 85x the slowest of them, generous on purpose. It must never fire on a
* slow-but-healthy broker, and it is what turns "the entry never came" into a named failure
* carrying the last reading — instead of a check that merely read too early, which is what
* the seven failures it was written for looked like.
*/
const SETTLE_MS = 60_000;
/**
* One journey. The longest holds two sign-ins' worth of work behind it.
*
* NOT the sum of the steps it encloses, and that is deliberate — the same arbitration
* `packages/polyfill/e2e/notebook.ts` records for its own: the longest journey here sums to
* 16 min of step bounds (four bridge calls), and a journey bounded above that would outlast
* the SUITE's own clock, so one hung journey would take the summary down with it. Every step
* inside a journey already carries a bound and names itself, so this catches only a hang in
* code no step wraps. What DOES have to hold is that this enclosure cannot fire BEFORE the
* settle point it now encloses, or a settle that gave up would report as an anonymous
* journey timeout: measured on a green run, the longest journey holding a settle takes 1.6s
* and the longest of all (a sign-in) 9.8s, so a journey that spends SETTLE_MS (60s) waiting
* still has more than eight minutes of this bound left — the settle is always the one that
* reports.
*/
const JOURNEY_MS = 10 * 60_000;
/** The whole run. A budget that cannot interrupt anything is not a budget. */
const SUITE_MS = 30 * 60_000;
// the report
let actors: BrowserContext | null = null;
const { check, journey, finish } = declareSuite({
label: "ng-helpers indexing e2e",
journeyBound: JOURNEY_MS,
diagnose: async () => (actors === null ? null : browserTrouble("actors", actors)),
journeys: [
{
name: "Alice signs in and creates an index",
checks: [
"Alice signs in and the application knows who she is",
"creating an index answers with a document reference",
"the index document declares the field it indexes by",
],
},
{
name: "Bob signs in configured with Alice's index, and publishes an object",
checks: [
"Bob signs in, configured with the index his application contributes to",
"Bob publishes a public object carrying the indexed field",
"Bob's object reads back carrying the value he wrote",
],
},
{
name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
checks: [
"a stranger's deposit into the index's inbox reached its owner and became an entry",
"the entry is stored under Bob's object's own reference as its subject",
"the indexed value was read off Bob's object, and never travelled in his deposit",
],
},
{
name: "The index reads back, for its owner and for a stranger",
checks: [
"Alice reads exactly one entry, and it is Bob's object",
"Bob, who does not own the index, reads the same entry",
"connecting a second time changes nothing, and the index still holds one entry",
],
},
{
name: "An object carrying nothing for the field is not indexed",
checks: [
"the unrelated object is not indexed, and the index still holds exactly one entry",
],
},
{
name: "An index whose inbox cannot be opened leaves a document behind",
checks: [
"createIndex refuses when the inbox cannot be opened",
"a document was nevertheless created in the owner's public store",
"the leaked document carries a descriptor but accepts no deposit",
],
},
{
name: "A hostile value crosses the round trip as one inert literal",
checks: [
"the object reads back the hostile value byte for byte",
"the index holds it as one entry, and its own descriptor is untouched",
],
},
{
name: "The index answers an ordinary SPARQL query",
checks: [
"readUnion returns both entries and the index's own declaration",
"a SELECT for the entry predicate returns both entries, with their values",
"a SELECT of the index's own subject returns the field it declares",
"a stranger's SELECT returns the same entries",
],
},
],
});
/**
* Two readings of the same entries — the same objects, carrying the same values.
*
* Compared as a WHOLE: a query that answered with a subset, or with a value that changed
* shape crossing the round trip, is not the same answer as the document's own content.
*/
function sameEntries(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
if (a.size !== b.size) return false;
for (const [object, value] of a) {
if (b.get(object) !== value) return false;
}
return true;
}
/** The `?object`/`?value` rows of an entries SELECT, as the entries they claim to be. */
function entriesOf(rows: ReadonlyArray<Readonly<Record<string, string>>>): Map<string, string> {
const found = new Map<string, string>();
for (const row of rows) {
const object = row["object"];
const value = row["value"];
if (object !== undefined && value !== undefined) found.set(object, value);
}
return found;
}
/** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */
function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
return measured(what, ms, (bound) => within(what, bound, task));
}
/**
* Read the index until it HOLDS what the checks about to run expect, and hand that reading
* back to them.
*
* ── Why the harness needs this at all ───────────────────────────────────────
* Nothing on this package's surface says "processing is done", deliberately: the two acts
* are `create` and `add`, and what becomes of what is added belongs to the layer below,
* which applies a deposit when the owner's session is PUSHED one. There is no call that
* forces it and no receipt to hold. So a check that reads the instant after a deposit reads
* too early — which is exactly what seven of these checks were reporting.
*
* ── A wait is not a retry of the assertion ──────────────────────────────────
* `holds` asks one thing only: has the deposit ARRIVED. The checks then assert their own
* claims ONCE, on the reading this returns. A loop that re-ran the checks until one of them
* passed would turn a flapping result into a green one and report the reading that happened
* to suit it.
*
* ── It reads the way an application reads ───────────────────────────────────
* `read` is an ordinary `readUnion` of an ordinary document, or an anchored SELECT — the
* only two ways anyone has of reading an index, the harness included. Nothing here reaches
* for a way to hurry the layer below, because a consumer has none either.
*
* On expiry it throws, naming what never arrived and WHAT THE INDEX HELD INSTEAD: the
* journey's remaining checks are then reported unreached with that reason, so the run still
* reports its thirty rows and the reason is the observation rather than a bare "timeout".
*/
async function settled<T>(
what: string,
read: () => Promise<T>,
holds: (seen: T) => boolean,
describe: (seen: T) => string,
): Promise<T> {
return measured(what, SETTLE_MS, async (bound) => {
const deadline = Date.now() + bound;
for (;;) {
const seen = await within(what, bound, read);
if (holds(seen)) return seen;
if (Date.now() >= deadline) {
throw new Error(
`[e2e] ${what}: nothing arrived within ${(bound / 1000).toFixed(0)}s — ` +
`the last reading was ${describe(seen)}`,
);
}
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
}
});
}
// an actor
interface Actor {
readonly id: string;
readonly frame: Frame;
readonly page: Page;
}
/**
* Sign an actor in, and wait for its application to be up.
*
* `?ng-id=` is the one channel that survives the broker round trip (the access gate's
* resolution order). `index` rides the same query string when the actor's deployment is
* built to contribute to one.
*/
async function signIn(
ctx: BrowserContext,
appUrl: string,
id: string,
index: string | null,
): Promise<Actor> {
const opened: { page: Page | null } = { page: null };
const query =
`?ng-id=${encodeURIComponent(id)}` +
(index === null ? "" : `&index=${encodeURIComponent(index)}`);
try {
return await measured(`${id}'s sign-in`, SIGN_IN_MS, (bound) =>
within(`${id} to sign in`, bound, async () => {
const page = await measured(`a page for ${id}`, NEW_PAGE_MS, () => newPage(id, ctx));
opened.page = page;
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error(`[${id} console]`, m.text());
});
const frame = await measured(`${id}'s broker round trip`, BROKER_ROUND_TRIP_MS, () =>
setupBrokerPage(page, `${appUrl}/${query}`, WALLET.password),
);
await waitReady(id, frame);
return { id, frame, page };
}),
);
} catch (e) {
if (opened.page !== null) {
await closeQuietly(`${id}'s abandoned sign-in page`, () => opened.page!.close());
}
throw e;
}
}
/** Wait for the application to be up, and say why with ITS reason when it is not. */
async function waitReady(id: string, frame: Frame): Promise<void> {
await step(`${id}'s application bundle`, BRIDGE_UP_MS, () =>
frame.waitForFunction(() => window.__indexing !== undefined, undefined, {
timeout: BRIDGE_UP_MS,
}),
);
await step(`${id}'s identity and session`, READY_MS, () =>
frame.waitForFunction(() => window.__indexing.status() !== "connecting", undefined, {
timeout: READY_MS,
}),
);
const status = await frame.evaluate(() => window.__indexing.status());
if (status !== "ready") {
const why = await frame.evaluate(() => window.__indexing.error());
throw new Error(`[e2e] ${id}'s application did not start (${status}): ${why ?? "no reason given"}`);
}
}
/**
* A journey cannot start without the actor it drives — reported as that, not discovered
* as a timeout on an innocent call.
*
* It takes a THUNK, not the actor: read eagerly, the value would be captured as it was
* before any sign-in happened, and every journey would report an actor that is standing
* right there as missing.
*/
function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
return () => (actor() === null ? `${id} never signed in` : null);
}
// the run
async function main(): Promise<void> {
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
finish("the suite exceeded its wall clock"),
);
console.log("[e2e] building the application...");
buildApp();
// This run's own physical user, in a directory of its own — so another repository's
// suite can drive the same broker at the same time without either noticing.
console.log("[e2e] minting this run's wallet...");
const wallet: RunProfile = await mintRunWallet("the indexing suite (e2e/run.ts)");
const stamp = Date.now().toString(36);
const ALICE = `alice-${stamp}`;
const BOB = `bob-${stamp}`;
let ctx: BrowserContext | null = null;
let closeServer: (() => void) | null = null;
try {
const served = await serveApp();
closeServer = served.close;
console.log(`[e2e] application served at ${served.url}`);
ctx = await launchWatchedContext("actors", wallet.dir);
actors = ctx;
let alice: Actor | null = null;
let bob: Actor | null = null;
let index: string | null = null;
let bobsObject: string | null = null;
const aliceIsUp = actorIsUp(ALICE, () => alice);
const bobIsUp = actorIsUp(BOB, () => bob);
const indexExists: Prerequisite = () =>
index === null ? "Alice never created an index" : null;
await journey({
name: "Alice signs in and creates an index",
run: async () => {
alice = await signIn(ctx!, served.url, ALICE, null);
const who = await alice.frame.evaluate(() => window.__indexing.whoami());
check("Alice signs in and the application knows who she is", who.length > 0, `who=${who}`);
index = await step("Alice creating an index", BRIDGE_MS, () =>
alice!.frame.evaluate((f) => window.__indexing.createIndex(f), PUBLISHED_AT),
);
check(
"creating an index answers with a document reference",
typeof index === "string" && index.startsWith("did:ng:"),
`index=${index}`,
);
// The descriptor's round trip — and the first thing the fake could have been
// lying about: the index document is found by an EXACT match on its own NURI as
// a subject, so a broker that returns a subject shaped differently breaks every
// read of every index.
const raw = await step("Alice reading the index document", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const self = raw.find((s) => s.subject === index);
const declared = self?.props[INDEX_FIELD] ?? [];
check(
"the index document declares the field it indexes by",
declared.length === 1 && declared[0] === PUBLISHED_AT && self?.graph === index,
`subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` +
`graph=${self?.graph} declared=${JSON.stringify(declared)}`,
);
},
});
await journey({
name: "Bob signs in configured with Alice's index, and publishes an object",
needs: [indexExists],
run: async () => {
bob = await signIn(ctx!, served.url, BOB, index);
const configured = await bob.frame.evaluate(() => window.__indexing.configuredIndex());
check(
"Bob signs in, configured with the index his application contributes to",
configured === index,
`configured=${configured}`,
);
bobsObject = await step("Bob publishing an object", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[PUBLISHED_AT, "2026-08-17T09:00:00Z"],
),
);
check(
"Bob publishes a public object carrying the indexed field",
typeof bobsObject === "string" && bobsObject.startsWith("did:ng:"),
`object=${bobsObject}`,
);
// The CONTROL for the write form: the same primitive, the document as its own
// subject. This is the shape the polyfill's own suites already exercise.
const raw = await step("Bob reading his own object", BRIDGE_MS, () =>
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), bobsObject!),
);
const self = raw.find((s) => s.subject === bobsObject);
check(
"Bob's object reads back carrying the value he wrote",
(self?.props[PUBLISHED_AT] ?? []).includes("2026-08-17T09:00:00Z"),
`subjects=${raw.length} props=${JSON.stringify(self?.props ?? {})}`,
);
},
});
await journey({
name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
needs: [
aliceIsUp,
bobIsUp,
indexExists,
() => (bobsObject === null ? "Bob never published an object" : null),
],
run: async () => {
// Bob names his OWN object, and the index he was configured with. Nothing about
// the value travels: the deposit is the reference and nothing else.
await step("Bob depositing a reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), bobsObject!),
);
// NOBODY PROCESSES ANYTHING HERE, and nothing says when it is done: there is no
// such call on the surface, and no receipt. The reference becomes an entry because
// the layer below applies the inbox of the index Alice owns, when her session is
// pushed it — so the suite WAITS for the index to hold it, reading the document the
// way any application reads one.
//
// THE WRITE FORM, answered by the same reading. An entry is a triple whose subject
// is another document, written into this one's anchored default graph. "The write
// did not throw" is not the same claim as "oxigraph stored it": this reads it back.
const raw = await settled(
"Bob's deposit becoming an entry of Alice's index",
() => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
(subjects) => subjects.some((s) => s.subject === bobsObject),
(subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
);
const entry = raw.find((s) => s.subject === bobsObject);
// The deposit is proven ARRIVED by its only possible effect: nobody but Alice
// reads that inbox, so an entry for Bob's object means his deposit crossed the
// identity boundary and her session found it. That the post did not throw is a
// weaker claim entirely — it says the call returned, and nothing more.
check(
"a stranger's deposit into the index's inbox reached its owner and became an entry",
entry !== undefined,
`from=${BOB} subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
check(
"the entry is stored under Bob's object's own reference as its subject",
entry !== undefined && Object.hasOwn(entry.props, ENTRY_VALUE),
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
// The value never travelled: a deposit is the reference and nothing else, so its
// presence here means whatever processed that inbox read it off Bob's object itself.
check(
"the indexed value was read off Bob's object, and never travelled in his deposit",
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
`entry=${JSON.stringify(entry?.props ?? {})}`,
);
},
});
await journey({
name: "The index reads back, for its owner and for a stranger",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
const mine = await step("Alice reading the index", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"Alice reads exactly one entry, and it is Bob's object",
mine.length === 1 && mine[0]?.object === bobsObject,
`entries=${JSON.stringify(mine)}`,
);
// A public index is read by whoever holds its reference — including someone who
// owns neither it nor anything in it. This is the act an application performs.
//
// Waited for on its OWN account: the journey above settled ALICE's session, and
// Bob's is a different one reading a document he does not own. "The owner sees it"
// and "a stranger sees it" are two arrivals, and only one of them has happened.
const theirs = await settled(
"the entry reaching a stranger's session",
() => bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
(rows) => rows.some((r) => r.object === bobsObject),
(rows) => `entries=${JSON.stringify(rows)}`,
);
check(
"Bob, who does not own the index, reads the same entry",
theirs.length === 1 && theirs[0]?.object === bobsObject,
`entries=${JSON.stringify(theirs)}`,
);
// Deposits are never retired, so every run sees every deposit again. Convergence
// is what makes that affordable.
await step("Alice loading her page a second time", BRIDGE_MS, () =>
alice!.frame.evaluate(() => window.__indexing.rebuildHandle()),
);
const still = await step("Alice reading the index again", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"connecting a second time changes nothing, and the index still holds one entry",
still.length === 1 && still[0]?.object === bobsObject,
`entries=${JSON.stringify(still)}`,
);
},
});
await journey({
name: "An object carrying nothing for the field is not indexed",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// PRESENT but carrying nothing for the field — which is a different answer from
// an object that cannot be read at all, and the reason this object carries a
// predicate rather than being empty: an empty document reads exactly like an
// unreadable one, and resolves as `unresolved`, not `skipped`.
const other = await step("Bob publishing an unrelated object", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[UNRELATED, "nothing to index by"],
),
);
await step("Bob depositing the unrelated reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), other),
);
// NOTHING TO WAIT FOR, and it is worth saying rather than dressing up. A deposit
// that is deliberately not indexed writes nothing, so the index holds afterwards
// exactly what it held before and no reading can tell "examined and skipped" from
// "not examined yet". A wait for the state the check expects would return on its
// first reading and prove nothing; a wait for a duration would be a sleep. So this
// reads straight away, and the check is a weaker claim than it reads as — see the
// suite's header. What DOES hold it: the deposit is applied before the hostile one
// below, and that one is waited for.
const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
);
check(
"the unrelated object is not indexed, and the index still holds exactly one entry",
entries.length === 1 && !entries.some((e) => e.object === other),
`entries=${JSON.stringify(entries)}`,
);
},
});
await journey({
name: "An index whose inbox cannot be opened leaves a document behind",
needs: [aliceIsUp],
run: async () => {
const outcome = await step("Alice creating an index whose inbox fails", BRIDGE_MS, () =>
alice!.frame.evaluate(
(f) => window.__indexing.createIndexWithBrokenInbox(f),
PUBLISHED_AT,
),
);
check(
"createIndex refuses when the inbox cannot be opened",
outcome.rejected !== null && outcome.returned === null,
`rejected=${outcome.rejected} returned=${outcome.returned}`,
);
check(
"a document was nevertheless created in the owner's public store",
outcome.appeared.length === 1,
`appeared=${JSON.stringify(outcome.appeared)}`,
);
// What the leaked document IS: an index in every respect but the one that makes
// it usable. Alice found it in her own store — the only way anyone can, since
// `createIndex` threw its reference away.
const leaked = outcome.appeared[0];
if (leaked === undefined) {
check(
"the leaked document carries a descriptor but accepts no deposit",
false,
"no document appeared, so there was nothing to inspect",
);
return;
}
const raw = await step("Alice reading the leaked document", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), leaked),
);
const declares = (raw.find((s) => s.subject === leaked)?.props[INDEX_FIELD] ?? []).includes(
PUBLISHED_AT,
);
const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => {
try {
await alice!.frame.evaluate(
([i, o]) => window.__indexing.addTo(i!, o!),
[leaked, bobsObject ?? leaked],
);
return null;
} catch (e) {
return firstLine(e);
}
});
check(
"the leaked document carries a descriptor but accepts no deposit",
declares && refused !== null,
`declares=${declares} deposit=${refused ?? "(accepted)"}`,
);
},
});
// LAST, deliberately: if the escaping below turned out not to hold, the damage would
// be to this index, and every check above has already been taken.
await journey({
name: "A hostile value crosses the round trip as one inert literal",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// `src/sparql.ts` carries this package's OWN escaping, because the polyfill
// publishes none. Until now it had only ever been judged by a fake whose SPARQL
// reader was written from the same assumptions — a pair that agrees with itself
// proves nothing about oxigraph. This value closes every construct the escaping
// is responsible for: the literal's own quote, a backslash, the whitespace
// escapes, and a complete injected UPDATE that would empty the index if the
// quote ever escaped its literal.
const hostile =
'a "quoted" part, a \\ backslash, a\nnewline, a\ttab, ' +
'" } ; DROP ALL ; INSERT DATA { <urn:ng-helpers-e2e:pwned> <urn:ng-helpers-e2e:pwned> "';
const object = await step("Bob publishing a hostile value", BRIDGE_MS, () =>
bob!.frame.evaluate(
([p, v]) => window.__indexing.publishObject(p!, v!),
[PUBLISHED_AT, hostile],
),
);
const raw = await step("Bob reading the hostile object", BRIDGE_MS, () =>
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), object),
);
const stored = raw.find((s) => s.subject === object)?.props[PUBLISHED_AT] ?? [];
check(
"the object reads back the hostile value byte for byte",
stored.length === 1 && stored[0] === hostile,
`stored=${JSON.stringify(stored)}`,
);
await step("Bob depositing the hostile reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), object),
);
// Waited for as the first deposit was, and read RAW: the index must still declare
// its own field. An injected `DROP ALL` that had taken effect would show up exactly
// here, as a descriptor that is no longer there — and `read()` alone could not tell
// that apart from an ordinary failure.
const after = await settled(
"the hostile deposit becoming an entry",
() => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
(subjects) => subjects.some((s) => s.subject === object),
(subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
);
const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? [];
const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? [];
check(
"the index holds it as one entry, and its own descriptor is untouched",
entry.length === 1 && entry[0] === hostile && descriptor.includes(PUBLISHED_AT),
`entry=${JSON.stringify(entry)} descriptor=${JSON.stringify(descriptor)}`,
);
},
});
// AFTER the hostile journey, and read-only: by here the index holds SEVERAL entries,
// which is the state the question is about — one entry cannot tell a query that
// returns everything from one that returns the first thing it finds.
//
// WHAT IS BEING ASKED. This package documents one way of reading an index
// (`readUnion`) and issues no query of its own, so "an index is an ordinary document
// anyone queries normally" has never been anything but plausible. These four checks
// are a measurement of that sentence, not a feature: an empty answer is a RESULT and
// is reported as one, and a rejection is reported apart from it, because "the query
// found nothing" and "the query failed" are the two answers this repository keeps
// finding folded into one.
await journey({
name: "The index answers an ordinary SPARQL query",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// The REFERENCE the queries below are judged against. "The SELECT came back with
// the entries" is only a claim if something independent says what the entries
// are — and it is what makes an empty answer below mean something instead of
// being indistinguishable from an index that holds nothing.
const raw = await step("Alice reading the index before querying it", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const held = new Map<string, string>();
for (const subject of raw) {
const value = subject.props[ENTRY_VALUE] ?? [];
if (value.length === 1 && value[0] !== undefined) held.set(subject.subject, value[0]);
}
const declares = (raw.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? []).includes(
PUBLISHED_AT,
);
check(
"readUnion returns both entries and the index's own declaration",
held.size === 2 && declares,
`entries=${JSON.stringify([...held.keys()])} declares=${declares}`,
);
// The shape a reader would write, with nothing of this package in it: the entry
// predicate, and the anchored default graph the index document is.
const entriesQuery = `SELECT ?object ?value WHERE { ?object <${ENTRY_VALUE}> ?value }`;
const mine = await step("Alice querying the index for its entries", BRIDGE_MS, () =>
alice!.frame.evaluate(
([a, q]) => window.__indexing.select(a!, q!),
[index!, entriesQuery],
),
);
const answered = entriesOf(mine.rows);
check(
"a SELECT for the entry predicate returns both entries, with their values",
mine.failed === null && sameEntries(held, answered),
`failed=${mine.failed} rows=${mine.rows.length} ` +
`objects=${JSON.stringify([...answered.keys()])} raw=${mine.raw}`,
);
// The index document's OWN subject, which is the other half of what an index
// holds — and the half a reader needs to know what the values mean.
const fieldQuery = `SELECT ?field WHERE { <${index!}> <${INDEX_FIELD}> ?field }`;
const declared = await step("Alice querying the index's declaration", BRIDGE_MS, () =>
alice!.frame.evaluate(([a, q]) => window.__indexing.select(a!, q!), [index!, fieldQuery]),
);
check(
"a SELECT of the index's own subject returns the field it declares",
declared.failed === null &&
declared.rows.length === 1 &&
declared.rows[0]?.["field"] === PUBLISHED_AT,
`failed=${declared.failed} rows=${declared.rows.length} raw=${declared.raw}`,
);
// The reader who matters: an index exists to be read by people who own neither it
// nor anything in it. `readUnion` already answers him (the journey above); whether
// a query does is a separate question, and it is the one an application asks.
const theirs = await step("Bob querying the index he does not own", BRIDGE_MS, () =>
bob!.frame.evaluate(
([a, q]) => window.__indexing.select(a!, q!),
[index!, entriesQuery],
),
);
const strangers = entriesOf(theirs.rows);
check(
"a stranger's SELECT returns the same entries",
theirs.failed === null && sameEntries(held, strangers),
`failed=${theirs.failed} rows=${theirs.rows.length} ` +
`objects=${JSON.stringify([...strangers.keys()])} raw=${theirs.raw}`,
);
},
});
} finally {
if (ctx !== null) await closeContext("actors", ctx);
if (closeServer !== null) {
await closeQuietly("the application server", async () => closeServer!());
}
wallet.discard();
}
finish(null);
}
void main().catch((e: unknown) => {
console.error("[e2e] fatal:", (e as Error)?.stack ?? e);
finish(firstLine(e));
});