import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { ensureAccount, allAccounts, loadShim, resolveWriteGraph, resolveReadGraphs, resolveScopeGraph, resolveInboxAnchor, createEntityDoc, listEntityDocs, resetRegistryCache, } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig, } from "../src/polyfill"; // This suite injects a fake `ng` via configure(); bun runs test files in a // shared process with a single module singleton, and may run this file BEFORE // docs.test.ts's order-dependent "not configured" guard. Restore the un- // configured state when we're done so that guard still sees a null config. afterAll(() => { resetConfig(); resetStoreRegistry(); }); // NOTE ORDER: the "not configured → throw" case MUST run first — configure*() // sets module-level singletons and this suite never fully un-injects the real // `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard. test("throws a clear error when configureStoreRegistry() was not called", async () => { resetStoreRegistry(); resetRegistryCache(); await expect(ensureAccount("alice")).rejects.toThrow( /configureStoreRegistry\(\) must be called before use/, ); }); // --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory // quad store: INSERT DATA parsing + the two SELECT shapes the registry issues. interface Quad { g: string; s: string; p: string; o: string } /** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */ function unescapeLiteral(s: string): string { let out = ""; for (let i = 0; i < s.length; i++) { if (s[i] === "\\" && i + 1 < s.length) { const next = s[++i]; out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next; } else { out += s[i]; } } return out; } function makeFakeNg() { const quads: Quad[] = []; let docCounter = 0; const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); // Parses `INSERT DATA { GRAPH {

"o"//;-lists } }`. The literal // pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote // inside a value does NOT terminate the literal — this is what proves the // injection escaping keeps the query well-formed. const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); if (!gm) return undefined; const g = gm[1]!; const body = gm[2]!; // Subject is the first <...> token in the body. const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; // Predicate/object pairs: `

"o"` (escape-aware) or `

