Files
ng-eventually/packages/client/test/cross-user-access.test.ts
T
Sylvain Duchesne 8a382f29f8 feat(inbox): l'inbox d'un document est adressable par tout détenteur
Répond au brief 2026-08-03 remonté depuis le consommateur. `documentInbox(doc)`
répondait « quelle inbox est-ce que MOI je connais pour ce document » et en
créait une quand la réponse était « aucune » : un tiers n'atteignait jamais
l'inbox du propriétaire, il en obtenait une à lui, que personne ne lit, et son
dépôt disparaissait sans erreur. C'est l'acte central du consommateur —
s'inscrire à l'événement d'un autre — qui était silencieusement perdu.

Lire une inbox et savoir où y déposer sont deux actes opposés, avec des publics
opposés. Ils sont désormais deux fonctions :

- `openDocumentInbox(doc)` — le PROPRIÉTAIRE ouvre une inbox dédiée. Refuse sur
  la PROPRIÉTÉ (lue depuis les branches Store), pas sur la possession du cap :
  un cap se reçoit, et un destinataire ne doit pas pouvoir rediriger vers lui
  les dépôts destinés au propriétaire.
- `documentInboxAddress(doc)` — n'importe quel détenteur trouve où déposer. Ne
  crée jamais rien.

L'adresse est publiée dès la CRÉATION, sur la branche Header émulée du document
— un sujet réservé à l'intérieur du document, donc lisible par qui détient le
document. Publier seulement le jour où le propriétaire ouvre une inbox dédiée
laisserait une fenêtre pendant laquelle un tiers lit le document, ne trouve
aucune adresse, et ne peut pas joindre le propriétaire du tout.

Sur le coût mesuré par le brief (9m37 → 21m30) : il venait de la création d'un
DOCUMENT supplémentaire par document. L'adresse publiée pointe vers l'inbox
propre du propriétaire, qui existe déjà et s'amortit sur tous ses documents ;
la création grandit d'un triple, pas d'un document. Le dépôt porte le document
concerné, donc le propriétaire matérialise toujours par document. La forme
« dérivable » du brief n'était pas disponible : notre inbox est un document, et
un NURI dérivé nommerait un repo que `doc_create` n'a jamais créé.

Le tout reflète la séparation d'amont : un déposant scelle avec la clé PUBLIQUE
de l'inbox et n'a besoin de rien d'autre, seul le propriétaire détient la
moitié privée — une adresse est donc publique par nature.

`src/machinery.ts` : l'espace de noms `urn:ng-eventually:` que la bibliothèque
se réserve, et le prédicat que le chemin de lecture utilise. La branche Header
est le premier compartiment logé dans un document que le consommateur lit ;
`read-model` écarte désormais tout sujet de cet espace, par SUJET et non par
prédicat — ce qui couvre toutes les branches émulées, présentes et futures.

Question ouverte du brief, tranchée : « une inbox de document adressable par
tout détenteur » est une invention de cette bibliothèque, pas de l'amont — aucun
document n'y a d'inbox, ni le store privé. Ce qui EST vérifié, c'est la forme
qui rend l'anticipation défendable : `AddInboxCapV0` est clé par `repo_id`.

