8c8ade7a9e
connectedUser() restaure les capacités reçues et draine les inbox. Aucun de ses chemins ne pouvait échouer : un broker injoignable rendait exactement la même promesse qu'un succès complet. L'application affichait alors des listes vides, et rien nulle part ne disait que la restauration n'avait pas eu lieu. L'énumération m'avait échappé sur deux points, l'agent les a établis. resolveAccount attrapait tout et rendait null : une lecture qui ÉCHOUAIT ressortait donc comme « ce compte n'existe pas ». L'échec était déguisé en absence — c'est la racine du partage cassé trouvé ce matin, dont on n'avait traité que le déclencheur. lookupAccount le remplace : le silence n'est plus possible que sur une absence VÉRIFIÉE. Et readLinks comme readInboxCapPairs avalaient leur propre erreur en rendant un tableau vide, un étage sous le catch de connect. Une panne n'y parvenait même pas. Elles relèvent désormais. La règle est simple : tout échec remonte, seul « il n'y avait rien à faire » se résout en silence. Ce qui reste silencieux — aucun détenteur, compte réellement absent, identité changée en route — l'est parce que c'est la vérité. Le piège consigné hier est fermé par là même : une exécution qui ne peut pas répondre rejette, et ceux qui la rejoignent en héritent. Sa feuille est supprimée, la question qu'elle laissait ouverte étant tranchée. Trois fixtures de test utilisaient un ng vide — une forme qu'aucune plateforme ne présente, dont le TypeError était mangé par le catch. Remplacées par un broker au portefeuille vide. Aucune assertion modifiée.
577 lines
24 KiB
TypeScript
577 lines
24 KiB
TypeScript
/**
|
|
* Connecting a user either DID THE WORK or SAYS IT DID NOT — the whole case space.
|
|
*
|
|
* `connectedUser()` restores the caps a person was given (the Links already applied on
|
|
* their User branch) and drains their inboxes. `ensureIdentity()` awaits it, and the
|
|
* published contract says that call "completes the connection work it starts". So the one
|
|
* thing it must never do is resolve after failing: an application then renders, shows empty
|
|
* lists, and nothing anywhere says the restore never happened. The symptom a consumer sees
|
|
* is *"the app works but the documents shared with me never appear"* — the worst kind,
|
|
* because it looks like a permission decision and is a swallowed error.
|
|
*
|
|
* The rule this file pins, one line: **any failure must surface; only "there was nothing to
|
|
* do" may resolve quietly.** Nothing to do means exactly two things — no identity is
|
|
* connected, or the identity has no account yet (connecting must never PROVISION one, see
|
|
* `connect.ts`) — plus abandoning when the identity changed under the run, which is not a
|
|
* failure either: the next connection picks it up.
|
|
*
|
|
* ── Why every branch is here, not just the interesting ones ───────────────
|
|
* A swallowed failure is invisible by construction, so a suite that covers "the happy path
|
|
* and one error" leaves exactly the places a bug hides. Every path through the function is
|
|
* asserted below: no holder, no account, a lookup that could not answer, each of the three
|
|
* identity checkpoints, each of the three collaborators failing, joining a run in flight
|
|
* (both outcomes), the fire-and-forget entry, and success.
|
|
*
|
|
* ── The faults are the broker's, not the test's ───────────────────────────
|
|
* Every failure below 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/verifier.rs`, and what `cold-start-anchor.test.ts` models too), or a
|
|
* `doc_create` that cannot reach the broker. Nothing here reaches into the library to make
|
|
* one of its functions reject artificially: 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,
|
|
getCaps,
|
|
resetCaps,
|
|
resetConfig,
|
|
resetStoreRegistry,
|
|
setCurrentUser,
|
|
} from "../src/shared-wallet/bootstrap";
|
|
import {
|
|
createEntityDoc,
|
|
ensureAccount,
|
|
resetRegistryCache,
|
|
userInbox,
|
|
} from "../src/shared-wallet/account-registry";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
|
import { connectedUser } from "../src/emulated-verifier/connect";
|
|
import { share } from "../src/surface/inbox";
|
|
import { sparqlUpdate } from "../src/surface/docs";
|
|
import type { Nuri } from "../src/model/types";
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-connect", privateStoreId: "PRIV-CONNECT" };
|
|
const SHIM = "urn:ng-eventually:shim";
|
|
const INBOX = "urn:ng-eventually:inbox";
|
|
const SECRET = "urn:connect-test:secret";
|
|
|
|
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; `null` means "answer normally".
|
|
*/
|
|
interface Faults {
|
|
/** The doc-shim read that answers "does this account exist" throws. */
|
|
accountLookup: boolean;
|
|
/** The User-branch read that answers "which Links has this user applied" throws. */
|
|
linksRead: boolean;
|
|
/** `doc_create` throws — the broker cannot mint the document an inbox needs. */
|
|
docCreate: boolean;
|
|
/** The User-branch read that answers "which document inboxes may I read" throws. */
|
|
inboxCapRead: boolean;
|
|
/** Reading THIS inbox throws (a repo the session cannot resolve). */
|
|
inboxRead: Nuri | null;
|
|
/** Called before every anchored read — the seam the identity-switch cases use. */
|
|
onQuery: ((query: string, anchor: string | undefined) => void) | null;
|
|
}
|
|
|
|
function noFaults(): Faults {
|
|
return {
|
|
accountLookup: false,
|
|
linksRead: false,
|
|
docCreate: false,
|
|
inboxCapRead: false,
|
|
inboxRead: null,
|
|
onQuery: null,
|
|
};
|
|
}
|
|
|
|
/** 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 — the shim SPARQL, the User-branch
|
|
* registers, the inbox SPARQL and the anchored per-doc read. Same shape as the one
|
|
* `cross-user-access.test.ts` drives the model with, plus the fault switches above.
|
|
*
|
|
* No `doc_subscribe`: `ensureRepoOpen` is then a no-op by design (`open-repo.ts`), which
|
|
* keeps every failure below attributable to the call that was armed.
|
|
*/
|
|
function makeFakeNg(faults: Faults) {
|
|
const quads: Quad[] = [];
|
|
/** The anchors an inbox READ was issued against, in order — what "was it drained" reads. */
|
|
const inboxReads: string[] = [];
|
|
/** How many times the Links register was read — what "the work ran once" reads. */
|
|
let linksReads = 0;
|
|
let docCounter = 0;
|
|
|
|
const doc_create = mock(async () => {
|
|
if (faults.docCreate) throw new Error("BrokerError: cannot create document");
|
|
return `did:ng:o:cdoc${++docCounter}`;
|
|
});
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
if (!anchor) 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 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] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g: anchor, 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;
|
|
faults.onQuery?.(query, anchor);
|
|
// Store-root pointer → the doc-shim.
|
|
if (query.includes(`<${SHIM}:shimDoc>`)) return rows(anchor, `${SHIM}:shimDoc`, "shimDoc");
|
|
// The account record — the read `lookupAccount` issues.
|
|
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 ?? "" },
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
// An inbox READ — the deposits queued for its owner.
|
|
if (query.includes(`<${INBOX}:payload>`)) {
|
|
inboxReads.push(anchor ?? "");
|
|
if (faults.inboxRead !== null && anchor === faults.inboxRead) throw new Error("RepoNotFound");
|
|
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 === `${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);
|
|
}
|
|
return {
|
|
results: {
|
|
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;
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
// The User branch: the applied Links, and the inbox caps.
|
|
if (query.includes(`<${SHIM}:link>`)) {
|
|
linksReads += 1;
|
|
if (faults.linksRead) throw new Error("RepoNotFound");
|
|
return rows(anchor, `${SHIM}:link`, "c");
|
|
}
|
|
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
|
if (faults.inboxCapRead) throw new Error("RepoNotFound");
|
|
return rows(anchor, `${SHIM}:inboxCap`, "c");
|
|
}
|
|
if (query.includes(`<${SHIM}:inboxAddress>`)) return rows(anchor, `${SHIM}:inboxAddress`, "a");
|
|
if (query.includes(`<${SHIM}:readCap>`)) return rows(anchor, `${SHIM}:readCap`, "c");
|
|
if (query.includes(`<${SHIM}:exposedReadCap>`)) return rows(anchor, `${SHIM}:exposedReadCap`, "c");
|
|
if (query.includes(`<${SHIM}:contains>`)) return rows(anchor, `${SHIM}:contains`, "e");
|
|
if (query.includes(`${SHIM}:isInbox`)) return rows(anchor, `${SHIM}:isInbox`, "i");
|
|
if (query.includes(`${SHIM}:docInbox`)) {
|
|
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 } })),
|
|
},
|
|
};
|
|
}
|
|
// 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 } })),
|
|
},
|
|
};
|
|
});
|
|
|
|
return {
|
|
doc_create,
|
|
sparql_query,
|
|
sparql_update,
|
|
inboxReads,
|
|
linksReadCount: (): number => linksReads,
|
|
};
|
|
}
|
|
|
|
/** 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> => SESSION,
|
|
normalizeId: (id: string) => id.trim().toLowerCase(),
|
|
});
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
return ng;
|
|
}
|
|
|
|
afterEach(() => {
|
|
setCurrentUser(null);
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetRegistryCache();
|
|
resetOpenedRepos();
|
|
resetCaps();
|
|
});
|
|
|
|
/** Write one triple into `doc`, as a consumer's write path would. */
|
|
async function write(doc: Nuri, p: string, o: string): Promise<void> {
|
|
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
|
|
}
|
|
|
|
/**
|
|
* Alice owns a protected note and shares it with Bob — the ordinary way a cap reaches
|
|
* someone. Bob's account and inbox come into existence through the system (he signs in
|
|
* once), never through a value the test hands across the identity boundary.
|
|
*
|
|
* Returns the note Alice shared, so a later assertion can ask "does Bob hold it".
|
|
*/
|
|
async function aliceSharesANoteWithBob(): Promise<Nuri> {
|
|
adoptCurrentUser("bob");
|
|
await ensureAccount("bob"); // Bob has signed in before: he exists, so he can be shared with.
|
|
|
|
adoptCurrentUser("alice");
|
|
const note = await createEntityDoc("alice", "protected");
|
|
await write(note, SECRET, "the-protected-content");
|
|
await share(note, "bob");
|
|
|
|
adoptCurrentUser(null);
|
|
return note;
|
|
}
|
|
|
|
/** Does the connected identity hold this document's cap? */
|
|
function holds(doc: Nuri): boolean {
|
|
return getCaps().capFor(doc) !== undefined;
|
|
}
|
|
|
|
// --- nothing to do: the only two silences that are legitimate ---------------
|
|
|
|
test("no identity connected: nothing to restore, and the call resolves", async () => {
|
|
// Anonymous holds nothing and owns no inbox, so there is genuinely no work. This is the
|
|
// one branch `ensureIdentity` can never reach — it has settled an identity by then — and
|
|
// it exists for the internal callers (`startConnect` is gated on a non-null id; a test or
|
|
// the e2e harness may call in before signing in).
|
|
const ng = inject(noFaults());
|
|
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
|
|
expect(ng.linksReadCount()).toBe(0);
|
|
expect(ng.inboxReads).toEqual([]);
|
|
});
|
|
|
|
test("an identity with no account yet: nothing to restore, and the call resolves", async () => {
|
|
// Connecting must not PROVISION (see `connect.ts`): an account that does not exist has no
|
|
// Links to restore and no inbox to drain. A genuine absence is therefore silence, and the
|
|
// ONLY silence a failed read may not borrow — see the next test.
|
|
const ng = inject(noFaults());
|
|
adoptCurrentUser("nobody-has-signed-in-as-this");
|
|
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
|
|
expect(ng.linksReadCount()).toBe(0);
|
|
expect(ng.inboxReads).toEqual([]);
|
|
});
|
|
|
|
// --- the failures, one per collaborator -------------------------------------
|
|
|
|
test("an account lookup that could not answer is not an absent account: the call rejects", async () => {
|
|
// The shipped shape of this defect. The tolerant resolver answers `null` for a read that
|
|
// FAILED exactly as for one that found nothing, so a broker that cannot answer looks like
|
|
// "you have no account" — which connecting is entitled to pass over in silence. The whole
|
|
// restore is then skipped and the promise resolves like a success.
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
resetRegistryCache(); // a fresh session: nothing is answered from a warm cache
|
|
faults.accountLookup = true;
|
|
|
|
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
|
});
|
|
|
|
test("a Links register that cannot be read fails the connection", async () => {
|
|
// The restore step. Its failure is the one with no other symptom at all: every document
|
|
// ever shared with this person stays invisible, and an application has no way to tell
|
|
// that from "nobody has shared anything with me".
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
const note = await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
await connectedUser(); // a first, healthy connection: the cap is applied durably
|
|
resetCaps();
|
|
resetRegistryCache();
|
|
faults.linksRead = true;
|
|
|
|
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
|
expect(holds(note)).toBe(false); // and it really did not restore
|
|
});
|
|
|
|
test("an inbox list that cannot be built fails the connection", async () => {
|
|
// Enumerating the inboxes resolves the user's own two, minting the document on first
|
|
// ask. A broker that cannot create it leaves the drain list unknowable — not empty.
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
|
|
adoptCurrentUser("bob");
|
|
await ensureAccount("bob"); // exists, but has never opened an inbox
|
|
faults.docCreate = true;
|
|
|
|
await expect(connectedUser()).rejects.toThrow(/cannot create document/);
|
|
});
|
|
|
|
test("a document-inbox list that cannot be read fails the connection", async () => {
|
|
// The other half of the drain list: the inboxes this user opened on its own documents.
|
|
// Unreadable is not empty — a queue skipped for want of knowing it exists holds a share
|
|
// that was delivered and will never be applied, with nothing to see anywhere.
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
|
|
adoptCurrentUser("bob");
|
|
await userInbox("bob", "public"); // so the OWN inboxes resolve and the fault lands later
|
|
await userInbox("bob", "protected");
|
|
faults.inboxCapRead = true;
|
|
|
|
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
|
});
|
|
|
|
test("an inbox that cannot be drained fails the connection, and the rest stay undrained", async () => {
|
|
// Draining is what APPLIES a share. An inbox that could not be read may hold the cap this
|
|
// very reconnection was for, so a silent skip loses it with no trace — and it took the
|
|
// remaining inboxes down with it, silently too. It still stops at the first failure; the
|
|
// difference is that stopping is now audible.
|
|
const faults = noFaults();
|
|
const ng = inject(faults);
|
|
await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
|
|
faults.inboxRead = publicInbox;
|
|
|
|
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
|
expect(ng.inboxReads).toEqual([publicInbox]); // the protected one was never reached
|
|
});
|
|
|
|
// --- abandoning on an identity switch: not a failure ------------------------
|
|
|
|
test("the identity changes right after the account resolved: abandons, and resolves", async () => {
|
|
// Everything below the checkpoint resolves the CURRENT holder when it reads a register,
|
|
// so after a switch it would read the WRONG user's. Abandoning loses nothing: the new
|
|
// identity's own connection does its own work.
|
|
const faults = noFaults();
|
|
const ng = inject(faults);
|
|
await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
resetRegistryCache();
|
|
faults.onQuery = (query) => {
|
|
if (query.includes(`<${SHIM}:id>`)) adoptCurrentUser("carol");
|
|
};
|
|
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
expect(ng.linksReadCount()).toBe(0);
|
|
expect(ng.inboxReads).toEqual([]);
|
|
});
|
|
|
|
test("the identity changes while the Links are being read: abandons, and resolves", async () => {
|
|
const faults = noFaults();
|
|
const ng = inject(faults);
|
|
const note = await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
await connectedUser(); // apply the cap durably, so there IS something to restore
|
|
resetCaps();
|
|
resetRegistryCache();
|
|
const drainedBefore = ng.inboxReads.length; // the healthy connection above drained them
|
|
faults.onQuery = (query) => {
|
|
if (query.includes(`<${SHIM}:link>`)) adoptCurrentUser("carol");
|
|
};
|
|
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
expect(holds(note)).toBe(false); // nothing was filed under the wrong holder
|
|
expect(ng.inboxReads.length).toBe(drainedBefore); // and it never reached the queues
|
|
});
|
|
|
|
test("the identity changes between two inboxes: abandons, and resolves", async () => {
|
|
const faults = noFaults();
|
|
const ng = inject(faults);
|
|
|
|
adoptCurrentUser("bob");
|
|
const publicInbox = await userInbox("bob", "public");
|
|
const protectedInbox = await userInbox("bob", "protected");
|
|
expect(protectedInbox).not.toBe(publicInbox);
|
|
faults.onQuery = (query, anchor) => {
|
|
if (query.includes(`<${INBOX}:payload>`) && anchor === publicInbox) adoptCurrentUser("carol");
|
|
};
|
|
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
expect(ng.inboxReads).toEqual([publicInbox]); // the checkpoint stopped the loop
|
|
});
|
|
|
|
// --- joining a run already in flight ----------------------------------------
|
|
|
|
test("a concurrent caller joins the run in flight and inherits its success", async () => {
|
|
const ng = inject(noFaults());
|
|
await aliceSharesANoteWithBob();
|
|
adoptCurrentUser("bob");
|
|
const publicInbox = await userInbox("bob", "public");
|
|
const protectedInbox = await userInbox("bob", "protected");
|
|
resetRegistryCache();
|
|
|
|
const first = connectedUser();
|
|
const second = connectedUser();
|
|
|
|
await expect(first).resolves.toBeUndefined();
|
|
await expect(second).resolves.toBeUndefined();
|
|
// ONE run, two callers: each inbox was drained exactly once. Two independent runs
|
|
// would show each anchor twice — the memoisation is what the joiner depends on.
|
|
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
|
|
});
|
|
|
|
test("a concurrent caller joins the run in flight and inherits its FAILURE", async () => {
|
|
// The sibling defect: a run that gives up registers itself as the run in flight FIRST, so
|
|
// a caller that did nothing wrong joins it and resolves having done nothing. Inheriting
|
|
// the outcome — failure included — is what makes joining safe.
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
resetRegistryCache();
|
|
faults.accountLookup = 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([connectedUser(), connectedUser()]);
|
|
|
|
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected"]);
|
|
const reasons = outcomes.flatMap((o) => (o.status === "rejected" ? [String(o.reason)] : []));
|
|
expect(reasons).toEqual([expect.stringContaining("RepoNotFound"), expect.stringContaining("RepoNotFound")]);
|
|
});
|
|
|
|
// --- the fire-and-forget entry ----------------------------------------------
|
|
|
|
test("the fire-and-forget entry reports the failure instead of dropping it", async () => {
|
|
// `setCurrentUser` fires the work un-awaited, so there is no caller to reject at. The
|
|
// failure must still leave a trace rather than vanish — and it must not surface as an
|
|
// unhandled rejection, which would take down whatever runtime the consumer is in.
|
|
const faults = noFaults();
|
|
inject(faults);
|
|
await aliceSharesANoteWithBob();
|
|
resetRegistryCache();
|
|
faults.accountLookup = true;
|
|
|
|
const logged: string[] = [];
|
|
const original = console.error;
|
|
console.error = (...args: unknown[]): void => void logged.push(args.map(String).join(" "));
|
|
try {
|
|
setCurrentUser("bob");
|
|
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
|
} finally {
|
|
console.error = original;
|
|
}
|
|
|
|
expect(logged.some((line) => /connect(ing)? failed/i.test(line))).toBe(true);
|
|
});
|
|
|
|
// --- success ----------------------------------------------------------------
|
|
|
|
test("a healthy connection restores the applied Links and drains every inbox", async () => {
|
|
// The normal case, and the reason all of the above matters: this is what an application
|
|
// is entitled to assume happened when `ensureIdentity()` came back.
|
|
const faults = noFaults();
|
|
const ng = inject(faults);
|
|
const note = await aliceSharesANoteWithBob();
|
|
|
|
adoptCurrentUser("bob");
|
|
const publicInbox = await userInbox("bob", "public");
|
|
const protectedInbox = await userInbox("bob", "protected");
|
|
|
|
// First connection: the cap is in the inbox, draining APPLIES it (durably, as a Link).
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
expect(holds(note)).toBe(true);
|
|
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
|
|
|
|
// Second connection, as a later session would: nothing left in the queue, and the cap
|
|
// comes back from the durable register alone.
|
|
resetCaps();
|
|
resetRegistryCache();
|
|
await expect(connectedUser()).resolves.toBeUndefined();
|
|
expect(holds(note)).toBe(true);
|
|
expect(ng.linksReadCount()).toBeGreaterThanOrEqual(1);
|
|
});
|