b62bfe1e63
`openDocumentInbox` passe du shim aux registres de branche. Ce qu'il fait est de
la comptabilité du verifier : vérifier la propriété, enregistrer la moitié
lecture sur la branche User, publier l'adresse. Seule la création du document
support relève du shim, et elle est appelée, pas hébergée. En amont l'acte
équivalent est générer une paire de clés et commiter `AddInboxCap`.
Deux risques de migration signalés par le contrat interne, transformés en
invariants vérifiés plutôt que supposés :
- **Le couple `(document, inbox)`** est un littéral RDF séparé par une espace là
où l'amont a une structure typée (`AddInboxCapV0 { repo_id, overlay, priv_key }`).
L'espace est sûr parce qu'un NURI n'en contient pas — alphabet base64url et
segments `:` — mais c'était une propriété implicite. `encodeInboxCap` la
vérifie désormais : un découpage erroné classerait une inbox sous un document
tronqué et perdrait les dépôts sans erreur, la classe de panne que ce chemin a
déjà payée une fois.
- **Le namespace réservé** garantit qu'aucun identifiant utilisateur ne peut s'y
loger — sauf que `normalizeId` est injecté par le consommateur et que le
défaut de la bibliothèque ne fait que trimmer. Une collision ne serait pas
cosmétique : un utilisateur se retrouverait sur un compte d'infrastructure, à
lire et écrire des documents qui ne sont pas les siens. Vérifié à la
normalisation, avec un test qui simule un `normalizeId` hostile.
160 tests unitaires, typecheck src/test/e2e vert, e2e 40/40 contre le broker.
527 lines
21 KiB
TypeScript
527 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,
|
|
resetRegistryCache,
|
|
userInbox,
|
|
} from "../src/shared-wallet/account-registry";
|
|
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import {
|
|
configure,
|
|
configureStoreRegistry,
|
|
resetStoreRegistry,
|
|
resetConfig,
|
|
capFor,
|
|
getCaps,
|
|
resetCaps,
|
|
setCurrentUser,
|
|
shareCap,
|
|
connectedUser,
|
|
} from "../src/polyfill";
|
|
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
|
import { readUnion } from "../src/surface/read-model";
|
|
import { sparqlUpdate } from "../src/surface/docs";
|
|
import type { Nuri } from "../src/model/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 userInbox("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 userInbox("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 userInbox("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 userInbox("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 userInbox("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 fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
const link = capFor(doc)!;
|
|
|
|
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
|
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
|
// at one inbox is a relation the model cannot express.
|
|
setCurrentUser("bob");
|
|
getCaps().learn(link);
|
|
expect(await documentInboxAddress(doc)).toBeUndefined();
|
|
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
|
|
// whole path exists to close.
|
|
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
|
|
});
|
|
|
|
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
const dedicated = await openDocumentInbox(doc);
|
|
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
|
|
|
const link = capFor(doc)!;
|
|
setCurrentUser("bob");
|
|
getCaps().learn(link);
|
|
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
|
// The deposit reaches the owner, addressed by the document alone.
|
|
await postToDocument(doc, { payload: { signingUp: true } });
|
|
setCurrentUser("alice");
|
|
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
|
|
});
|
|
|
|
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 userInbox("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 userInbox("alice");
|
|
setCurrentUser("bob");
|
|
const bobView = await userInbox("alice");
|
|
expect(bobView).toBe(aliceView);
|
|
});
|