import { expect, test } from "bun:test"; import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql"; import { asNuri, isNuri } from "../src/nuri"; import { decodeReference } from "../src/deposit"; // --- 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> ?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/); }); test("a deposit is a bare reference — anything else decodes to null", () => { expect(decodeReference("did:ng:o:doc-7")).toBe("did:ng:o:doc-7"); // The shapes a well-meaning caller might invent, all refused: the payload IS // the reference, it is not wrapped and it carries nothing else. expect(decodeReference({ object: "did:ng:o:doc-7" })).toBeNull(); expect(decodeReference({ assert: "published", object: "did:ng:o:doc-7" })).toBeNull(); expect(decodeReference(["did:ng:o:doc-7"])).toBeNull(); expect(decodeReference("please index did:ng:o:doc-7")).toBeNull(); expect(decodeReference(null)).toBeNull(); expect(decodeReference(7)).toBeNull(); }); // --- escaping ------------------------------------------------------------- test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => { const attack = '" } ; DROP ALL ; INSERT DATA { GRAPH { "'; 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 { 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 { "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 { "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 { ")).toBe(true); expect(query.endsWith('" }')).toBe(true); }); test("buildInsertTriple neutralises a breakout attempt in the subject", () => { const query = buildInsertTriple( "did:ng:o:a> "v" }', ); }); test("every builder emits an INSERT and nothing else", async () => { const sparql: Record = 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-write-path.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([]); });