Files
ng-helpers/test/fake-polyfill.ts
T
Sylvain Duchesne 75378fc5a4 test: rendre la suppression inexécutable plutôt que détectée
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.
2026-08-17 09:23:24 +02:00

522 lines
20 KiB
TypeScript

import { mock } from "bun:test";
import type { Nuri, UnionSubject } from "../src/port";
/**
* `@ng-eventually/polyfill` itself, in memory — NOT a mock returning constants.
*
* `fake-nextgraph.ts` stands in one layer higher, behind `NextGraphPort`, so it
* exercises the indexing rules and leaves `polyfill-adapter.ts` untouched. This
* one stands BELOW the adapter, so the real adapter runs on top of it and every
* behavioural test becomes a test of the wiring too.
*
* ## Why a real implementation and not a recorder returning `[]`
*
* The previous gate mocked `readUnion` to a constant `[]`. That single constant
* was an escape hatch: a destructive statement guarded by
* `if ((await readUnion([graph])).length > 0)` never ran, so it was never
* recorded, and the gate passed. A mock that always answers the same thing tests
* one state of the world. This answers what was actually written, so a curation
* run that adds an entry to a document already holding a descriptor DOES take the
* non-empty branch.
*
* ## Why the SPARQL is EXECUTED and not pattern-matched
*
* {@link applyInsertData} is a strict parser for the one statement this package
* writes. It is not a regex looking for `DELETE`: it is an engine that can only
* ADD, so a removal is not "detected", it is **unrunnable**. `DELETE WHERE …`
* fails at the first keyword; `INSERT DATA { … } ; DROP GRAPH <…>` fails on the
* trailing statement; and a keyword hidden inside a literal stays inside the
* literal, because a parser tokenises where a regex only matches. A split literal
* (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys
* nothing.
*
* ## Every rule below is one the polyfill actually enforces
*
* - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`);
* - anyone may DEPOSIT into a document's inbox, but reading one throws for anyone
* but its owner (`inbox.read`'s `assertOwnInbox`);
* - depositing into a document whose owner never opened an inbox THROWS
* (`inbox.postToDocument`), rather than going nowhere;
* - a document with no inbox READS as `[]` — a state, not an error
* (`depositsForDocument`);
* - opening an inbox is refused to anyone but the document's owner
* (`openDocumentInbox`);
* - `readUnion` swallows a failing document into `[]` (`readDoc`'s
* `try {…} catch { return [] }`), and may also reject outright;
* - `readUnion` builds each subject's props as a plain object literal filled by
* `(props[p] ??= []).push(o)` — inherited members and all.
*
* ## What is NOT modelled, and what happens then
*
* Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is
* absent here THROWS by name (see {@link namespace}) instead of returning
* `undefined` — so routing a query through `docs.sparqlQuery` is a red test with a
* message that says so. A brand-new TOP-LEVEL import is the one case that degrades:
* Bun's `mock.module` materialises the module namespace from own keys, so a Proxy
* there is lost and an unmodelled top-level export arrives as `undefined`. Still
* red (`undefined is not a function`), just with a duller message.
*/
// --- the deposit, as the polyfill publishes it ----------------------------
/** Mirrors `inbox.Deposit`: sender when identified, opaque payload, ms timestamp. */
interface Deposit {
readonly from: string | null;
readonly payload: unknown;
readonly ts: number;
}
/** Mirrors `inbox.PostOptions` for the two fields this package can reach. */
interface PostOptions {
readonly payload?: unknown;
readonly from?: string | null;
}
// --- the one statement this fake can execute ------------------------------
/** A triple as this package writes them: two IRIs and one string literal. */
interface Triple {
readonly subject: string;
readonly predicate: string;
readonly value: string;
}
const IRI_BREAKERS = new Set(['"', "<", ">", "\\", "^", "`", "{", "|", "}"]);
/**
* A strict reader for `INSERT DATA { <s> <p> "v" . … }` and NOTHING else.
*
* Deliberately whole-string: it consumes the query to its end, so a second
* statement smuggled after the closing brace is a parse failure rather than
* something the engine quietly ignores.
*/
class Reader {
#at = 0;
constructor(private readonly query: string) {}
refuse(what: string): never {
throw new Error(
`[fake-polyfill] refused to execute: ${what} at offset ${this.#at}. ` +
"This fake implements ONE statement — an anchored `INSERT DATA` of literal " +
"triples — because that is the only thing this package may ever emit. " +
`Query: ${JSON.stringify(this.query)}`,
);
}
#skipSpace(): void {
while (this.#at < this.query.length && /\s/.test(this.query[this.#at] ?? "")) this.#at += 1;
}
keyword(word: string): void {
this.#skipSpace();
const slice = this.query.slice(this.#at, this.#at + word.length);
const next = this.query[this.#at + word.length] ?? " ";
if (slice.toUpperCase() !== word || /[A-Za-z0-9_]/.test(next)) {
this.refuse(`expected the keyword ${word}, found ${JSON.stringify(slice)}`);
}
this.#at += word.length;
}
symbol(character: string): void {
if (!this.trySymbol(character)) this.refuse(`expected ${JSON.stringify(character)}`);
}
trySymbol(character: string): boolean {
this.#skipSpace();
if (this.query[this.#at] !== character) return false;
this.#at += 1;
return true;
}
/** An IRI between angle brackets — no character that could close it early. */
iri(): string {
this.#skipSpace();
if (this.query[this.#at] === "G" || this.query[this.#at] === "g") {
// Named after the shape it is refusing, because this one is a CONVENTION and
// not a broker limit: `packages/polyfill/e2e/` verified an anchored
// `GRAPH <plainNuri>` write round-trips upstream too. The polyfill writes the
// anchored DEFAULT graph with no wrapper and calls that the canonical,
// always-safe shape (`src/surface/inbox.ts`), so this layer writes it too —
// two layers writing the same data two ways is diagnosis work bought for later.
this.refuse(
"an explicit GRAPH clause — `docs.sparqlUpdate(sid, update, anchor)` already " +
"scopes the write to the anchor's default graph, and the polyfill writes " +
"that shape with no GRAPH wrapper",
);
}
this.symbol("<");
let out = "";
while (true) {
const character = this.query[this.#at];
if (character === undefined) this.refuse("an IRI that is never closed");
this.#at += 1;
if (character === ">") return out;
if (IRI_BREAKERS.has(character) || (character.codePointAt(0) ?? 0) <= 0x20) {
this.refuse(`${JSON.stringify(character)} inside an IRI`);
}
out += character;
}
}
/** A quoted literal, unescaped back to the string the caller passed in. */
literal(): string {
this.#skipSpace();
this.symbol('"');
let out = "";
while (true) {
const character = this.query[this.#at];
if (character === undefined) this.refuse("a literal that is never closed");
this.#at += 1;
if (character === '"') return out;
if (character !== "\\") {
out += character;
continue;
}
const escaped = this.query[this.#at];
this.#at += 1;
if (escaped === undefined) this.refuse("a trailing backslash");
const known: Record<string, string> = { n: "\n", r: "\r", t: "\t", "\\": "\\", '"': '"' };
const decoded = Object.hasOwn(known, escaped) ? known[escaped] : undefined;
if (decoded === undefined) this.refuse(`the escape \\${escaped}`);
out += decoded;
}
}
end(): void {
this.#skipSpace();
if (this.#at !== this.query.length) this.refuse("a second statement");
}
}
/** Reads the ONLY query form this package emits. Everything else throws. */
export function applyInsertData(query: string): Triple[] {
const reader = new Reader(query);
reader.keyword("INSERT");
reader.keyword("DATA");
reader.symbol("{");
const triples: Triple[] = [];
while (!reader.trySymbol("}")) {
const subject = reader.iri();
const predicate = reader.iri();
const value = reader.literal();
triples.push({ subject, predicate, value });
reader.trySymbol(".");
}
reader.end();
return triples;
}
// --- the lexical tripwire, kept as a SECOND and independent layer ---------
/**
* Blanks every SPARQL string literal, so a keyword INSIDE one reads as what it is:
* inert text. If escaping ever breaks, the injected statement lands OUTSIDE a
* literal and survives this — which is the whole point.
*/
export function blankLiterals(value: string): string {
let out = "";
let inside = false;
for (let i = 0; i < value.length; i += 1) {
const character = value[i];
if (inside && character === "\\") {
i += 1; // an escaped character can never close the literal
continue;
}
if (character === '"') {
inside = !inside;
out += '"';
continue;
}
if (!inside) out += character;
}
return out;
}
/**
* Every SPARQL 1.1 form that can destroy or displace data, plus the modifiers that
* introduce one. For a tripwire a false positive is far cheaper than a miss.
*/
export const DESTRUCTIVE = /\b(DELETE|DROP|CLEAR|MOVE|COPY|ADD|LOAD|MODIFY|WITH|SILENT)\b/i;
// --- what was called ------------------------------------------------------
export interface RecordedCall {
/** `docs.sparqlUpdate`, `inbox.postToDocument`, … */
readonly entry: string;
readonly args: readonly unknown[];
}
interface StoredDocument {
readonly nuri: Nuri;
readonly owner: string;
readonly subjects: Map<string, Map<string, string[]>>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: Deposit[] | undefined;
}
export interface FakePolyfill {
/** Every call the adapter made into the polyfill, in order. */
readonly calls: readonly RecordedCall[];
/** Acts under this identity from now on — a polyfill session IS one identity. */
signIn(user: string): void;
/** The session id for the signed-in identity, as `init`'s callback hands it over. */
sessionId(): string;
/**
* The broker can no longer answer about this document. `readDoc` catches and
* yields `[]`, so it arrives indistinguishable from an empty document — which is
* exactly the confusion `resolution.ts` exists to refuse.
*/
breakReadsOf(doc: string, reason: string): void;
healReadsOf(doc: string): void;
/** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */
breakReadUnion(reason: string): void;
healReadUnion(): void;
/** Every subject in a document, read from outside the adapter. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
}
/**
* Installs the fake in place of `@ng-eventually/polyfill` and hands back the
* handle on it. Call at the TOP of a test file: the adapter must be imported
* after this runs, so import it with `await import(…)` inside the tests.
*/
export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>();
const unreachable = new Map<string, string>();
const calls: RecordedCall[] = [];
let unionFailure: string | undefined;
let currentUser = "nobody";
let documentCount = 0;
let clock = 0;
function require(doc: string): StoredDocument {
const stored = documents.get(doc);
if (stored === undefined) throw new Error(`cannot open ${doc}`);
return stored;
}
function readOne(doc: string): UnionSubject[] {
const broken = unreachable.get(doc);
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
const stored = require(doc);
const out: UnionSubject[] = [];
for (const [subject, properties] of stored.subjects) {
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
// `(props[p] ??= []).push(o)`. Neither detail is cosmetic: the literal
// inherits from `Object.prototype`, and `??=` does NOT assign over an
// inherited truthy member, so a predicate named `constructor` or `toString`
// leaves `.push` undefined and the read THROWS.
const props: Record<string, string[]> = {};
for (const [predicate, values] of properties) {
for (const value of values) {
(props[predicate] ??= []).push(value);
}
}
out.push({ subject, graph: doc as Nuri, props });
}
return out;
}
const docsImpl = {
async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise<unknown> {
if (typeof query !== "string") throw new Error("[fake-polyfill] the query must be a string");
const writer = userOfSession(sessionId);
if (writer !== currentUser) {
// A session belongs to ONE identity upstream, so these cannot disagree.
throw new Error(
`[fake-polyfill] session of ${writer} used while ${currentUser} is signed in`,
);
}
if (typeof anchor !== "string") {
throw new Error(
"[fake-polyfill] docs.sparqlUpdate without an anchor: the write would not be " +
"scoped to a document. This package always anchors — see polyfill-adapter.ts.",
);
}
const stored = require(anchor);
if (stored.owner !== writer) {
throw new Error(
`${writer} may not write ${anchor}: only a document's owner writes to it, and ` +
"holding its read key never grants a write",
);
}
for (const triple of applyInsertData(query)) {
let properties = stored.subjects.get(triple.subject);
if (properties === undefined) {
properties = new Map();
stored.subjects.set(triple.subject, properties);
}
const values = properties.get(triple.predicate);
// An INSERT of a triple already present is a no-op in RDF: a graph is a set.
if (values === undefined) properties.set(triple.predicate, [triple.value]);
else if (!values.includes(triple.value)) values.push(triple.value);
}
return [];
},
async sparqlQuery(_sessionId: unknown, query: unknown): Promise<never> {
// Reached only if this package starts querying, which it does not: it reads
// through `readUnion`. The message splits the two reasons someone lands here,
// because one of them is an attempt to write through the read door.
if (typeof query === "string" && DESTRUCTIVE.test(blankLiterals(query))) {
throw new Error(
"[fake-polyfill] a destructive statement was routed through docs.sparqlQuery. " +
"An index only ever grows — there is no door for this. " +
`Query: ${JSON.stringify(query)}`,
);
}
throw new Error(
"[fake-polyfill] docs.sparqlQuery is not modelled — this package reads through " +
"`readUnion`. Model it READ-ONLY here before using it.",
);
},
};
const inboxImpl = {
async postToDocument(doc: unknown, options: unknown): Promise<void> {
const stored = require(String(doc));
if (stored.deposits === undefined) {
throw new Error(
`[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` +
`owner never opened one, or you cannot read the document: ${JSON.stringify(doc)}`,
);
}
const opts = (options ?? {}) as PostOptions;
// "Defaults to the current polyfill user when the property is entirely absent."
const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser;
clock += 1;
stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock });
},
async readForDocument(doc: unknown): Promise<Deposit[]> {
const stored = require(String(doc));
// No inbox → there is no address to read, which is a state and not an error.
if (stored.deposits === undefined) return [];
if (stored.owner !== currentUser) {
throw new Error(
`${currentUser} may not read the inbox of ${String(doc)}: you may DEPOSIT into ` +
"anyone's inbox, you may only READ your own",
);
}
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
};
const storeRegistryImpl = {
async createEntityDoc(scope: unknown): Promise<Nuri> {
if (scope !== "public" && scope !== "mine") {
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`);
}
documentCount += 1;
const nuri = `did:ng:o:doc-${documentCount}` as Nuri;
documents.set(nuri, { nuri, owner: currentUser, subjects: new Map(), deposits: undefined });
return nuri;
},
async openDocumentInbox(doc: unknown): Promise<string> {
const stored = require(String(doc));
if (stored.owner !== currentUser) {
throw new Error(
`${currentUser} may not open an inbox on ${String(doc)}: opening one publishes ` +
"the document's address, so a non-owner would route the owner's deposits to itself",
);
}
stored.deposits ??= [];
return `${stored.nuri}:inbox`;
},
};
async function readUnionImpl(docsLike: unknown): Promise<UnionSubject[]> {
if (unionFailure !== undefined) throw new Error(unionFailure);
const list = Array.isArray(docsLike) ? docsLike : [];
const out: UnionSubject[] = [];
for (const doc of [...new Set(list.filter(Boolean))]) {
try {
out.push(...readOne(String(doc)));
} catch (error) {
// `readDoc` is `try {…} catch { return [] }`: a failing document is skipped
// and never aborts the batch, so failure and emptiness arrive identical.
console.error("[fake-polyfill] read failed for", doc, String(error));
}
}
return out;
}
function userOfSession(sessionId: unknown): string {
const id = String(sessionId);
const user = id.startsWith("session:") ? id.slice("session:".length) : undefined;
if (user === undefined) throw new Error(`[fake-polyfill] not a session id: ${id}`);
return user;
}
function record(entry: string, args: readonly unknown[]): void {
calls.push({ entry, args });
}
/**
* A namespace whose every member is recorded, and whose UNKNOWN members throw by
* name rather than arriving as `undefined`. `Object.hasOwn` and not `impl[name]`,
* because a plain object literal inherits `toString` and friends — the same trap
* `valuesOf` guards against in `src/index-document.ts`.
*/
function namespace(name: string, impl: Record<string, (...args: never[]) => unknown>): unknown {
return new Proxy(impl, {
get(target, property) {
if (typeof property === "symbol" || property === "then") return undefined;
const entry = `${name}.${property}`;
return (...args: unknown[]) => {
record(entry, args);
if (!Object.hasOwn(target, property)) {
throw new Error(
`[fake-polyfill] ${entry} is not modelled. The adapter reached for a polyfill ` +
"entry this fake knows nothing about, so nothing here can vouch for what it " +
"does. Model it (read-only if it queries) before using it.",
);
}
return (target[property] as (...a: unknown[]) => unknown)(...args);
};
},
});
}
mock.module("@ng-eventually/polyfill", () => ({
docs: namespace("docs", docsImpl),
inbox: namespace("inbox", inboxImpl),
storeRegistry: namespace("storeRegistry", storeRegistryImpl),
async readUnion(docsLike: unknown) {
record("readUnion", [docsLike]);
return readUnionImpl(docsLike);
},
}));
return {
calls,
signIn(user: string) {
currentUser = user;
},
sessionId() {
return `session:${currentUser}`;
},
breakReadsOf(doc: string, reason: string) {
unreachable.set(doc, reason);
},
healReadsOf(doc: string) {
unreachable.delete(doc);
},
breakReadUnion(reason: string) {
unionFailure = reason;
},
healReadUnion() {
unionFailure = undefined;
},
contentsOf(doc: string) {
const stored = require(doc);
const out: { subject: string; predicate: string; values: string[] }[] = [];
for (const [subject, properties] of stored.subjects) {
for (const [predicate, values] of properties) out.push({ subject, predicate, values });
}
return out;
},
};
}