215 lines
10 KiB
TypeScript
215 lines
10 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { indexingOn } from "../src/indexing";
|
|
import { INDEX_FIELD } from "../src/vocabulary";
|
|
import type { NextGraphPort, Nuri } from "../src/port";
|
|
import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill";
|
|
|
|
/**
|
|
* The REAL `polyfill-adapter.ts`, running.
|
|
*
|
|
* ## What was wrong with the gate this replaces
|
|
*
|
|
* It mocked the polyfill to constants and drove only the methods `create` happens to
|
|
* use. Everything else was inert: a method could be gutted — write nothing, return
|
|
* `[]` — with the suite still green, and the gate's own promise ("every query it
|
|
* emits is read back") held only for the queries those methods emitted. A
|
|
* destructive statement planted in a method nobody drove was never recorded.
|
|
*
|
|
* ## What holds now
|
|
*
|
|
* The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather
|
|
* than returning constants, so the tests below are ordinary behavioural tests that
|
|
* happen to run through the real wiring. Gut any of the four and something here goes
|
|
* red, because each one is load-bearing for an outcome that is asserted.
|
|
*
|
|
* Three properties are checked on top of behaviour, and each closes a hole the
|
|
* review walked through:
|
|
*
|
|
* - **the fake EXECUTES the SPARQL** (`applyInsertData`) — an engine that can only
|
|
* add, so a removal is unrunnable rather than "detected". This is what covers a
|
|
* query site in a method nobody thought to inspect: whatever the query, it has to
|
|
* go through the engine to have any effect at all, and a split literal is one
|
|
* string by the time it gets there;
|
|
* - **an unmodelled polyfill entry throws by name** — `docs.sparqlQuery` included,
|
|
* so routing a statement through the read door is red, not silent;
|
|
* - **the last test fails when a method is added without a driver** — it reads the
|
|
* adapter's own keys, so there is no list to keep up to date.
|
|
*/
|
|
|
|
const world = installFakePolyfill();
|
|
|
|
const FIELD = "http://schema.org/datePublished";
|
|
|
|
/** Every adapter method actually CALLED by the tests below. Read by the last test. */
|
|
const driven = new Set<string>();
|
|
|
|
function track(port: NextGraphPort): NextGraphPort {
|
|
return new Proxy(port, {
|
|
get(target, property, receiver) {
|
|
const value: unknown = Reflect.get(target, property, receiver);
|
|
if (typeof property !== "string" || typeof value !== "function") return value;
|
|
return (...args: unknown[]) => {
|
|
driven.add(property);
|
|
return (value as (...a: unknown[]) => unknown).apply(target, args);
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Everything inside runs under `user`'s session.
|
|
*
|
|
* A polyfill session IS one identity, so this is not test sugar: it is the only
|
|
* shape the real thing has. Each actor gets its OWN port and no actor is handed a
|
|
* value another one computed, except the index NURI — which is exactly the value
|
|
* the README says an application hardcodes in its own source, and the only thing
|
|
* that legitimately travels.
|
|
*/
|
|
async function as<T>(user: string, run: (port: NextGraphPort) => Promise<T>): Promise<T> {
|
|
world.signIn(user);
|
|
const { polyfillPort } = await import("../src/polyfill-adapter");
|
|
return run(track(polyfillPort({ sessionId: world.sessionId() })));
|
|
}
|
|
|
|
/** A public document carrying one value for `field`, published by whoever is signed in. */
|
|
async function publish(port: NextGraphPort, field: string, value: string): Promise<Nuri> {
|
|
const object = await port.createPublicDocument();
|
|
await port.addLiteralProperty(object, object, field, value);
|
|
return object;
|
|
}
|
|
|
|
// --- both acts, through the real adapter ----------------------------------
|
|
|
|
test("the real adapter carries both acts: create, publish, add", async () => {
|
|
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
|
|
|
const article = await as("bob", async (bob) => {
|
|
const object = await publish(bob, FIELD, "2026-07-08");
|
|
await indexingOn(bob).add(index, object);
|
|
return object;
|
|
});
|
|
|
|
// The index declares its field, and Bob's bare reference is waiting in its inbox.
|
|
// That is the whole of what these two acts do; making an entry of that reference
|
|
// is the business of the layer below, and nothing here can do it or ask for it.
|
|
expect(world.contentsOf(index)).toEqual([
|
|
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
|
]);
|
|
expect(world.depositsIn(index)).toEqual([{ from: "bob", payload: article, ts: 1 }]);
|
|
});
|
|
|
|
test("two indexes are two documents, each owned by whoever created it", async () => {
|
|
const [first, second] = await as("alice", async (alice) => [
|
|
await indexingOn(alice).create(FIELD),
|
|
await indexingOn(alice).create(FIELD),
|
|
]);
|
|
expect(first).not.toBe(second);
|
|
|
|
// A stranger holding the NURI still cannot write it — reaching is not owning.
|
|
await expect(
|
|
as("bob", (bob) => bob.addLiteralProperty(first, first, INDEX_FIELD, "urn:forged")),
|
|
).rejects.toThrow(/only a document's owner writes to it/);
|
|
});
|
|
|
|
// --- the inbox, from both sides -------------------------------------------
|
|
|
|
test("a document whose owner never opened an inbox REFUSES the deposit", async () => {
|
|
const orphan = await as("alice", (alice) => alice.createPublicDocument());
|
|
// A deposit that vanishes without an error is worse than a refusal.
|
|
await expect(as("bob", (bob) => bob.depositTo(orphan, "did:ng:o:x"))).rejects.toThrow(
|
|
/has no inbox/,
|
|
);
|
|
});
|
|
|
|
test("anyone may hand a reference to an index they do not own", async () => {
|
|
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
|
await as("bob", async (bob) => indexingOn(bob).add(index, "did:ng:o:some-object"));
|
|
|
|
// Bob needed no permission and got no write, and the deposit carries who made it.
|
|
const waiting = world.depositsIn(index);
|
|
expect(waiting?.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
|
|
expect(waiting?.[0]?.from).toBe("bob");
|
|
expect(world.contentsOf(index)).toEqual([
|
|
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
|
]);
|
|
});
|
|
|
|
// --- what the adapter actually wrote --------------------------------------
|
|
|
|
test("creating an index writes its field declaration, and nothing else", async () => {
|
|
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
|
expect(world.contentsOf(index)).toEqual([
|
|
{ subject: index, predicate: INDEX_FIELD, values: [FIELD] },
|
|
]);
|
|
// …and an ordinary public document is left exactly as it was made.
|
|
const ordinary = await as("alice", (alice) => alice.createPublicDocument());
|
|
expect(world.contentsOf(ordinary)).toEqual([]);
|
|
});
|
|
|
|
test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => {
|
|
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
|
|
const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate");
|
|
const last = writes.at(-1);
|
|
expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`);
|
|
// No `GRAPH <…>` inside the statement — the anchor already scopes the write.
|
|
expect(String(last?.args[1])).not.toContain("GRAPH");
|
|
expect(last?.args[2]).toBe(index);
|
|
});
|
|
|
|
test("a hostile value lands as ONE inert literal, not as a second statement", async () => {
|
|
const HOSTILE = '" } ; DROP GRAPH <did:ng:o:doc-1> ; INSERT DATA { <a> <b> "c';
|
|
// A field is caller-supplied and goes straight into the statement, so it is the
|
|
// value this package really does have to survive.
|
|
const index = await as("alice", async (alice) => indexingOn(alice).create(HOSTILE));
|
|
|
|
// Semantic, not a quote count: the value round-trips through a parser that would
|
|
// have refused the query outright had the literal closed early — and had it
|
|
// closed early and still parsed, the extra subject would show up here.
|
|
expect(world.contentsOf(index)).toEqual([
|
|
{ subject: index, predicate: INDEX_FIELD, values: [HOSTILE] },
|
|
]);
|
|
});
|
|
|
|
// --- the two blanket checks -----------------------------------------------
|
|
|
|
test("nothing the adapter sent the polyfill, in any method, carries a destructive form", () => {
|
|
// Over EVERY recorded call, not a chosen few, and after blanking SPARQL literals
|
|
// so a keyword inside one reads as the inert text it is. If escaping ever breaks,
|
|
// the injected statement lands outside a literal and survives the blanking —
|
|
// which is the point of doing it this way rather than skipping queries wholesale.
|
|
const strings = world.calls.flatMap((call) =>
|
|
call.args.flatMap((arg) => (typeof arg === "string" ? [arg] : [])),
|
|
);
|
|
expect(world.calls.length).toBeGreaterThan(0); // not vacuously true
|
|
expect(strings.length).toBeGreaterThan(0);
|
|
for (const value of strings) {
|
|
expect(blankLiterals(value)).not.toMatch(DESTRUCTIVE);
|
|
}
|
|
});
|
|
|
|
test("the port the entry point builds has NO operation that reads or removes", async () => {
|
|
// Building it must not touch the broker: `indexing(sessionId)` builds one for every
|
|
// handle, and only the calls on it talk to anything. This check lived on the
|
|
// published surface while `polyfillPort` was published; the port is internal now,
|
|
// and the properties it guards are not.
|
|
const { polyfillPort } = await import("../src/polyfill-adapter");
|
|
const port = polyfillPort({ sessionId: "session:alice" });
|
|
expect(typeof port.createPublicDocument).toBe("function");
|
|
expect(typeof port.addLiteralProperty).toBe("function");
|
|
const forbidden = Object.keys(port).filter((name) =>
|
|
/delete|remove|clear|drop|read|list|watch/i.test(name),
|
|
);
|
|
expect(forbidden).toEqual([]);
|
|
});
|
|
|
|
test("LAST — every method the adapter exposes was driven above", async () => {
|
|
// Read off the adapter itself, so there is no list to remember to extend: add a
|
|
// fifth method and this fails until something above actually calls it. That is
|
|
// the whole answer to "the gate proves the methods it drives" — it now names them
|
|
// from the code rather than from a test's memory.
|
|
const { polyfillPort } = await import("../src/polyfill-adapter");
|
|
const exposed = Object.keys(polyfillPort({ sessionId: "session:alice" }));
|
|
expect(exposed.length).toBeGreaterThan(0);
|
|
expect(exposed.filter((method) => !driven.has(method)).sort()).toEqual([]);
|
|
});
|