Files
ng-eventually/packages/client/test/discovery.test.ts
T
Sylvain Duchesne 42174608f8 fix(client): reserve the discovery @index account key (prevent user collision)
The special discovery-index account was keyed as "@index", which the consumer's
normalizeUsername collapses to "index" — colliding with a real user named
"index" (who could then hijack/tamper the global index document). Introduce a
reserved-account namespace (a sentinel key unreachable by any typed username) so
the index account can never collide with user input. Test proves a user named
"index"/"@index" resolves to a DIFFERENT document than the reserved index
account. 80 tests pass; tsc rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:58:52 +02:00

260 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} 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);
});
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;
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 gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
if (!gm) return undefined;
const g = gm[1]!;
const body = gm[2]!;
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 });
}
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.
if (query.includes(`<${SHIM}:username>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:username`) rec.username = 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.username)
.map((r) => ({
username: { value: r.username! },
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<string, Record<string, string>>();
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<string, { value: string }> = {
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, 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();
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
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 () => {
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" };
await submitToIndex(ref, { from: "alice", ts: 100 });
await submitToIndex(ref, { from: "bob", 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();
});
test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => {
// The index account occupies a key no user input can produce: it is prefixed
// with a NUL control char, which a user cannot type into a username field and
// which no `normalizeUser` 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);
});