Tests : le test qui validait « n'importe qui dépose » passait le NURI d'inbox au
déposant par une variable du test — chemin qu'aucune app n'a. Réécrit avec les
deux acteurs cloisonnés : le déposant reçoit le lien du document, qui est la
seule chose qui circule dans ce modèle, et doit trouver l'adresse lui-même. Le
fake `ng` gagne le SELECT de la branche Header et le `DELETE WHERE` (sans quoi
un remplacement devenait une accumulation, précisément le bug qu'il évite).

157 tests unitaires, e2e 40/40 contre le broker en ligne.
2026-08-03 16:02:11 +02:00

526 lines
21 KiB
TypeScript

/**
* Cross-user access — the scenario that proves the model end to end.
*
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
* REFERENCE to the protected one. Then:
*
* - **Bob** has the public document's link. He reads it, sees the reference, and
* cannot read what it points at. Naming is not reading, and publication is
* **not recursive**: a public object may point at private content without
* disclosing it.
* - **Charlie** has the public document's link AND was given the protected
* document's cap. Same reference, same path — he reads through it.
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
* inbox files it, which fires the held-caps signal, which re-runs the read — the
* protected document appears with nothing else happening.
*
* The difference between Bob and Charlie is ONLY each of them holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
documentInboxAddress,
openDocumentInbox,
resetRegistryCache,
walletInbox,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
capFor,
getCaps,
resetCaps,
setCurrentUser,
shareCap,
connectedUser,
} from "../src/polyfill";
import { post, read as readInbox } from "../src/inbox";
import { readUnion } from "../src/read-model";
import { sparqlUpdate } from "../src/docs";
import type { Nuri } from "../src/types";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The predicate Alice uses to point from her public doc at her protected one. */
const REFERS_TO = "urn:e2e:refersTo";
const SECRET = "urn:e2e:secret";
interface Quad { g: string; s: string; p: string; o: string }
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`: the shim SPARQL, the inbox SPARQL, and the anchored
* per-doc `?s ?p ?o` read the read-model uses. */
function makeFakeNg() {
const 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;
if (!anchor) return undefined;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
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 sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes(`<${SHIM}:shimDoc>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:id>`)) {
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 ?? "" },
})),
},
};
}
if (query.includes(`<${INBOX}:payload>`)) {
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;
}),
},
};
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
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_update, sparql_query, _quads: quads };
}
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** Write one triple into `doc`, as the 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");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI. Returns what each actor could plausibly come to hold.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "the-protected-content");
const pubDoc = await createEntityDoc("alice", "public");
// The reference is the BARE NURI of the protected document: it names it, and
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
return { protDoc, pubDoc, pubLink, protCap };
}
/** Follow the reference found in the public document — what a reader actually does. */
function referenceFoundIn(values: string[]): Nuri {
const ref = values[0];
expect(ref).toBeDefined();
return ref as Nuri;
}
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob was given the public document's link — "whoever has the URL reads it".
getCaps().learn(pubLink);
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(capFor(ref)).toBeUndefined();
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(capFor(ref)).toBe(protCap);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("bob");
getCaps().learn(pubLink);
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
expect(bobSees).toEqual([]);
expect(charlieSees).toEqual(["the-protected-content"]);
});
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await walletInbox("bob");
setCurrentUser("bob");
getCaps().learn(pubLink);
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
let reread = 0;
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
// happens; no "receive" call exists.
setCurrentUser("alice");
await shareCap(protCap, BOB_INBOX);
setCurrentUser("bob");
await readInbox(BOB_INBOX);
// Filing the cap fired the signal…
expect(reread).toBeGreaterThan(0);
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
// …and the read that was empty now yields the content.
expect(capFor(ref)).toBe(protCap);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
inject();
const { pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link.
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
getCaps().learn(pubLink);
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
// { read_cap }` on the User branch of the private store — and the queue is consumed.
// Re-reading a queue to recover state is using it as a database.
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc, protCap } = await aliceSetsUpHerDocuments();
const bobInbox = await walletInbox("bob");
setCurrentUser("alice");
await shareCap(protCap, bobInbox);
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
for (let k = ng._quads.length - 1; k >= 0; k--) {
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
}
resetCaps();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob");
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(capFor(protDoc)).toBe(protCap);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
inject();
setCurrentUser("nobody");
await connectedUser();
// No account, no stores, no caps — connecting must not create a user as a side
// effect, or the emulation would arm itself in the background.
expect(getCaps().isEnforcing()).toBe(false);
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
// owner records the private half with `AddInboxCap` on the User branch — the same
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
// drains them all: the user's own, and one per document it opened an inbox on.
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await walletInbox("alice"));
const link = capFor(doc)!; // the repo link alice circulates — links DO travel
// Bob RESOLVES the address himself, from the document. The only thing he is handed
// is the link, which is the one thing the model says circulates. The address is not
// passed to him — if it had to be, there would be no way for an app to get it.
setCurrentUser("bob");
getCaps().learn(link);
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and he cannot read it back: depositing grants nothing.
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it.
setCurrentUser("alice");
const deposits = await readInbox(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
});
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
const link = capFor(doc)!;
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob");
getCaps().learn(link);
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a document is addressable from creation — its owner's inbox, no second document", async () => {
inject();
setCurrentUser("alice");
const aliceInbox = await walletInbox("alice");
const doc = await createEntityDoc("alice", "public");
const link = capFor(doc)!;
// No `openDocumentInbox` anywhere: a depositor must not have to wait for the owner
// to open one. This is the window the consumer's central act falls into — signing up
// to someone else's document, before that owner ever touched an inbox.
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("opening a dedicated inbox REPLACES the published address — it never accumulates", async () => {
inject();
setCurrentUser("alice");
const aliceInbox = await walletInbox("alice");
const doc = await createEntityDoc("alice", "public");
expect(await documentInboxAddress(doc)).toBe(aliceInbox); // the general one, first
const dedicated = await openDocumentInbox(doc);
expect(dedicated).not.toBe(aliceInbox);
// A depositor resolving now must reach the DEDICATED one, and only it: a stale
// address left beside the new one is a deposit written where nobody reads.
const link = capFor(doc)!;
setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await walletInbox("alice");
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await shareCap(capFor(carolDoc)!, aliceInbox); // a Link, to alice herself
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
// Alice connects: one call, both queues.
setCurrentUser("alice");
await connectedUser();
expect(capFor(carolDoc)).toBeDefined(); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});
// The same resolution property one level up: a user's own inbox.
test("a third party resolves another user's inbox (the wallet level)", async () => {
inject();
setCurrentUser("alice");
const aliceView = await walletInbox("alice");
setCurrentUser("bob");
const bobView = await walletInbox("alice");
expect(bobView).toBe(aliceView);
});