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.
This commit is contained in:
Sylvain Duchesne
2026-08-17 09:23:24 +02:00
parent 469346aef3
commit 75378fc5a4
7 changed files with 834 additions and 155 deletions
+11 -19
View File
@@ -78,17 +78,12 @@ test("isIriSafe agrees with escapeIri on what needs encoding", () => {
// --- the one statement this package writes --------------------------------
test("buildInsertTriple writes an anchored INSERT DATA and nothing else", () => {
expect(
buildInsertTriple(
"did:ng:o:index",
"did:ng:o:object",
"urn:ng-helpers:index:value",
"2026-01-02",
),
).toBe(
"INSERT DATA { GRAPH <did:ng:o:index> " +
'{ <did:ng:o:object> <urn:ng-helpers:index:value> "2026-01-02" } }',
test("buildInsertTriple writes the anchored INSERT DATA, with NO GRAPH clause", () => {
// The document is named ONCE, as `docs.sparqlUpdate`'s anchor, never inside the
// statement — the polyfill's canonical shape (`src/surface/inbox.ts`). The builder
// takes no graph parameter at all, so the statement cannot name a second one.
expect(buildInsertTriple("did:ng:o:object", "urn:ng-helpers:index:value", "2026-01-02")).toBe(
'INSERT DATA { <did:ng:o:object> <urn:ng-helpers:index:value> "2026-01-02" }',
);
});
@@ -106,31 +101,28 @@ function unescapedQuotes(query: string): number {
test("buildInsertTriple neutralises a breakout attempt in the value", () => {
const query = buildInsertTriple(
"did:ng:o:index",
"did:ng:o:object",
"urn:ng-helpers:index:value",
'" } } ; DROP ALL ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "c',
'" } ; DROP ALL ; INSERT DATA { <a> <b> "c',
);
// The attack text survives as TEXT, which is fine — what matters is that it
// cannot leave the literal. Exactly two quotes are real delimiters: the ones
// this builder wrote. Every quote in the value is escaped, so the `DROP ALL`
// and the second `INSERT DATA` are inert characters, not statements.
expect(unescapedQuotes(query)).toBe(2);
expect(query.startsWith('INSERT DATA { GRAPH <did:ng:o:index> { <did:ng:o:object> ')).toBe(true);
expect(query.endsWith('" } }')).toBe(true);
expect(query.startsWith("INSERT DATA { <did:ng:o:object> ")).toBe(true);
expect(query.endsWith('" }')).toBe(true);
});
test("buildInsertTriple neutralises a breakout attempt in the subject", () => {
const query = buildInsertTriple(
"did:ng:o:index",
"did:ng:o:a> <urn:evil> <urn:x",
"urn:ng-helpers:index:value",
"v",
);
// The injected angle brackets are percent-encoded, so the subject stays one IRI.
expect(query).toBe(
"INSERT DATA { GRAPH <did:ng:o:index> " +
'{ <did:ng:o:a%3E%20%3Curn:evil%3E%20%3Curn:x> <urn:ng-helpers:index:value> "v" } }',
'INSERT DATA { <did:ng:o:a%3E%20%3Curn:evil%3E%20%3Curn:x> <urn:ng-helpers:index:value> "v" }',
);
});
@@ -140,7 +132,7 @@ test("every builder emits an INSERT and nothing else", async () => {
for (const [name, exported] of Object.entries(sparql)) {
if (typeof exported !== "function" || !name.startsWith("build")) continue;
const builder = exported as (...args: string[]) => unknown;
emitted.push(String(builder("did:ng:o:g", "did:ng:o:s", "urn:p", "v")));
emitted.push(String(builder("did:ng:o:s", "urn:p", "v")));
}
expect(emitted.length).toBeGreaterThan(0); // not vacuously true
for (const query of emitted) {