|
|
|
@@ -0,0 +1,670 @@
|
|
|
|
|
/**
|
|
|
|
|
* Only a VERIFIED ABSENCE may mint an inbox — the whole case space of `userInbox`.
|
|
|
|
|
*
|
|
|
|
|
* `userInbox(id, scope)` answers *which inbox document does this virtual user own for this
|
|
|
|
|
* scope*, and mints one the first time. Everything downstream is addressed through that
|
|
|
|
|
* answer: `share` deposits into it, `connect.connectedUser` drains it, `isOwnInbox` guards
|
|
|
|
|
* reads with it. So the one thing it must never do is treat a failure as an absence — where
|
|
|
|
|
* `connect.ts` lost a restore for the same fault (2026-08-13), this one WRITES:
|
|
|
|
|
*
|
|
|
|
|
* - a read that could not answer, taken for "no inbox exists", MINTS a second document for
|
|
|
|
|
* one (user, scope). Two `shim:docInbox` triples then sit on the same subject and which
|
|
|
|
|
* one wins later is decided by `canonicalDoc` picking among them — the owner and a
|
|
|
|
|
* depositor can resolve different documents;
|
|
|
|
|
* - a persist that failed, followed by handing the document back anyway, gives the owner a
|
|
|
|
|
* reference whose triple was never written. Nobody else can resolve it: a depositor
|
|
|
|
|
* finding nothing mints yet another. The owner reads a box nobody writes to, depositors
|
|
|
|
|
* write to boxes nobody reads.
|
|
|
|
|
*
|
|
|
|
|
* The rule this file pins, one line: **a failure surfaces; only an absence the broker
|
|
|
|
|
* actually confirmed may mint.** Resolving from the cache, joining a run in flight, and
|
|
|
|
|
* minting on a confirmed 0 are unchanged — they are the states that are true.
|
|
|
|
|
*
|
|
|
|
|
* ── Why every branch is here, not just the interesting ones ───────────────
|
|
|
|
|
* A swallowed failure is invisible by construction, so a suite covering the happy path and
|
|
|
|
|
* one error leaves exactly the places a bug hides. Every path through the function is
|
|
|
|
|
* asserted below: the cache, a run in flight (both outcomes), each of the four collaborators
|
|
|
|
|
* it awaits before deciding (session, doc-shim, account, the read), the read answering and
|
|
|
|
|
* the read finding nothing, and each of the three writes the mint performs.
|
|
|
|
|
*
|
|
|
|
|
* ── The faults are the broker's, not the test's ───────────────────────────
|
|
|
|
|
* Every failure is injected at the `ng` boundary — a read that throws `RepoNotFound` (what
|
|
|
|
|
* the engine hard-errors when a repo is not in `self.repos`,
|
|
|
|
|
* `engine/verifier/src/request_processor.rs:264`), a write or a `doc_create` that cannot
|
|
|
|
|
* reach the broker — or at the consumer's injected `getSession`, which is the other edge of
|
|
|
|
|
* the library. Nothing reaches into the library to make one of its own functions reject: a
|
|
|
|
|
* fake that fabricates a state the real system never produces goes green while leaving the
|
|
|
|
|
* real state untested.
|
|
|
|
|
*/
|
|
|
|
|
import { test, expect, mock, afterEach } from "bun:test";
|
|
|
|
|
import { configure } from "../src/index";
|
|
|
|
|
import {
|
|
|
|
|
adoptCurrentUser,
|
|
|
|
|
configureStoreRegistry,
|
|
|
|
|
resetCaps,
|
|
|
|
|
resetConfig,
|
|
|
|
|
resetStoreRegistry,
|
|
|
|
|
setCurrentUser,
|
|
|
|
|
} from "../src/shared-wallet/bootstrap";
|
|
|
|
|
import {
|
|
|
|
|
createEntityDoc,
|
|
|
|
|
ensureAccount,
|
|
|
|
|
isKnownInbox,
|
|
|
|
|
resetRegistryCache,
|
|
|
|
|
userInbox,
|
|
|
|
|
} from "../src/shared-wallet/account-registry";
|
|
|
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
|
|
|
import {
|
|
|
|
|
documentInboxAddress,
|
|
|
|
|
openDocumentInbox,
|
|
|
|
|
} from "../src/emulated-verifier/branch-registers";
|
|
|
|
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
|
|
|
|
import type { InboxScope, Nuri } from "../src/model/types";
|
|
|
|
|
|
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-inbox", privateStoreId: "PRIV-INBOX" };
|
|
|
|
|
const SHIM = "urn:ng-eventually:shim";
|
|
|
|
|
|
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* What the broker refuses to do, and when. Every field arms a REAL failure of the
|
|
|
|
|
* corresponding platform call; `false` means "answer normally". Mutable after `inject`, so
|
|
|
|
|
* a test can build a healthy world first and only then break the one call it is about.
|
|
|
|
|
*/
|
|
|
|
|
interface Faults {
|
|
|
|
|
/** The consumer's `getSession` thunk rejects — the wallet cannot answer. */
|
|
|
|
|
session: boolean;
|
|
|
|
|
/** `doc_create` throws — the broker cannot mint a document. */
|
|
|
|
|
docCreate: boolean;
|
|
|
|
|
/** The doc-shim read that answers "which inbox does this user own" throws. */
|
|
|
|
|
inboxLookup: boolean;
|
|
|
|
|
/** The shim write that records "this NURI IS an inbox" throws. */
|
|
|
|
|
inboxRecord: boolean;
|
|
|
|
|
/** The shim write that associates the inbox with its (user, scope) throws. */
|
|
|
|
|
inboxPersist: boolean;
|
|
|
|
|
/** The store-root read that answers "where is the doc-shim" throws. */
|
|
|
|
|
pointerRead: boolean;
|
|
|
|
|
/** The store-root write that publishes the doc-shim's address throws. */
|
|
|
|
|
pointerWrite: boolean;
|
|
|
|
|
/** The doc-shim read that answers "does this account exist" throws. */
|
|
|
|
|
accountLookup: boolean;
|
|
|
|
|
/** The doc-shim write that records an account's three scope documents throws. */
|
|
|
|
|
accountRecord: boolean;
|
|
|
|
|
/** The User-branch write that records "I opened this document's inbox" throws. */
|
|
|
|
|
inboxCapPersist: boolean;
|
|
|
|
|
/** The Header-branch write that publishes WHERE to deposit for a document throws. */
|
|
|
|
|
addressPublish: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noFaults(): Faults {
|
|
|
|
|
return {
|
|
|
|
|
session: false,
|
|
|
|
|
docCreate: false,
|
|
|
|
|
inboxLookup: false,
|
|
|
|
|
inboxRecord: false,
|
|
|
|
|
inboxPersist: false,
|
|
|
|
|
pointerRead: false,
|
|
|
|
|
pointerWrite: false,
|
|
|
|
|
accountLookup: false,
|
|
|
|
|
accountRecord: false,
|
|
|
|
|
inboxCapPersist: false,
|
|
|
|
|
addressPublish: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A stateful fake `ng` over an in-memory quad store, modelling the pointer → doc-shim
|
|
|
|
|
* indirection the registry is built on: the store-root graph carries the write-once pointer,
|
|
|
|
|
* the doc-shim carries the account records, the inbox index and the (user, scope) → inbox
|
|
|
|
|
* associations. Same shape as the one `anti-fork.test.ts` drives the registry with, plus the
|
|
|
|
|
* fault switches above.
|
|
|
|
|
*
|
|
|
|
|
* `doc_subscribe` pushes a first `State` so the barrier `ensureRepoOpen` waits on resolves
|
|
|
|
|
* at once — the platform's real behaviour, and it keeps every failure below attributable to
|
|
|
|
|
* the call that was armed rather than to a missing primitive.
|
|
|
|
|
*/
|
|
|
|
|
function makeFakeNg(faults: Faults) {
|
|
|
|
|
const quads: Quad[] = [];
|
|
|
|
|
let docCounter = 0;
|
|
|
|
|
/** How many times the (user, scope) → inbox association was READ. */
|
|
|
|
|
let inboxLookups = 0;
|
|
|
|
|
|
|
|
|
|
const doc_create = mock(async () => {
|
|
|
|
|
if (faults.docCreate) throw new Error("BrokerError: cannot create document");
|
|
|
|
|
return `did:ng:o:idoc${++docCounter}`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
|
|
|
const query = a[1] as string;
|
|
|
|
|
const anchor = a[2] as string | undefined;
|
|
|
|
|
if (query.includes(`${SHIM}:isInbox`) && faults.inboxRecord) {
|
|
|
|
|
throw new Error("BrokerError: cannot write the inbox index");
|
|
|
|
|
}
|
|
|
|
|
if (query.includes(`${SHIM}:docInbox`) && faults.inboxPersist) {
|
|
|
|
|
throw new Error("BrokerError: cannot write the inbox association");
|
|
|
|
|
}
|
|
|
|
|
if (query.includes(`${SHIM}:shimDoc`) && faults.pointerWrite) {
|
|
|
|
|
throw new Error("BrokerError: cannot write the pointer");
|
|
|
|
|
}
|
|
|
|
|
if (query.includes(`${SHIM}:docPublic`) && faults.accountRecord) {
|
|
|
|
|
throw new Error("BrokerError: cannot write the account record");
|
|
|
|
|
}
|
|
|
|
|
if (query.includes(`${SHIM}:inboxCap`) && faults.inboxCapPersist) {
|
|
|
|
|
throw new Error("BrokerError: cannot write the inbox cap");
|
|
|
|
|
}
|
|
|
|
|
// Only the INSERT half: the `DELETE` that clears the previous address lands, which is
|
|
|
|
|
// the state that bites — the old address gone and the new one never written.
|
|
|
|
|
if (
|
|
|
|
|
query.includes("INSERT DATA") && query.includes(`${SHIM}:inboxAddress`) &&
|
|
|
|
|
faults.addressPublish
|
|
|
|
|
) {
|
|
|
|
|
throw new Error("BrokerError: cannot publish the inbox address");
|
|
|
|
|
}
|
|
|
|
|
// `DELETE WHERE { <s> <p> ?x }` — the clear half of a Header-branch replacement.
|
|
|
|
|
const dm = query.match(/DELETE WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?\w+\s*\}/);
|
|
|
|
|
if (dm) {
|
|
|
|
|
for (let i = quads.length - 1; i >= 0; i--) {
|
|
|
|
|
const q = quads[i]!;
|
|
|
|
|
if (q.g === anchor && q.s === dm[1] && q.p === dm[2]) quads.splice(i, 1);
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses;
|
|
|
|
|
// everything else writes the anchored default graph.
|
|
|
|
|
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
|
|
|
|
|
const g = gm ? gm[1]! : anchor;
|
|
|
|
|
if (!g) return undefined;
|
|
|
|
|
const body = gm ? gm[2]! : 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) {
|
|
|
|
|
const p = m[1] ?? `${SHIM}:Account`;
|
|
|
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
|
|
|
quads.push({ g, s, p, o });
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rows = (anchor: string | undefined, pred: string, name: string) => ({
|
|
|
|
|
results: {
|
|
|
|
|
bindings: quads
|
|
|
|
|
.filter((q) => q.g === anchor && q.p === pred)
|
|
|
|
|
.map((q) => ({ [name]: { value: q.o } })),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
|
|
|
const query = a[1] as string;
|
|
|
|
|
const anchor = a[3] as string | undefined;
|
|
|
|
|
// Store-root pointer → the doc-shim.
|
|
|
|
|
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
|
|
|
|
if (faults.pointerRead) throw new Error("RepoNotFound");
|
|
|
|
|
return rows(anchor, `${SHIM}:shimDoc`, "shimDoc");
|
|
|
|
|
}
|
|
|
|
|
// The account record — the read `lookupAccount` issues, bounded to one subject.
|
|
|
|
|
if (query.includes(`<${SHIM}:id>`)) {
|
|
|
|
|
if (faults.accountLookup) throw new Error("RepoNotFound");
|
|
|
|
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
|
|
|
const only = subjM ? subjM[1]! : null;
|
|
|
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
|
|
|
for (const q of quads) {
|
|
|
|
|
if (q.g !== anchor) continue;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
results: {
|
|
|
|
|
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 ?? "" },
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// Which inbox does this (user, scope) own — the read the whole file is about.
|
|
|
|
|
if (query.includes(`${SHIM}:docInbox`)) {
|
|
|
|
|
inboxLookups += 1;
|
|
|
|
|
if (faults.inboxLookup) throw new Error("RepoNotFound");
|
|
|
|
|
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
|
|
|
|
|
const pred = pm ? pm[1]! : "";
|
|
|
|
|
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
|
|
|
|
|
const subj = sm ? sm[1]! : null;
|
|
|
|
|
return {
|
|
|
|
|
results: {
|
|
|
|
|
bindings: quads
|
|
|
|
|
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
|
|
|
|
|
.map((q) => ({ d: { value: q.o } })),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i");
|
|
|
|
|
// The registers a document and a store carry — enough for `createEntityDoc`,
|
|
|
|
|
// `ownsDocument` and `openDocumentInbox` to run against this wallet.
|
|
|
|
|
if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c");
|
|
|
|
|
if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c");
|
|
|
|
|
if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e");
|
|
|
|
|
if (query.includes(`<${SHIM}:inboxCap>`)) return rows(anchor, `${SHIM}:inboxCap`, "c");
|
|
|
|
|
if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a");
|
|
|
|
|
// Anchored per-doc read (`SELECT ?s ?p ?o`).
|
|
|
|
|
return {
|
|
|
|
|
results: {
|
|
|
|
|
bindings: quads
|
|
|
|
|
.filter((q) => q.g === anchor)
|
|
|
|
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Push a `State` on subscribe — the sync barrier `open-repo.ts` waits for.
|
|
|
|
|
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: unknown) => {
|
|
|
|
|
if (typeof cb === "function") (cb as (r: unknown, t?: string) => void)({ V0: { State: {} } });
|
|
|
|
|
return () => {};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
doc_create,
|
|
|
|
|
sparql_query,
|
|
|
|
|
sparql_update,
|
|
|
|
|
doc_subscribe,
|
|
|
|
|
/** Documents minted so far — what "did it mint a second one" reads. */
|
|
|
|
|
created: (): number => docCounter,
|
|
|
|
|
/** Times the (user, scope) → inbox association was read. */
|
|
|
|
|
lookups: (): number => inboxLookups,
|
|
|
|
|
/** The inbox NURIs DURABLY associated with `scope`, across every account subject. */
|
|
|
|
|
associated: (scope: InboxScope): string[] =>
|
|
|
|
|
quads.filter((q) => q.p === `${SHIM}:docInbox:${scope}`).map((q) => q.o),
|
|
|
|
|
/** Every triple written with `pred`, whatever its subject or graph. */
|
|
|
|
|
written: (pred: string): string[] =>
|
|
|
|
|
quads.filter((q) => q.p === `${SHIM}:${pred}`).map((q) => q.o),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Wire a clean world: fresh fake broker, fresh caches, nobody connected. */
|
|
|
|
|
function inject(faults: Faults) {
|
|
|
|
|
const ng = makeFakeNg(faults);
|
|
|
|
|
configure({ ng: ng as never, useShape: (() => {}) as never });
|
|
|
|
|
configureStoreRegistry({
|
|
|
|
|
getSession: async (): Promise<RegistrySession> => {
|
|
|
|
|
if (faults.session) throw new Error("WalletError: the session cannot answer yet");
|
|
|
|
|
return SESSION;
|
|
|
|
|
},
|
|
|
|
|
normalizeId: (id: string) => id.trim().toLowerCase(),
|
|
|
|
|
});
|
|
|
|
|
resetRegistryCache();
|
|
|
|
|
resetOpenedRepos();
|
|
|
|
|
resetCaps();
|
|
|
|
|
setCurrentUser(null);
|
|
|
|
|
return ng;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
setCurrentUser(null);
|
|
|
|
|
resetConfig();
|
|
|
|
|
resetStoreRegistry();
|
|
|
|
|
resetRegistryCache();
|
|
|
|
|
resetOpenedRepos();
|
|
|
|
|
resetCaps();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** A later session over the SAME wallet: every in-memory cache gone, the quads kept. */
|
|
|
|
|
function freshSession(): void {
|
|
|
|
|
resetRegistryCache();
|
|
|
|
|
resetOpenedRepos();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- resolving without asking: the two states that are already true ---------
|
|
|
|
|
|
|
|
|
|
test("A — an inbox already resolved comes back from the cache, untouched", async () => {
|
|
|
|
|
const ng = inject(noFaults());
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const inbox = await userInbox("bob", "public");
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
const lookupsBefore = ng.lookups();
|
|
|
|
|
|
|
|
|
|
const again = await userInbox("bob", "public");
|
|
|
|
|
|
|
|
|
|
expect(again).toBe(inbox);
|
|
|
|
|
expect(ng.lookups()).toBe(lookupsBefore); // no read at all
|
|
|
|
|
expect(ng.created()).toBe(createdBefore); // and nothing minted
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("B — a concurrent caller joins the resolution in flight instead of racing a second inbox", async () => {
|
|
|
|
|
// Two independent callers ask at once on a cold cache — the ordinary shape on a fresh
|
|
|
|
|
// page. Without the join each would see "no inbox" and mint one, forking the (user,
|
|
|
|
|
// scope) association in-session.
|
|
|
|
|
const ng = inject(noFaults());
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob");
|
|
|
|
|
|
|
|
|
|
const [first, second] = await Promise.all([
|
|
|
|
|
userInbox("bob", "public"),
|
|
|
|
|
userInbox("bob", "public"),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(second).toBe(first);
|
|
|
|
|
expect(ng.associated("public")).toEqual([first]); // ONE association, not two
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("B — a concurrent caller joins the resolution in flight and inherits its FAILURE", async () => {
|
|
|
|
|
// The join is only safe if what it hands out is the run's real outcome. A joiner that
|
|
|
|
|
// inherits a resolved promise over a failed run is the sibling defect fixed in
|
|
|
|
|
// `connect.ts`: a caller that did nothing wrong carries on over work that never happened.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob");
|
|
|
|
|
faults.inboxPersist = true;
|
|
|
|
|
|
|
|
|
|
// Both handlers attached in the same tick, as two real concurrent callers would: awaiting
|
|
|
|
|
// one and only THEN the other leaves the second rejection momentarily unobserved, which
|
|
|
|
|
// the runtime reports as an unhandled rejection rather than as the outcome under test.
|
|
|
|
|
const outcomes = await Promise.allSettled([
|
|
|
|
|
userInbox("bob", "public"),
|
|
|
|
|
userInbox("bob", "public"),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- the collaborators awaited before any decision is taken -----------------
|
|
|
|
|
|
|
|
|
|
test("C — a session that cannot answer fails the call", async () => {
|
|
|
|
|
// The consumer's `getSession` is the other edge of the library. A wallet that cannot
|
|
|
|
|
// answer says nothing about which inbox a user owns, so there is nothing to conclude.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
faults.session = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/the session cannot answer/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("D — a doc-shim that cannot be reached fails the call", async () => {
|
|
|
|
|
// No pointer yet (a first login), so resolving the doc-shim mints it. A broker that
|
|
|
|
|
// cannot create it leaves the registry itself unresolved — every account record and every
|
|
|
|
|
// inbox association lives in that document.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
faults.docCreate = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
|
|
|
|
|
expect(ng.created()).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("E — an account that cannot be provisioned fails the call", async () => {
|
|
|
|
|
// A user must exist before it can own an inbox. `ensureAccount` mints its three scope
|
|
|
|
|
// documents on first sight; a broker that cannot returns no record, and an inbox filed
|
|
|
|
|
// under a user with no stores is filed under nothing.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("alice");
|
|
|
|
|
await ensureAccount("alice"); // the doc-shim now exists, so the fault lands on the account
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
faults.docCreate = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
|
|
|
|
|
expect(ng.associated("public")).toEqual([]);
|
|
|
|
|
expect(ng.created()).toBe(createdBefore);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- the read: an answer, an absence, or a failure --------------------------
|
|
|
|
|
|
|
|
|
|
test("F — a lookup that could not answer is not an absent inbox: no second one is minted", async () => {
|
|
|
|
|
// The shipped defect. The read that asks *which inbox does this user own* was wrapped in a
|
|
|
|
|
// catch that only logged, and execution fell through to the mint — so a broker that could
|
|
|
|
|
// not answer produced a SECOND document for one (user, scope). Which of the two later wins
|
|
|
|
|
// is then decided by `canonicalDoc` picking among the triples: the owner drains one, a
|
|
|
|
|
// depositor may write to the other.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const inbox = await userInbox("bob", "public");
|
|
|
|
|
freshSession();
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
faults.inboxLookup = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/RepoNotFound/);
|
|
|
|
|
|
|
|
|
|
// The durable consequence: the wallet still holds exactly ONE inbox for (bob, public),
|
|
|
|
|
// and no document was minted behind the failure.
|
|
|
|
|
expect(ng.associated("public")).toEqual([inbox]);
|
|
|
|
|
expect(ng.created()).toBe(createdBefore);
|
|
|
|
|
// …so once the broker answers again, it is that same inbox that comes back.
|
|
|
|
|
faults.inboxLookup = false;
|
|
|
|
|
freshSession();
|
|
|
|
|
expect(await userInbox("bob", "public")).toBe(inbox);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("G — an inbox the shim already records is resolved, never re-minted", async () => {
|
|
|
|
|
const ng = inject(noFaults());
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const inbox = await userInbox("bob", "public");
|
|
|
|
|
freshSession(); // a later session over the same wallet: nothing warm
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
|
|
|
|
|
const resolved = await userInbox("bob", "public");
|
|
|
|
|
|
|
|
|
|
expect(resolved).toBe(inbox);
|
|
|
|
|
expect(ng.created()).toBe(createdBefore);
|
|
|
|
|
expect(ng.associated("public")).toEqual([inbox]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("H — a first ask on a VERIFIED absence mints the inbox, records it, and associates it", async () => {
|
|
|
|
|
// The normal case, and the only one entitled to write: the broker answered, and it
|
|
|
|
|
// answered nothing. The three writes that make the answer usable all have to land — the
|
|
|
|
|
// document, the shim's "this IS an inbox", and the (user, scope) association.
|
|
|
|
|
const ng = inject(noFaults());
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
|
|
|
|
|
const publicInbox = await userInbox("bob", "public");
|
|
|
|
|
const protectedInbox = await userInbox("bob", "protected");
|
|
|
|
|
|
|
|
|
|
expect(protectedInbox).not.toBe(publicInbox); // two store repos upstream, two documents
|
|
|
|
|
expect(ng.associated("public")).toEqual([publicInbox]);
|
|
|
|
|
expect(ng.associated("protected")).toEqual([protectedInbox]);
|
|
|
|
|
// Durably an inbox, not merely one this session happens to remember.
|
|
|
|
|
freshSession();
|
|
|
|
|
expect(await isKnownInbox(publicInbox)).toBe(true);
|
|
|
|
|
expect(await isKnownInbox(protectedInbox)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- the mint: three writes, and none of them may fail in silence -----------
|
|
|
|
|
|
|
|
|
|
test("I — a broker that cannot mint the document fails the call", async () => {
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob"); // exists, and has never opened an inbox
|
|
|
|
|
faults.docCreate = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot create document/);
|
|
|
|
|
expect(ng.associated("public")).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("J — an inbox the shim could not record as one is not handed back", async () => {
|
|
|
|
|
// `recordInbox` is what stands in for the broker's `inboxes: PubKey → RepoId` table: it is
|
|
|
|
|
// how a depositor learns that a NURI is an inbox at all, and `inbox.post` refuses anything
|
|
|
|
|
// it cannot confirm. It swallowed its own write failure, so the call went on to associate a
|
|
|
|
|
// document that no depositor will ever be allowed to write to — the owner's inbox, silently
|
|
|
|
|
// closed to everyone, forever.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob");
|
|
|
|
|
faults.inboxRecord = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox index/);
|
|
|
|
|
expect(ng.associated("public")).toEqual([]); // nothing points at a box deposits bounce off
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("K — an association that could not be persisted is not returned as an inbox", async () => {
|
|
|
|
|
// The other shipped defect. The `INSERT DATA` that records WHICH inbox belongs to (user,
|
|
|
|
|
// scope) was wrapped in a catch that only logged, and the function cached and returned the
|
|
|
|
|
// document anyway. The owner then holds a reference whose triple was never written: nobody
|
|
|
|
|
// else resolves it, so a depositor finding nothing mints yet another. The owner reads a box
|
|
|
|
|
// nobody writes to, depositors write to a box nobody reads.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob");
|
|
|
|
|
faults.inboxPersist = true;
|
|
|
|
|
|
|
|
|
|
await expect(userInbox("bob", "public")).rejects.toThrow(/cannot write the inbox association/);
|
|
|
|
|
expect(ng.associated("public")).toEqual([]);
|
|
|
|
|
|
|
|
|
|
// …and the reference a healthy resolution DOES hand back is one everybody resolves the
|
|
|
|
|
// same. Alice obtains Bob's inbox the way `inbox.share` does — by asking the registry
|
|
|
|
|
// under her own identity — rather than being handed a value across the boundary.
|
|
|
|
|
faults.inboxPersist = false;
|
|
|
|
|
freshSession();
|
|
|
|
|
const ownersView: Nuri = await userInbox("bob", "protected");
|
|
|
|
|
freshSession();
|
|
|
|
|
adoptCurrentUser("alice");
|
|
|
|
|
const depositorsView: Nuri = await userInbox("bob", "protected");
|
|
|
|
|
|
|
|
|
|
expect(depositorsView).toBe(ownersView);
|
|
|
|
|
expect(ng.associated("protected")).toEqual([ownersView]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- the same shape, swept out of the rest of the registry -------------------
|
|
|
|
|
//
|
|
|
|
|
// `userInbox` was the third member of this family found by accident, so the sweep that
|
|
|
|
|
// followed the fix looked for every other `catch` that logs and lets execution carry on as
|
|
|
|
|
// though the thing looked for was absent — especially where what follows creates or writes.
|
|
|
|
|
// These are the ones whose consequence was a DURABLE false state; each is pinned here,
|
|
|
|
|
// because a swallow that comes back is invisible again by construction.
|
|
|
|
|
|
|
|
|
|
test("SWEEP — a pointer read that never answered does not mint a second registry root", async () => {
|
|
|
|
|
// `resolvePointer` retries a bounded number of times and answered `""` when the budget ran
|
|
|
|
|
// out — the same value it uses for "no pointer yet", which is what makes `resolveShimDoc`
|
|
|
|
|
// CREATE one. A store-root that could not be read therefore forked the registry itself: a
|
|
|
|
|
// second doc-shim, a second pointer, and every account record afterwards split between two
|
|
|
|
|
// documents, the loser's simply invisible.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("bob");
|
|
|
|
|
const pointersBefore = ng.written("shimDoc");
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
freshSession();
|
|
|
|
|
faults.pointerRead = true;
|
|
|
|
|
|
|
|
|
|
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
|
|
|
|
|
|
|
|
|
|
expect(ng.written("shimDoc")).toEqual(pointersBefore); // still ONE registry root
|
|
|
|
|
expect(ng.created()).toBe(createdBefore); // and nothing minted behind the failure
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("SWEEP — a pointer that could not be written fails the first login", async () => {
|
|
|
|
|
// The pointer is the only NURI a fresh session can name without a lookup. Carrying on
|
|
|
|
|
// without it means writing every account into a doc-shim no later session can find — and
|
|
|
|
|
// the next login, seeing no pointer, mints another one and re-provisions everybody.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
faults.pointerWrite = true;
|
|
|
|
|
|
|
|
|
|
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the pointer/);
|
|
|
|
|
expect(ng.written("id")).toEqual([]); // no account was filed into an unreachable shim
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("SWEEP — an account read that could not answer does not provision a second account", async () => {
|
|
|
|
|
// `ensureAccount` asked through the TOLERANT resolver, which answers `null` for a read
|
|
|
|
|
// that failed exactly as for one that found nothing — and what follows an absence here is
|
|
|
|
|
// a PROVISION. So a broker hiccup minted a second set of three store documents and a
|
|
|
|
|
// second record: the account fork the doc-shim barrier was introduced to end, walking back
|
|
|
|
|
// in through the error path.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const first = await ensureAccount("bob");
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
freshSession();
|
|
|
|
|
faults.accountLookup = true;
|
|
|
|
|
|
|
|
|
|
await expect(ensureAccount("bob")).rejects.toThrow(/RepoNotFound/);
|
|
|
|
|
|
|
|
|
|
expect(ng.created()).toBe(createdBefore); // no second set of scope documents
|
|
|
|
|
faults.accountLookup = false;
|
|
|
|
|
freshSession();
|
|
|
|
|
expect(await ensureAccount("bob")).toEqual(first); // and the one account is intact
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("SWEEP — an account record that could not be persisted is not handed back", async () => {
|
|
|
|
|
// `ensureAccount` cached and returned the record right after the write. The session then
|
|
|
|
|
// wrote its entities into three documents the shim never heard of, and the next session,
|
|
|
|
|
// finding no record, provisioned the account again — everything created in between
|
|
|
|
|
// orphaned, with no error anywhere.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
await ensureAccount("alice"); // the doc-shim exists, so the fault lands on the record
|
|
|
|
|
faults.accountRecord = true;
|
|
|
|
|
|
|
|
|
|
await expect(ensureAccount("bob")).rejects.toThrow(/cannot write the account record/);
|
|
|
|
|
|
|
|
|
|
// Nothing remembers a user that was never filed: asking again re-provisions from scratch
|
|
|
|
|
// rather than handing back the set the first attempt abandoned.
|
|
|
|
|
faults.accountRecord = false;
|
|
|
|
|
const createdBefore = ng.created();
|
|
|
|
|
const record = await ensureAccount("bob");
|
|
|
|
|
expect(ng.created()).toBe(createdBefore + 3);
|
|
|
|
|
expect(record.docPrivate).not.toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("SWEEP — a document inbox the owner's branch could not record is never published", async () => {
|
|
|
|
|
// `openDocumentInbox` writes twice: the pair on the owner's User branch (what makes the
|
|
|
|
|
// inbox DRAINABLE — `myInboxes` builds the connection's drain list from it) and the
|
|
|
|
|
// address on the document (what makes it REACHABLE). Swallowing the first went on to
|
|
|
|
|
// publish the second, inviting depositors into a queue its owner never enumerates: every
|
|
|
|
|
// message delivered, none ever applied, permanently.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const note = await createEntityDoc("bob", "protected");
|
|
|
|
|
faults.inboxCapPersist = true;
|
|
|
|
|
|
|
|
|
|
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot write the inbox cap/);
|
|
|
|
|
|
|
|
|
|
expect(ng.written("inboxAddress")).toEqual([]); // nobody was told where to deposit
|
|
|
|
|
expect(await documentInboxAddress(note)).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("SWEEP — an address that could not be published fails opening the inbox", async () => {
|
|
|
|
|
// The other half. Publishing is the only way a third party learns where to deposit here,
|
|
|
|
|
// and the address is a REPLACEMENT: the `DELETE` lands, the `INSERT` does not, and the
|
|
|
|
|
// document is left with no address at all while its owner is handed an inbox and told
|
|
|
|
|
// nothing.
|
|
|
|
|
const faults = noFaults();
|
|
|
|
|
const ng = inject(faults);
|
|
|
|
|
adoptCurrentUser("bob");
|
|
|
|
|
const note = await createEntityDoc("bob", "protected");
|
|
|
|
|
faults.addressPublish = true;
|
|
|
|
|
|
|
|
|
|
await expect(openDocumentInbox(note)).rejects.toThrow(/cannot publish the inbox address/);
|
|
|
|
|
expect(ng.written("inboxAddress")).toEqual([]);
|
|
|
|
|
});
|