`; `a `. const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; let m: RegExpExecArray | null; // Skip the subject token so we don't treat it as a predicate. const after = body.slice(body.indexOf(sm[0]) + sm[0].length); while ((m = pairRe.exec(after)) !== null) { const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g, s, p, o }); } return undefined; }); const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; if (query.includes("")) { // Account SELECT, scoped to the anchor graph. const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.username) .map((r) => ({ username: { value: r.username! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, })); return { results: { bindings } }; } // Entity-index SELECT: ` ?e` in the anchor graph. const bindings = quads .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") .map((q) => ({ e: { value: q.o } })); return { results: { bindings } }; }); return { doc_create, sparql_update, sparql_query, _quads: quads }; } const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" }; function inject() { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), }); resetRegistryCache(); return ng; } let fake: ReturnType; beforeEach(() => { fake = inject(); }); test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { const rec = await ensureAccount("Alice"); expect(rec.username).toBe("Alice"); expect(fake.doc_create).toHaveBeenCalledTimes(3); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docProtected).not.toBe(rec.docPublic); expect(rec.docPrivate).not.toBe(rec.docProtected); // Persisted into the shim anchor graph (did:ng:PRIV). expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV"); }); test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => { const a = await ensureAccount("Alice"); const b = await ensureAccount("@alice"); expect(b).toEqual(a); expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6 }); test("loadShim round-trips a persisted account across a cache reset", async () => { await ensureAccount("Bob"); resetRegistryCache(); // force a re-read from the fake store const map = await loadShim(); const rec = map.get("bob"); expect(rec?.username).toBe("Bob"); expect(rec?.docPublic).toMatch(/^did:ng:o:doc/); }); test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => { const rec = await ensureAccount("Carol"); expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected); expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]); }); test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => { // Session with all three store ids: private → private store; public+protected // co-locate on the protected native store (the polyfill's Axis-A placement). const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-2", privateStoreId: "PRIV", protectedStoreId: "PROT", publicStoreId: "PUB", }), }); resetRegistryCache(); expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT"); expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located expect(await resolveInboxAnchor()).toBe("did:ng:PRIV"); }); test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => { // The default SESSION carries only privateStoreId — non-private scopes fall // back to the private store rather than emitting a broken NURI. expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV"); }); test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => { const rec = await ensureAccount("Dave"); const e1 = await createEntityDoc("dave", "public"); const e2 = await createEntityDoc("dave", "public"); const other = await createEntityDoc("dave", "protected"); // Public listing unions dave's public entities only. const pub = await listEntityDocs("public"); expect(pub.sort()).toEqual([e1, e2].sort()); const prot = await listEntityDocs("protected"); expect(prot).toEqual([other]); // The index append targets the account's public index doc. expect(rec.docPublic).toMatch(/^did:ng:o:doc/); }); test("listEntityDocs fans out across multiple accounts", async () => { await ensureAccount("Eve"); await ensureAccount("Frank"); const e = await createEntityDoc("eve", "public"); const f = await createEntityDoc("frank", "public"); expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort()); }); test("allAccounts reflects every ensured account", async () => { await ensureAccount("Gina"); await ensureAccount("Hank"); const names = (await allAccounts()).map((a) => a.username).sort(); expect(names).toEqual(["Gina", "Hank"]); }); // --- SPARQL injection hardening (F1) -------------------------------------- // // A malicious username must NOT be able to break out of the literal / IRI it // lands in and inject arbitrary triples into the shim (the account→doc trust // root). We inspect the exact SPARQL string the registry hands to sparql_update. /** The raw INSERT DATA string produced by ensureAccount for `username`. */ async function insertFor(username: string): Promise { await ensureAccount(username); const calls = fake.sparql_update.mock.calls; return calls[calls.length - 1]![1] as string; } /** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`. * Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */ function rawQuoteCount(s: string): number { const withoutEscapes = s.replace(/\\./g, ""); return (withoutEscapes.match(/"/g) ?? []).length; } test("injection: username with a quote cannot open extra literals", async () => { const evil = 'x" ; "pwn'; const update = await insertFor(evil); // A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw // quotes (the delimiters). The injected `"` must have been escaped, so the // count stays 8 — no extra literal was opened. expect(rawQuoteCount(update)).toBe(8); // The escaped username is present as a single literal value — the injected // `` survives only as INERT text inside that literal (its // surrounding quotes are escaped `\"`), never as query syntax. expect(update).toContain('"x\\" ; \\"pwn"'); }); test("injection: username with '>' cannot break out of the account-subject IRI", async () => { const evil = "x> ` — the encoded // username must NOT contain a raw `>` that would close the IRI early. const subjMatch = update.match(/]*)>/)!; expect(subjMatch).not.toBeNull(); expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E }); test("injection: newline / control chars in username are neutralised", async () => { const evil = "a\nb\tc"; const update = await insertFor(evil); // In the literal: escaped to \n / \t (no raw control char). expect(update).toContain('"a\\nb\\tc"'); // In the IRI subject: percent-encoded. const subjMatch = update.match(/]*)>/)!; expect(subjMatch[1]).toContain("%0A"); expect(subjMatch[1]).toContain("%09"); }); test("injection: '; DELETE'-style payload stays inert inside the literal", async () => { const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { "'; const update = await insertFor(evil); // The whole attack survives, escaped, as ONE literal value — the injected // `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text. expect(update).toContain(escapeLiteralRef(evil)); // Quote count stays even (all delimiters balanced; the injected `"` escaped): // 4 predicate literals → 8 raw delimiter quotes, nothing extra opened. expect(rawQuoteCount(update)).toBe(8); // The injected `"}` did not survive as raw syntax (it was escaped to `\"}`). expect(update).not.toMatch(/[^\\]"} ; DELETE/); }); // Local mirror of the lib's escapeLiteral so the assertion is self-checking. function escapeLiteralRef(v: string): string { return `"${v .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n") .replace(/\r/g, "\\r") .replace(/\t/g, "\\t")}"`; } test("injection: a malicious username still round-trips through the shim", async () => { const evil = 'eve" ; "x'; const rec = await ensureAccount(evil); expect(rec.username).toBe(evil); resetRegistryCache(); const map = await loadShim(); // The stored username came back verbatim (escaping is lossless) under its // normalized key, and exactly ONE account exists (no injected extra subject). const key = evil.trim().replace(/^@+/, "").toLowerCase(); expect(map.get(key)?.username).toBe(evil); expect(map.size).toBe(1); }); test("normalizeUser defaults to trim when not provided", async () => { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION }); resetRegistryCache(); const a = await ensureAccount(" Ivy "); const b = await ensureAccount("Ivy"); // trimmed key matches expect(b).toEqual(a); expect(ng.doc_create).toHaveBeenCalledTimes(3); });