Files

165 lines
7.0 KiB
TypeScript

import { expect, test } from "bun:test";
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
import { asNuri, isNuri } from "../src/nuri";
// --- what a reference is, and what it is not ------------------------------
test("isNuri accepts a NURI and rejects everything that is not one", () => {
expect(isNuri("did:ng:o:abc-123")).toBe(true);
expect(isNuri("did:ng:")).toBe(false);
expect(isNuri("https://example.org/thing")).toBe(false);
expect(isNuri(42)).toBe(false);
expect(isNuri(null)).toBe(false);
expect(isNuri({ nuri: "did:ng:o:abc" })).toBe(false);
});
test("isNuri rejects a reference carrying characters that would break out of an IRI", () => {
expect(isNuri("did:ng:o:a> <urn:evil> ?x")).toBe(false);
expect(isNuri('did:ng:o:a"b')).toBe(false);
expect(isNuri("did:ng:o:a\nb")).toBe(false);
});
test("asNuri throws on a non-reference rather than passing it on", () => {
expect(() => asNuri("nope")).toThrow(/not a NURI/);
});
// --- escaping -------------------------------------------------------------
test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => {
const attack = '" } ; DROP ALL ; INSERT DATA { GRAPH <urn:evil> { <a> <b> "';
const escaped = escapeLiteral(attack);
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
});
test("escapeLiteral escapes the backslash before the quote, so neither is undone", () => {
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
});
test("escapeLiteral escapes the whitespace controls", () => {
expect(escapeLiteral("a\nb\rc\td")).toBe("a\\nb\\rc\\td");
});
test("escapeIri percent-encodes every character that could close an IRI", () => {
expect(escapeIri("a>b")).toBe("a%3Eb");
expect(escapeIri("a<b")).toBe("a%3Cb");
expect(escapeIri('a"b')).toBe("a%22b");
expect(escapeIri("a b")).toBe("a%20b");
expect(escapeIri("a\nb")).toBe("a%0Ab");
expect(escapeIri("a\\b")).toBe("a%5Cb");
expect(escapeIri("a{b}c|d^e`f")).toBe("a%7Bb%7Dc%7Cd%5Ee%60f");
});
test("escapeIri leaves the characters a NURI is actually made of alone", () => {
expect(escapeIri("did:ng:o:AbC-123_x.y")).toBe("did:ng:o:AbC-123_x.y");
});
test("escapeIri leaves printable non-ASCII alone, since encoding it would corrupt it", () => {
expect(escapeIri("élan")).toBe("élan");
});
test("isIriSafe agrees with escapeIri on what needs encoding", () => {
expect(isIriSafe("did:ng:o:abc")).toBe(true);
expect(isIriSafe("did:ng:o:a b")).toBe(false);
expect(isIriSafe("did:ng:o:a>b")).toBe(false);
});
// --- the one statement this package writes --------------------------------
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" }',
);
});
/** Quotes that are NOT preceded by an odd run of backslashes — i.e. real delimiters. */
function unescapedQuotes(query: string): number {
let count = 0;
for (let i = 0; i < query.length; i += 1) {
if (query[i] !== '"') continue;
let backslashes = 0;
for (let j = i - 1; j >= 0 && query[j] === "\\"; j -= 1) backslashes += 1;
if (backslashes % 2 === 0) count += 1;
}
return count;
}
test("buildInsertTriple neutralises a breakout attempt in the value", () => {
const query = buildInsertTriple(
"did:ng:o:object",
"urn:ng-helpers:index:value",
'" } ; 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 { <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: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 { <did:ng:o:a%3E%20%3Curn:evil%3E%20%3Curn:x> <urn:ng-helpers:index:value> "v" }',
);
});
test("every builder emits an INSERT and nothing else", async () => {
const sparql: Record<string, unknown> = await import("../src/sparql");
const emitted: string[] = [];
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:s", "urn:p", "v")));
}
expect(emitted.length).toBeGreaterThan(0); // not vacuously true
for (const query of emitted) {
expect(query.startsWith("INSERT DATA")).toBe(true);
}
});
test("a tripwire: no destructive SPARQL keyword is written anywhere under src/", async () => {
// SECONDARY, and worth being honest about what it is worth. A pattern over
// SOURCE can always be out-written, and an earlier version of this test was:
// four evasions passed it with the whole suite green — `COPY DEFAULT TO GRAPH`,
// a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
// literal split across concatenated lines.
//
// What actually guards the invariant is `test/adapter.test.ts`,
// which RUNS the adapter and reads back every query it emits; all four evasions
// fail there. This stays as a cheap tripwire that catches the obvious
// regression early and names the file — it is not the proof.
//
// It fails CLOSED: a trailing `// … DROP GRAPH …` comment trips it. That is the
// right direction for a tripwire, and stripping trailing comments properly
// would need a tokenizer, since `//` appears inside every `http://` IRI.
const { Glob } = await import("bun");
const root = new URL("../src/", import.meta.url).pathname;
const files = await Array.fromAsync(
new Glob("**/*.{ts,mts,cts,js,mjs,cjs}").scan({ cwd: root, absolute: true }),
);
expect(files.length).toBeGreaterThan(0);
// Bare keywords, destructive and data-moving alike. For a tripwire a false
// positive is far cheaper than a miss, so this does not try to match forms.
const destructive = /\b(DELETE|DROP|CLEAR|MOVE|COPY|MODIFY|LOAD)\b/i;
const offenders: string[] = [];
for (const file of files) {
const code = (await Bun.file(file).text())
.replace(/\/\*[^]*?\*\//g, " ") // block comments
.replace(/^\s*\/\/.*$/gm, " "); // whole-line comments
if (destructive.test(code)) offenders.push(file.slice(root.length));
}
expect(offenders).toEqual([]);
});