import { test, expect, mock, beforeEach, afterAll } from "bun:test"; import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery"; import type { IndexEntry } from "../src/discovery"; import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig, setCurrentUser, getCaps, resetCaps, } from "../src/polyfill"; import { resetRegistryCache, ensureAccount } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; // discovery.ts submits to / reads from a global index owned by a RESERVED // SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that // emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 + // shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over // a single in-memory quad store. Restore un-configured state at the end. afterAll(() => { resetConfig(); resetStoreRegistry(); setCurrentUser(null); resetCaps(); }); test("throws a clear error when configureStoreRegistry() was not called", async () => { resetStoreRegistry(); resetRegistryCache(); await expect(submitToIndex({ ref: 1 })).rejects.toThrow( /configureStoreRegistry\(\) must be called before use/, ); }); interface Quad { g: string; s: string; p: string; o: string } const SHIM = "urn:ng-eventually:shim"; const INBOX = "urn:ng-eventually:inbox"; /** 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; } // A stateful fake `ng` serving BOTH the shim and the inbox SPARQL. function makeFakeNg() { const quads: Quad[] = []; let docCounter = 0; // Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a // callback per anchor + fires an initial push; sparql_update pushes a Patch to // that anchor's subscribers, so discovery.watchIndex (now event-driven) works // without a timer. const subs = new Map void>>(); const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => { let set = subs.get(nuri); if (!set) { set = new Set(); subs.set(nuri, set); } set.add(cb); queueMicrotask(() => cb({ V0: { State: { doc: nuri } } })); return () => set!.delete(cb); }); const pushTo = (anchor: string): void => { for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } }); }; const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); const sparql_update = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[2] as string | undefined; // TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>` // (the private-store repo's graph name equals the plain store NURI → it // round-trips; key by that GRAPH IRI). The inbox deposit write has NO // explicit GRAPH — the real broker keys it by the ANCHORED repo's default // graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]). const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); let g: string; let body: string; if (gm) { g = gm[1]!; body = gm[2]!; } else { if (!anchor) return undefined; g = anchor; body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); } const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; const after = body.slice(body.indexOf(sm[0]) + sm[0].length); const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g; let m: RegExpExecArray | null; while ((m = pairRe.exec(after)) !== null) { // `a` → an rdf:type marker; the two type IRIs the modules use differ, so // pick by which body we're in (deposit vs account) — harmless if wrong, // the SELECT filters by the real predicates below. const isDeposit = query.includes(`${INBOX}:Deposit`); const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`); const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g, s, p, o }); } pushTo(g); // local-push to the written graph's subscribers return undefined; }); const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; // Shim account SELECT. Two shapes: the full scan (`?acc a `) and // the TARGETED bounded resolve (` a `), which binds one // subject — honour that subject filter so the bounded query is O(1)/exact. if (query.includes(`<${SHIM}:id>`)) { const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; if (q.p === `${SHIM}:id`) rec.id = q.o; if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o; if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.id) .map((r) => ({ id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, })); return { results: { bindings } }; } // Inbox deposit SELECT (?payload ?ts ?from). if (query.includes(`<${INBOX}:payload>`)) { const bySubject = new Map>(); for (const q of quads) { if (q.g !== anchor) continue; if (q.p === `${INBOX}:Deposit`) { if (!bySubject.has(q.s)) bySubject.set(q.s, {}); continue; } const rec = bySubject.get(q.s) ?? {}; if (q.p === `${INBOX}:payload`) rec.payload = q.o; if (q.p === `${INBOX}:ts`) rec.ts = q.o; if (q.p === `${INBOX}:from`) rec.from = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] .filter((r) => r.payload !== undefined && r.ts !== undefined) .map((r) => { const row: Record = { payload: { value: r.payload! }, ts: { value: r.ts! }, }; if (r.from !== undefined) row.from = { value: r.from }; return row; }); return { results: { bindings } }; } // Entity-index SELECT (shim contains) — unused here. return { results: { bindings: [] } }; }); return { doc_create, doc_subscribe, 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, normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. provisionRetry: { attempts: 1 }, }); resetRegistryCache(); setCurrentUser(null); return ng; } let fake: ReturnType; beforeEach(() => { fake = inject(); }); test("submitToIndex creates the @index special account on first sight (3 docs)", async () => { await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" }); // ensureAccount('@index') created its 3 scope docs. expect(fake.doc_create).toHaveBeenCalledTimes(3); // The deposit landed in the @index public document (its inbox). const depositCall = fake.sparql_update.mock.calls.find((c) => (c[1] as string).includes(`${INBOX}:Deposit`), )!; expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined(); expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI }); test("submit → read round-trips the reference as an index entry", async () => { setCurrentUser("alice"); // `from` is bound to the current identity const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" }; await submitToIndex(ref, { from: "alice", ts: 100 }); const entries = await readIndex(); expect(entries).toHaveLength(1); expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry); }); test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => { // A submits (identified). No connection is ever declared. A separate reader // materializes the SAME index (same special account → same document) and sees // the reference — discovery is via the index, not any direct fan-out/link. setCurrentUser("alice"); const ref = { nuri: "did:ng:o:evA", title: "Public event by A" }; await submitToIndex(ref, { ts: 100 }); // Reader B: a fresh cache, never connected to A, reads the index. resetRegistryCache(); setCurrentUser("bob"); const entries = await readIndex(); const refs = entries.map((e) => e.ref); expect(refs).toContainEqual(ref); expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice"); }); test("readIndex deduplicates identical references (materialization moderation point)", async () => { const ref = { nuri: "did:ng:o:dup", title: "Twice" }; // Anonymous submissions (dedup keys on the ref, not the submitter). await submitToIndex(ref, { from: null, ts: 100 }); await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference const entries = await readIndex(); expect(entries).toHaveLength(1); // surfaced once }); test("from: null makes an anonymous submission", async () => { await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 }); const entries = await readIndex(); expect(entries[0]!.from).toBeNull(); }); // (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the // world-readable discovery index; a public (or ungoverned) document is fine. test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => { resetCaps(); // A PROTECTED and a PRIVATE governed document, and a PUBLIC one. getCaps().open("did:ng:o:prot", "protected", "alice"); getCaps().open("did:ng:o:priv", "private", "alice"); getCaps().open("did:ng:o:pub", "public", "alice"); // Submitting the protected doc's NURI is REJECTED. await expect( submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }), ).rejects.toThrow(/PUBLIC|public-only|protected\/private/i); // Private too. await expect( submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }), ).rejects.toThrow(/PUBLIC|public-only|protected\/private/i); // The PUBLIC document passes. await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 }); const entries = await readIndex(); expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]); resetCaps(); }); test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => { // The index account occupies a key no consumer input can produce: it is prefixed // with a NUL control char, which a user cannot type into an id field and // which no `normalizeId` output (a typeable value) contains. So it is // disjoint from the keys "index" / "@index" a hostile user would submit. expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel expect(INDEX_ACCOUNT).not.toBe("index"); expect(INDEX_ACCOUNT).not.toBe("@index"); }); test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => { // The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who // registers as "index" or "@index" normalizes to key "index" — which must be // a DISJOINT key from the reserved index account, so they get their own // documents and cannot hijack / read-write the global index document. const indexRecord = await ensureAccount(INDEX_ACCOUNT); // A real user "index" — same normalized form as "@index". const userIndex = await ensureAccount("index"); expect(userIndex.docPublic).not.toBe(indexRecord.docPublic); expect(userIndex.docProtected).not.toBe(indexRecord.docProtected); expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate); // "@index" must land on the SAME account as "index" (both normalize to // "index") — and still NOT on the reserved index account. const userAtIndex = await ensureAccount("@index"); expect(userAtIndex.docPublic).toBe(userIndex.docPublic); expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic); }); test("watchIndex fires immediately then when a submission arrives", async () => { const seen: IndexEntry[][] = []; const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 }); await new Promise((r) => setTimeout(r, 20)); expect(seen.length).toBeGreaterThanOrEqual(1); expect(seen[seen.length - 1]).toEqual([]); await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 }); await new Promise((r) => setTimeout(r, 20)); const last = seen[seen.length - 1]!; expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched"); stop(); const countAfterStop = seen.length; await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 }); await new Promise((r) => setTimeout(r, 20)); expect(seen.length).toBe(countAfterStop); });