d39b12885a
Inbox deposits and the per-(account,scope) index append were written into an explicit GRAPH <plainNuri> named graph, which the real broker stores separately from the repo's default graph (repo_graph_name with overlay suffix) — so an anchored default-graph read (read-model.readDoc) never saw them and deposits did not round-trip. Drop the GRAPH wrapper: the anchor scopes the write to the repo's default graph, matching the read. Mocks updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
9.3 KiB
TypeScript
234 lines
9.3 KiB
TypeScript
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
import { post, read, materialize, watch } from "../src/inbox";
|
|
import type { Deposit } from "../src/inbox";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
setCurrentUser,
|
|
} from "../src/polyfill";
|
|
import type { RegistrySession } from "../src/store-registry";
|
|
|
|
// This suite injects a fake `ng` via configure() and reuses the storeRegistry's
|
|
// injected session provider (inbox docs live in the shared wallet). Restore the
|
|
// un-configured state at the end so docs.test.ts's guard still sees null config.
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
// NOTE ORDER: the "not configured → throw" case runs first — it exercises the
|
|
// registry-deps guard before any configureStoreRegistry() call.
|
|
|
|
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
|
resetStoreRegistry();
|
|
await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow(
|
|
/configureStoreRegistry\(\) must be called before use/,
|
|
);
|
|
await expect(read("did:ng:o:inbox")).rejects.toThrow(
|
|
/configureStoreRegistry\(\) must be called before use/,
|
|
);
|
|
});
|
|
|
|
// --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read
|
|
// SELECT over an in-memory quad store.
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
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;
|
|
}
|
|
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
|
|
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
|
|
|
|
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
|
|
//
|
|
// The REAL broker keys triples by the ANCHORED repo's default graph, not by an
|
|
// explicit `GRAPH <…>` IRI (repo_graph_name(repo_id, overlay_id)). So this mock
|
|
// keys stored quads by the ANCHOR arg (a[2]) — the default graph of the anchored
|
|
// repo — and REJECTS any explicit `GRAPH <…>` wrapper, so the old wrong shape
|
|
// does NOT round-trip and can never regress silently.
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
|
|
if (!anchor) return undefined;
|
|
const g = anchor;
|
|
const 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);
|
|
// predicate/object pairs: `a <type>` or `<p> "literal"`.
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish
|
|
// Un-escape the SPARQL literal so payload JSON round-trips. Single pass
|
|
// over `\x` sequences (reverses the lib's escapeLiteral without the
|
|
// double-processing that chained .replace() would cause).
|
|
const rawLit = m[2];
|
|
const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? "");
|
|
quads.push({ g, s, p, o });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const anchor = a[3] as string | undefined;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (q.p === `${INBOX}:Deposit`) {
|
|
// rdf:type marker — ensure the subject exists.
|
|
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 } };
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
|
}
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
|
const TARGET = "did:ng:o:host-inbox";
|
|
|
|
function inject() {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION });
|
|
setCurrentUser(null);
|
|
return ng;
|
|
}
|
|
|
|
let fake: ReturnType<typeof makeFakeNg>;
|
|
beforeEach(() => {
|
|
fake = inject();
|
|
});
|
|
|
|
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
|
|
setCurrentUser("alice"); // `from` is bound to the current identity
|
|
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
|
|
expect(fake.sparql_update).toHaveBeenCalledTimes(1);
|
|
const call = fake.sparql_update.mock.calls[0]!;
|
|
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
|
|
expect(call[2]).toBe(TARGET); // anchored to the target inbox
|
|
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
|
|
// wrapper (which the real broker would route to a phantom graph).
|
|
expect(call[1] as string).not.toContain("GRAPH <");
|
|
});
|
|
|
|
test("post → read round-trips payload, from and ts", async () => {
|
|
setCurrentUser("alice"); // `from` is bound to the current identity
|
|
await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
|
|
const deposits = await read(TARGET);
|
|
expect(deposits).toHaveLength(1);
|
|
expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
|
|
});
|
|
|
|
// (c) `from` is BOUND to the current identity — a spoof (naming another
|
|
// principal) is REJECTED; identifying as self or anonymous (null) is allowed.
|
|
test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => {
|
|
setCurrentUser("alice");
|
|
// SPOOF: alice tries to deposit AS bob → rejected.
|
|
await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow(
|
|
/spoof|current identity/i,
|
|
);
|
|
// Identifying as self → allowed.
|
|
await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 });
|
|
// Explicit anonymous → allowed.
|
|
await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 });
|
|
const froms = (await read(TARGET)).map((d) => d.from);
|
|
expect(froms).toEqual(["alice", null]);
|
|
});
|
|
|
|
test("from is optional — omitting it defaults to the current user", async () => {
|
|
setCurrentUser("bob");
|
|
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
|
|
const deposits = await read(TARGET);
|
|
expect(deposits[0]!.from).toBe("bob");
|
|
});
|
|
|
|
test("from: null makes an anonymous deposit even when a current user is set", async () => {
|
|
setCurrentUser("bob");
|
|
await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 });
|
|
const deposits = await read(TARGET);
|
|
expect(deposits[0]!.from).toBeNull();
|
|
});
|
|
|
|
test("read returns deposits sorted by ts ascending and materialize is an alias", async () => {
|
|
await post(TARGET, { from: null, payload: "second", ts: 300 });
|
|
await post(TARGET, { from: null, payload: "first", ts: 100 });
|
|
await post(TARGET, { from: null, payload: "third", ts: 500 });
|
|
const deposits = await materialize(TARGET);
|
|
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
|
|
});
|
|
|
|
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
|
|
await post(TARGET, { from: null, payload: "mine", ts: 1 });
|
|
await post("did:ng:o:other-inbox", { from: null, payload: "theirs", ts: 2 });
|
|
const deposits = await read(TARGET);
|
|
expect(deposits.map((d) => d.payload)).toEqual(["mine"]);
|
|
});
|
|
|
|
test("payload with quotes/newlines/backslashes survives the round-trip", async () => {
|
|
const payload = { text: 'a "quoted"\nline\\path\ttab' };
|
|
await post(TARGET, { from: null, payload, ts: 1 });
|
|
const deposits = await read(TARGET);
|
|
expect(deposits[0]!.payload).toEqual(payload);
|
|
});
|
|
|
|
test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => {
|
|
const seen: Deposit[][] = [];
|
|
const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 });
|
|
// Give the immediate tick a chance to run (empty inbox → still fires once).
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(seen.length).toBeGreaterThanOrEqual(1);
|
|
expect(seen[seen.length - 1]).toEqual([]);
|
|
|
|
await post(TARGET, { from: null, payload: "x", ts: 1 });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const last = seen[seen.length - 1]!;
|
|
expect(last.map((d) => d.payload)).toEqual(["x"]);
|
|
|
|
stop();
|
|
const countAfterStop = seen.length;
|
|
await post(TARGET, { from: null, payload: "y", ts: 2 });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe
|
|
});
|