620 lines
28 KiB
TypeScript
620 lines
28 KiB
TypeScript
/**
|
|
* `@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.** `createIndex` 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.
|
|
*
|
|
* ── 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 does NOT curate 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 here are curations, which round-trip per deposit. */
|
|
const BRIDGE_MS = 4 * 60_000;
|
|
/** One journey. The longest holds two sign-ins' worth of work behind it. */
|
|
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 Alice's next connection curates it",
|
|
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",
|
|
"curating 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",
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
/** 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));
|
|
}
|
|
|
|
// ── 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 Alice's next connection curates it",
|
|
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.referConfigured(o), bobsObject!),
|
|
);
|
|
|
|
// NOBODY CURATES: there is nothing on the surface to call. Alice's page connects
|
|
// again — what a page load does — and her session processes the inboxes of the
|
|
// indexes she owns, this one among them.
|
|
await step("Alice connecting again", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
|
);
|
|
|
|
// THE WRITE FORM, answered. 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 step("Alice reading the index document back", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
|
);
|
|
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 the curation 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.
|
|
const theirs = await step("Bob reading the index he does not own", BRIDGE_MS, () =>
|
|
bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
|
);
|
|
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 connecting a second time", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
|
);
|
|
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.referConfigured(o), other),
|
|
);
|
|
|
|
await step("Alice connecting after the unrelated reference", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
|
);
|
|
|
|
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.referTo(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.referConfigured(o), object),
|
|
);
|
|
await step("Alice connecting after the hostile reference", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
|
);
|
|
|
|
// Read the index document RAW: it 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 step("Alice reading the index after the hostile entry", BRIDGE_MS, () =>
|
|
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
|
);
|
|
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)}`,
|
|
);
|
|
},
|
|
});
|
|
} 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));
|
|
});
|