75378fc5a4
Trois trouvailles adverses survivaient à leur premier correctif. Elles avaient la même faiblesse : ce qui prouve le code n'exerçait pas le code. L'adaptateur n'était pas testé dans son comportement — six méthodes sur sept pouvaient être vidées avec la suite verte. Une doublure de polyfill en mémoire les exerce désormais toutes : elle RÉPOND au lieu de rendre des constantes, et elle EXÉCUTE le SPARQL, avec un analyseur qui n'accepte qu'un INSERT DATA ancré de triplets littéraux. Les six vidages ont été vérifiés rouges, entre 2 et 10 échecs chacun. Et la garde anti-suppression change de nature. Elle n'instrumentait que deux méthodes, et cinq contournements passaient — dont un littéral coupé placé dans readDeposits, qui est le chemin de la prochaine fonctionnalité. Maintenant le moteur REFUSE la requête : « expected the keyword INSERT, found DELETE ». La suppression n'est plus détectée, elle est inexécutable — ce qui ne se contourne pas par une écriture plus habile. Une nouvelle méthode est couverte par construction, sans liste à tenir : le test lit Object.keys du port. Vérifié — une huitième méthode sans pilote fait rougir. Le README disait qu'une entrée ne change jamais après sa création, alors qu'une valeur plus petite l'écrase. Remplacé par ce qui est vrai — un objet déjà indexé n'est jamais relu — avec le cas reproduit. Et « ne fait que grandir » dit désormais que la suppression n'a JAMAIS été construite, pas qu'elle a été retirée : celui qui en aura besoin doit le lire, pas le déduire d'une fonction manquante. Enfin la forme d'écriture s'aligne sur celle du polyfill — l'écriture ancrée sans clause GRAPH, le document nommé une seule fois comme ancre. buildInsertTriple ne prend plus de graphe, donc elle ne peut plus dériver.
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { indexing } from "../src/indexing";
|
|
import { ENTRY_VALUE, 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 exactly TWO of the adapter's seven
|
|
* methods — `addLiteralProperty` and (through `createIndex`) `createPublicDocument`
|
|
* and `openInbox`. Everything else was inert. Six methods 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 two methods
|
|
* emitted. A destructive statement planted in `readDeposits` was never recorded,
|
|
* because `readDeposits` was never called. That matters most exactly there:
|
|
* retiring an applied deposit — the README's own open question — lands in
|
|
* `readDeposits`.
|
|
*
|
|
* ## 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 seven and something here
|
|
* goes red, because each one is now 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;
|
|
}
|
|
|
|
// --- the whole loop, through the real adapter -----------------------------
|
|
|
|
test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
|
|
|
const article = await as("bob", async (bob) => {
|
|
const object = await publish(bob, FIELD, "2026-07-08");
|
|
await indexing(bob).refer(index, object);
|
|
return object;
|
|
});
|
|
|
|
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
|
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
|
|
|
|
const entries = await as("alice", (alice) => indexing(alice).read(index));
|
|
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
|
});
|
|
|
|
test("two indexes are two documents, each owned by whoever created it", async () => {
|
|
const [first, second] = await as("alice", async (alice) => [
|
|
await indexing(alice).createIndex(FIELD),
|
|
await indexing(alice).createIndex(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, ENTRY_VALUE, "2026-01-01")),
|
|
).rejects.toThrow(/only a document's owner writes to it/);
|
|
});
|
|
|
|
// --- the two answers a resolve may give, and why they must stay apart -----
|
|
|
|
test("a reference that could not be READ comes back unresolved", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
|
const article = await as("bob", async (bob) => {
|
|
const object = await publish(bob, FIELD, "2026-07-08");
|
|
await indexing(bob).refer(index, object);
|
|
return object;
|
|
});
|
|
|
|
// `readDoc` catches and yields `[]`, so a failed read arrives looking exactly
|
|
// like an object that holds nothing. Telling them apart is not possible; filing
|
|
// the failure as a FACT about the object is what must not happen.
|
|
world.breakReadsOf(article, "broker unreachable");
|
|
try {
|
|
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
|
expect(report.outcomes).toEqual([
|
|
{ result: "unresolved", object: article, reason: expect.stringContaining("absent") },
|
|
]);
|
|
} finally {
|
|
world.healReadsOf(article);
|
|
}
|
|
});
|
|
|
|
test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
|
const unrelated = await as("bob", async (bob) => {
|
|
const object = await publish(bob, "http://schema.org/name", "Anemone");
|
|
await indexing(bob).refer(index, object);
|
|
return object;
|
|
});
|
|
|
|
// Paired with the test above ON PURPOSE. `resolveObject` is the only thing
|
|
// keeping these two apart: replace `resolutionFromRead(subjects)` with
|
|
// `{ state: "present", subjects }` and the unreadable object above is reported
|
|
// here's answer instead — a broker failure filed as a property of the object.
|
|
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
|
expect(report.outcomes).toEqual([
|
|
{ result: "skipped", object: unrelated, reason: "no-field" },
|
|
]);
|
|
});
|
|
|
|
test("an EMPTY read is unresolved, straight off the adapter method", async () => {
|
|
// The same rule as the pair above, asserted directly rather than through a
|
|
// curation report, so the wiring of `resolutionFromRead` is held by two
|
|
// independent tests and not by one.
|
|
const blank = await as("alice", (alice) => alice.createPublicDocument());
|
|
const resolution = await as("alice", (alice) => alice.resolveObject(blank));
|
|
expect(resolution.state).toBe("unresolved");
|
|
expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable");
|
|
});
|
|
|
|
test("a read that REJECTS outright is unresolved too, and names the failure", async () => {
|
|
world.breakReadUnion("session lost");
|
|
try {
|
|
const resolution = await as("alice", (alice) => alice.resolveObject("did:ng:o:whatever"));
|
|
expect(resolution.state).toBe("unresolved");
|
|
expect(resolution.state === "unresolved" && resolution.reason).toContain("session lost");
|
|
} finally {
|
|
world.healReadUnion();
|
|
}
|
|
});
|
|
|
|
// --- 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 deposit into an index, only its owner may read what was deposited", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
|
await as("bob", (bob) => indexing(bob).refer(index, "did:ng:o:some-object"));
|
|
|
|
const own = await as("alice", (alice) => alice.readDeposits(index));
|
|
expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
|
|
expect(own[0]?.from).toBe("bob");
|
|
|
|
await expect(as("bob", (bob) => bob.readDeposits(index))).rejects.toThrow(
|
|
/may only READ your own/,
|
|
);
|
|
});
|
|
|
|
// --- what the adapter actually wrote --------------------------------------
|
|
|
|
test("readDocument returns what was written, and refuses a document that is no index", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
|
const subjects = await as("alice", (alice) => alice.readDocument(index));
|
|
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]);
|
|
|
|
const ordinary = await as("alice", (alice) => alice.createPublicDocument());
|
|
await expect(as("alice", (alice) => indexing(alice).read(ordinary))).rejects.toThrow(
|
|
/declares no index field/,
|
|
);
|
|
});
|
|
|
|
test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => {
|
|
const index = await as("alice", (alice) => indexing(alice).createIndex(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';
|
|
const doc = await as("alice", async (alice) => {
|
|
const object = await alice.createPublicDocument();
|
|
await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE);
|
|
return object;
|
|
});
|
|
|
|
// 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(doc)).toEqual([
|
|
{ subject: doc, predicate: ENTRY_VALUE, 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("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 an
|
|
// eighth 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([]);
|
|
});
|