/** * reload-own-document.test.ts — the creator, after a page RELOAD, meets its own document. * * ── The report this reproduces ──────────────────────────────────────────── * *"Create an object, refresh the browser, and the creator is denied access to the object * it just created."* * * A page reload is not a subtle event: every module-level cache the library holds is gone * (the caps registry among them), and the durable wallet is exactly as the first session * left it. What survives is what was WRITTEN — the scope index's Main branch (`contains`), * its Store branch (`readCap`, the emulated `AddRepo`), and the document's own triples. * * So the question this suite asks is: after `ensureIdentity()` has connected the same * identity over the same wallet, what does the creator get when it goes back to the * document it made? And it asks it three ways, because three different defects hide behind * the word "denied": * * - the document is ABSENT from the listing; * - it is listed, and reading it returns NOTHING; * - reading it is REFUSED with an error. * * …and for all three scopes, because a document in a public store serves its key to * whoever asks (`emulated-verifier/public-store.ts`) while a protected or private one * does not — so the scope is exactly the variable that decides. * * ── How the reload is simulated ─────────────────────────────────────────── * By dropping every piece of the library's module state ({@link reload}) while the fake * broker keeps its quads. Nothing is hand-planted: the second session starts from an * empty cap registry and an unresolved shim, and has to find its way back through the * pointer, the doc-shim and the account record exactly as a fresh page does. */ import { test, expect, describe, mock, afterAll, beforeEach } from "bun:test"; import { configure, docs, storeRegistry, readUnion } from "../src/index"; import { configureStoreRegistry, setCurrentUser, resetCaps, resetConfig, resetStoreRegistry, getCaps, } from "../src/shared-wallet/bootstrap"; import { resetRegistryCache } from "../src/shared-wallet/account-registry"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store"; import { connectedUser } from "../src/emulated-verifier/connect"; import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types"; const SESSION: RegistrySession = { sessionId: "sid-reload", privateStoreId: "PRIV-RELOAD" }; const SHIM = "urn:ng-eventually:shim"; const INBOX = "urn:ng-eventually:inbox"; const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; /** The application's own predicate — the note's title, in the document it created. */ const TITLE = "urn:test:title"; const ALL_SCOPES: Scope[] = ["public", "protected", "private"]; // --- the fake broker ------------------------------------------------------- interface Quad { g: string; s: string; p: string; o: string; } /** Reverse of the lib's `escapeLiteral`: one 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; } interface Token { kind: "term" | "sep"; value: string; } /** * Tokenize a triple body into IRIs, literals, `a`, `;` and `.`. * * A tokenizer rather than one regex per known query shape: the reload path issues * writes from six different modules, and a per-shape fake would answer only the shapes * whoever wrote it happened to think of — which is how a fake ends up green on a state * the library never reaches. */ function tokenize(body: string): Token[] { const re = /<([^>]*)>|"((?:[^"\\]|\\.)*)"|(;)|(\.)|\ba\b/g; const out: Token[] = []; let m: RegExpExecArray | null; while ((m = re.exec(body)) !== null) { if (m[1] !== undefined) out.push({ kind: "term", value: m[1] }); else if (m[2] !== undefined) out.push({ kind: "term", value: unescapeLiteral(m[2]) }); else if (m[3] !== undefined) out.push({ kind: "sep", value: ";" }); else if (m[4] !== undefined) out.push({ kind: "sep", value: "." }); else out.push({ kind: "term", value: RDF_TYPE }); } return out; } /** ` p o ; p o . p o` → the triples it carries. */ function parseTriples(body: string): Array<{ s: string; p: string; o: string }> { const toks = tokenize(body); const out: Array<{ s: string; p: string; o: string }> = []; let subject: string | null = null; let i = 0; while (i < toks.length) { const t = toks[i]!; if (t.kind === "sep") { if (t.value === ".") subject = null; i += 1; continue; } if (subject === null) { subject = t.value; i += 1; continue; } const p = toks[i]; const o = toks[i + 1]; if (!p || !o || p.kind === "sep" || o.kind === "sep") break; out.push({ s: subject, p: p.value, o: o.value }); i += 2; } return out; } /** * A quad-store fake `ng`. It holds the wallet; the library holds nothing across a * {@link reload}, which is the whole point. * * No `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the unit-fake path * (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly. That is a * limit of the fake broker, not a library state — the defect under test is about keys, * not about bringing a repo into the session. */ function makeWallet(quads: Quad[] = []) { let docCounter = 0; const doc_create = mock(async () => `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; const del = query.match(/DELETE\s+WHERE\s*\{([\s\S]*)\}/i); if (del) { const pattern = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/); if (pattern && anchor !== undefined) { for (let i = quads.length - 1; i >= 0; i--) { const q = quads[i]!; if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1); } } return undefined; } const wrapped = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/); const g = wrapped ? wrapped[1]! : anchor; if (g === undefined) return undefined; const body = wrapped ? wrapped[2]! : query.replace(/^[\s\S]*?INSERT\s+DATA\s*\{/i, "").replace(/\}\s*$/, ""); for (const t of parseTriples(body)) quads.push({ g, ...t }); return undefined; }); const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; const wrapped = query.match(/GRAPH\s+<([^>]+)>/); const g = wrapped ? wrapped[1]! : anchor; const inGraph = quads.filter((q) => q.g === g); // The whole-document read (`read-model.readDoc`). if (/SELECT\s+\?s\s+\?p\s+\?o/.test(query)) { return { results: { bindings: inGraph.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o }, })), }, }; } // The account record — several predicates on one subject. if (query.includes(`<${SHIM}:docPublic>`)) { const subjM = query.match(/<([^>]+)>\s+a\s+<[^>]*:Account>/); const only = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of inGraph) { if (only !== null && q.s !== only) 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 !== undefined) .map((r) => ({ id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, })); return { results: { bindings } }; } // An inbox's deposits — several predicates on one subject, `from` optional. if (query.includes(`<${INBOX}:payload>`)) { const bySubject = new Map>(); for (const q of inGraph) { 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 } }; } // Everything else the library reads is one bound subject, one bound predicate, // one variable: the pointer, the store index, the Store/User/Header branches, // the inbox index and its owner. const one = query.match(/<([^>]+)>\s+<([^>]+)>\s+\?(\w+)/); if (one) { const bindings = inGraph .filter((q) => q.s === one[1] && q.p === one[2]) .map((q) => ({ [one[3]!]: { value: q.o } })); return { results: { bindings } }; } return { results: { bindings: [] } }; }); return { doc_create, sparql_update, sparql_query, _quads: quads }; } // --- the page -------------------------------------------------------------- function boot(quads: Quad[]): ReturnType { const ng = makeWallet(quads); configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); return ng; } /** * A page RELOAD: everything the library holds in module scope goes, the wallet stays. * * Each call here drops one module's state, and together they are all of it — the config * and the captured session, the registry caches (accounts, the resolved doc-shim, the * inbox index), the opened repos, the outer-overlay memo, the caps, and who is connected. * A fresh page has none of them, so neither does the session that follows. */ function reload(quads: Quad[]): ReturnType { setCurrentUser(null); resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); resetPublicStoreFetches(); resetCaps(); return boot(quads); } /** Sign in and let the connection work finish — what `ensureIdentity()` awaits. */ async function signIn(id: string): Promise { setCurrentUser(id); await connectedUser(); } /** The first visit: sign in, create the note, write its title into it. */ async function firstVisit(quads: Quad[], scope: Scope): Promise { boot(quads); await signIn("alice"); const note = await storeRegistry.createEntityDoc(scope); await docs.sparqlUpdate( SESSION.sessionId, `INSERT DATA { <${note}> <${TITLE}> "the note" }`, note, "writeEntity", ); return note; } beforeEach(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); resetPublicStoreFetches(); resetCaps(); }); afterAll(() => { setCurrentUser(null); resetConfig(); resetStoreRegistry(); resetRegistryCache(); resetOpenedRepos(); resetPublicStoreFetches(); resetCaps(); }); // --- the reproduction ------------------------------------------------------ describe("after a reload, the creator comes back to its own document", () => { // Shape 1 — is it even there? The listing is the path the applicative journey takes, // and it is also the path that REFILES the caps, so it is asked first and alone. for (const scope of ALL_SCOPES) { test(`[${scope}] it is still LISTED among my documents`, async () => { const quads: Quad[] = []; const note = await firstVisit(quads, scope); reload(quads); await signIn("alice"); expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note); }); } // Shape 2 — listed, but does reading it answer? `readUnion` is the library's own // listing read, and it DROPS a document whose cap the reader does not hold: a failure // that arrives as an absence, which is the family this project has been bitten by. for (const scope of ALL_SCOPES) { test(`[${scope}] reading it by reference answers its content`, async () => { const quads: Quad[] = []; const note = await firstVisit(quads, scope); reload(quads); await signIn("alice"); // The application kept the reference (a route, a deep link) and reads it directly, // without listing its store first. const subjects = await readUnion([note]); expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]); }); } // Shape 3 — is it REFUSED? The guarded passage point, which is what an application // reaches through `docs.*` and what every read of the library goes through. for (const scope of ALL_SCOPES) { test(`[${scope}] reading it through the guarded surface is not refused`, async () => { const quads: Quad[] = []; const note = await firstVisit(quads, scope); reload(quads); await signIn("alice"); await docs.sparqlQuery( SESSION.sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, note, "readDoc", ); }); } // …and the same question for a WRITE: editing the note one made is the other half of // "denied access to the object it just created". for (const scope of ALL_SCOPES) { test(`[${scope}] writing to it again is not refused`, async () => { const quads: Quad[] = []; const note = await firstVisit(quads, scope); reload(quads); await signIn("alice"); await docs.sparqlUpdate( SESSION.sessionId, `INSERT DATA { <${note}> <${TITLE}> "edited" }`, note, "writeEntity", ); }); } // What the connection itself restored, stated as a fact rather than inferred from the // symptom: does the reconnected session HOLD the key of the document it created? for (const scope of ALL_SCOPES) { test(`[${scope}] the reconnected session holds its key`, async () => { const quads: Quad[] = []; const note = await firstVisit(quads, scope); reload(quads); await signIn("alice"); expect(getCaps().capFor(note)).toBeDefined(); }); } });