fix: la suite n'était pas hermétique, et deux tests ne pouvaient pas échouer

Second tour adverse sur le lot D. Trois trouvailles, et une erreur de diagnostic de ma
part qui vaut d'être consignée.

**La suite verte dépendait de l'ordre des fichiers.** `bun test isolation-active
public-store` donnait 5 échecs quand chaque fichier seul était vert — donc un checkout de
CI avec un autre ordre d'inodes livrait rouge. Deux causes distinctes :

- le travail de connexion, lancé sans être attendu par `setCurrentUser`, débordait d'un
  fichier sur le suivant et armait l'émulation. `connectedUser` abandonne désormais dès
  que l'identité pour laquelle il a démarré n'est plus connectée — ce qui est de toute
  façon la bonne sémantique : en amont une session appartient à un utilisateur, et
  changer d'utilisateur est une autre session ;
- et surtout **mon propre test de store public exposait le cap d'un document que
  personne ne détient** — un état que la bibliothèque ne produit jamais. Il ne passait
  que tant que l'émulation était désarmée. Alice crée sa note avant de l'exposer,
  maintenant. Balayage des 21 paires de fichiers : plus aucune ne pollue.

**Le contrôle symétrique ajouté hier ne pouvait pas échouer.** « La liste d'Alice ne
contient pas la note de Bob » lisait un rendu ANTÉRIEUR à l'écriture de Bob : l'attente
de `showScope` était satisfaite au premier sondage par le marqueur déjà à l'écran, sans
synchroniser quoi que ce soit. Alice écrit désormais une note APRÈS celle de Bob —
`writeNote` attend son apparition, donc ce qui suit est un rendu qui post-date. Et le
`.catch` qui avalait le délai d'attente est retiré : une liste qui ne se stabilise jamais
est un échec à voir, pas une dégradation à absorber.

**Le test anti-fork prouvait « pas le premier », pas « le canonique ».** Son minimum
lexicographique était aussi le DERNIER élément, si bien qu'un choix positionnel — la
faute exacte que ce test existe pour attraper — restait vert. Le minimum est déplacé au
milieu ; vérifié par mutation, « prendre le dernier » le fait rougir.

**Mon erreur de diagnostic.** J'ai cru trouver, sous la trouvaille d'ordre, une fuite
entre utilisateurs — les caps d'Alice classés chez Bob — et je l'ai « reproduite ». Le
repro était faux : son faux `ng` ignorait le sujet dans la requête d'inbox, donc l'inbox
de Bob résolvait vers celle d'Alice. Une fois le faux corrigé, la fuite ne se reproduit
plus, ni avec ni sans correctif. Le danger reste réel en lecture du code — trois chemins
classent des caps plusieurs `await` après la garde qui les autorisait — donc
`caps.holderKey`/`learnFor` le ferment par construction, mais les commentaires disent
maintenant ce que c'est : un risque fermé, pas un défaut observé.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
This commit is contained in:
Sylvain Duchesne
2026-08-10 10:02:45 +02:00
parent 30f6263db5
commit b7dc8ca2c3
8 changed files with 164 additions and 20 deletions
@@ -0,0 +1,37 @@
# A consumer's test harness has no way to play several identities
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings, one substantive and two defects in the contract's own text.
## 1. The substantive gap — a consumer harness cannot switch identity
`contract_sdk-surface` § *Guarantees* states the whole of signing in: `ensureIdentity()`, which takes no identifier, deliberately. The API contract adds, about `setCurrentUser`'s removal, that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not."*
That sentence holds for **this library's own** harness. It does not hold for a consumer's, and the package makes sure of it: `packages/sdk/package.json` maps exactly one entry, `"." : "./src/index.ts"`. A deep import is refused by the resolver, verified from the consumer's tree:
```
ROOT OK: configure, docChangeType, docs, ensureIdentity, inbox, init, initNg,
ng, readUnion, storeRegistry, subscribeDoc, subscribeDocs, useShape, watchShape
DEEP FAIL: Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'
```
So a consumer's multi-actor suite has **no path at all** — published or internal — to act as a second identity. The consequence is not cosmetic: a test that cannot obtain a second actor is forced to hand the first actor's values across the identity boundary through a shared variable, which is precisely the shape that hid a real bug in this library once already (a third party never reached the owner's inbox, and the green test proved nothing because the address crossed the boundary by JS scope). Losing the ability to write that test correctly costs more than the surface it saves.
What the consumer needs is narrow: **act as identity X for the duration of a block, then restore**. It is a harness capability, not an application one — the request is not to re-publish `setCurrentUser` on the application surface. A separate, explicitly-named test entry (`@ng-eventually/sdk/testing`, say) would keep the application surface exactly as it is while making the capability reachable; it would also carry its own deletion signal, since a consumer harness that plays several identities on one page is itself pure shared-wallet scaffolding.
Not proposing the shape — this is the library's call. Stating the need, and that it currently has no answer.
## 2. `watchShape`'s published signature contradicts its own types
`## Surface` publishes:
```ts
export function watchShape(query: ShapeQuery): ShapeObservable;
```
`ShapeQuery` is the **result** type (`{ data, isPending, isSuccess, isError, error }`, per `docs/api-contract.md` § 5), so as written the call takes its own return value. The signature the consumer has always called, and the one § 5 documents, is `watchShape<T>(shapeType, scope)` — two positional arguments. One of the two documents is wrong; the contract is the one consumers read.
## 3. There is no synced read for the per-document form
The inbox surface publishes `readSynced(targetInbox)` and `readForDocument(doc)`, but not their intersection. The consumer's materialization path depends on the **synced** guarantee specifically (`read` and `readSynced` differ by contract), and it addresses by document. Today it must therefore resolve an address itself to get the synced form — which is the exact gesture § *Guarantees* says an application never performs (*"an application never handles a key or an inbox address"*).
Either `readForDocument` carries the synced guarantee, or a `readSyncedForDocument(doc)` completes the pair. As it stands the document-addressed path is strictly weaker than the address-addressed one, and the contract does not say that is intentional.
+21 -7
View File
@@ -126,21 +126,32 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
* that has not re-rendered is green whether isolation holds or not — found adversarially, * that has not re-rendered is green whether isolation holds or not — found adversarially,
* 2026-08-10. * 2026-08-10.
*/ */
async function showScope(a: Actor, scope: string, settle = "les notes"): Promise<void> { async function showScope(a: Actor, scope: string, settle: string): Promise<void> {
await a.frame.locator('[data-testid="scope"]').selectOption(scope); await a.frame.locator('[data-testid="scope"]').selectOption(scope);
// The list is rebuilt wholesale; waiting for the marker the caller expects (or for the // The list is rebuilt wholesale; waiting for the marker the caller expects (or for the
// list to be empty) is the only signal the application offers. // list to be empty) is the only signal the application offers.
//
// This wait is NOT a synchronisation point when the marker is ALREADY on screen — it
// matches on the first poll and returns before the in-flight `refresh()` has done its
// broker round-trips. A check reading the list right after is then reading the previous
// render. Where a journey needs a FRESH list, it must create its own synchronisation
// point (a write it awaits), not lean on this. Found adversarially, 2026-08-10.
//
// No `.catch` swallowing the timeout either: a list that never settles is a failure to
// see, not a degradation to absorb — swallowing it reinstated the very bug this wait
// was added to fix.
await a.frame await a.frame
.locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`) .locator(`[data-testid="notes"]:has-text("${settle}"), [data-testid="notes"]:empty`)
.first() .first()
.waitFor({ timeout: 60000 }) .waitFor({ timeout: 60000 });
.catch(() => {});
} }
async function writeNote(a: Actor, scope: string, title: string, body: string): Promise<void> { async function writeNote(a: Actor, scope: string, title: string, body: string): Promise<void> {
await a.frame.locator('[data-testid="title"]').fill(title); await a.frame.locator('[data-testid="title"]').fill(title);
await a.frame.locator('[data-testid="body"]').fill(body); await a.frame.locator('[data-testid="body"]').fill(body);
await showScope(a, scope); // No settle marker to wait for here: the write below is its own synchronisation point,
// and the shelf we are switching to may legitimately be empty or hold anything.
await a.frame.locator('[data-testid="scope"]').selectOption(scope);
await a.frame.locator('[data-testid="write"]').click(); await a.frame.locator('[data-testid="write"]').click();
await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 }); await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 });
} }
@@ -279,11 +290,14 @@ async function main(): Promise<void> {
await showScope(bob, "public", "Vélo"); await showScope(bob, "public", "Vélo");
const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? ""; const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? "";
await showScope(alice, "public", "Courses"); // Alice's list has to be re-rendered AFTER Bob's note exists, or "she does not see
await alice.frame.locator('li:has-text("Courses")').waitFor({ timeout: 60000 }); // it" is read off a stale snapshot and holds whatever the boundary does. Writing a
// note is the synchronisation point the application offers: `writeNote` awaits the
// new entry appearing, so what follows is a render that post-dates Bob's.
await writeNote(alice, "public", "Timbres", "en acheter un carnet");
const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? ""; const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? "";
check("Alice sees her own note", aliceList.includes("Courses"), aliceList.slice(0, 60)); check("Alice sees her own notes", aliceList.includes("Courses") && aliceList.includes("Timbres"), aliceList.slice(0, 60));
check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60)); check("Bob sees HIS own note — the control that lets the next check fail", bobList.includes("Vélo"), bobList.slice(0, 60));
check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60)); check("Bob's list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 60));
check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60)); check("Alice's list does not contain Bob's note", !aliceList.includes("Vélo"), aliceList.slice(0, 60));
+35 -3
View File
@@ -133,9 +133,33 @@ export class CapRegistry {
// --- what the holder holds ---------------------------------------------- // --- what the holder holds ----------------------------------------------
/**
* The key of the holder currently connected — capture it when you DECIDE that a cap is
* someone's, and hand it back to {@link learnFor} when you file.
*
* **A hazard closed, not a leak observed** — the distinction matters and I got it wrong
* once while writing this. Filing resolves the holder at the moment it runs, and three
* paths file several `await`s after the check that authorised them (connecting, reading
* an inbox, listing one's own documents). So an application switching identity in the
* gap COULD have the first identity's caps filed into the second one's ring. That is
* structural and visible by reading. What was NOT established is that it happens: the
* reproduction that seemed to show it turned out to be a broken test fake, and once the
* fake was corrected the leak did not reproduce.
*
* The pairing stays because it costs one argument and removes the hazard by
* construction, where a re-check at each of three sites is a discipline. It is not
* evidence of a bug that was found.
*/
holderKey(): string {
return this.holder() ?? ANONYMOUS;
}
/** What the current holder holds, created on first use. */ /** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> { private heldCaps(): Map<Nuri, ReadCap> {
const key = this.holder() ?? ANONYMOUS; return this.ringFor(this.holderKey());
}
private ringFor(key: string): Map<Nuri, ReadCap> {
let ring = this.heldByHolder.get(key); let ring = this.heldByHolder.get(key);
if (!ring) this.heldByHolder.set(key, (ring = new Map())); if (!ring) this.heldByHolder.set(key, (ring = new Map()));
return ring; return ring;
@@ -155,7 +179,7 @@ export class CapRegistry {
* *
* Returns whether the cap was new. * Returns whether the cap was new.
*/ */
private file(cap: ReadCap): boolean { private file(cap: ReadCap, key: string = this.holderKey()): boolean {
if (!hasReadCap(cap)) { if (!hasReadCap(cap)) {
throw new Error( throw new Error(
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " + "[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
@@ -163,7 +187,7 @@ export class CapRegistry {
); );
} }
const target = targetOf(cap); const target = targetOf(cap);
const ring = this.heldCaps(); const ring = this.ringFor(key);
if (ring.get(target) === cap) return false; if (ring.get(target) === cap) return false;
ring.set(target, cap); ring.set(target, cap);
this.issued = true; this.issued = true;
@@ -206,6 +230,14 @@ export class CapRegistry {
this.file(cap); this.file(cap);
} }
/**
* File a cap for a NAMED holder — the one the caller decided for, not whoever happens
* to be connected when the `await` resumes. See {@link holderKey}.
*/
learnFor(key: string, cap: ReadCap): void {
this.file(cap, key);
}
/** /**
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the * File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading * emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
+27 -2
View File
@@ -56,6 +56,24 @@ export async function connectedUser(): Promise<void> {
const pending = inFlight.get(holder); const pending = inFlight.get(holder);
if (pending) return pending; if (pending) return pending;
/**
* Is `holder` still the connected identity?
*
* This work is fired un-awaited by `setCurrentUser`, and everything below resolves the
* CURRENT holder when it reads a register — `readLinks` and `myInboxes` both ask
* `getCurrentUser()` at the moment they run. After a switch they would therefore read
* the WRONG user's registers.
*
* The observed symptom was narrower and entirely in the tests: in-flight work from one
* test file armed the cap emulation in the next, making the suite's green depend on
* file order. Abandoning is right for both reasons, and it is what upstream implies —
* a session belongs to one user, and switching user is another session. Nothing is
* lost: the next connection picks it up.
*/
const stillConnected = (): boolean => getCurrentUser() === holder;
// Captured with the identity, handed back at filing time — see `caps.holderKey`.
const holderKey = getCaps().holderKey();
const run = (async (): Promise<void> => { const run = (async (): Promise<void> => {
try { try {
// Connecting must not PROVISION. `ensureAccount` would create the user on // Connecting must not PROVISION. `ensureAccount` would create the user on
@@ -64,15 +82,22 @@ export async function connectedUser(): Promise<void> {
// background side effect, at a moment nothing controls. An account that does // background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain. // not exist has nothing to restore and no inbox to drain.
if ((await resolveAccount(holder)) === null) return; if ((await resolveAccount(holder)) === null) return;
if (!stillConnected()) return;
// 1. Durable first: what this user has already applied. // 1. Durable first: what this user has already applied.
for (const cap of await readLinks()) getCaps().learn(cap); const links = await readLinks();
if (!stillConnected()) return;
for (const cap of links) getCaps().learnFor(holderKey, cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per // 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and // document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`). // both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it // Sequential rather than parallel: each `processInbox` writes what it
// applies to the SAME private store, and interleaving those writes buys // applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty. // nothing on a queue that is nearly always empty.
for (const inbox of await myInboxes()) await processInbox(inbox); const inboxes = await myInboxes();
for (const inbox of inboxes) {
if (!stillConnected()) return;
await processInbox(inbox);
}
} catch { } catch {
// Not configured yet, or offline. Nothing to restore, and connecting must // Not configured yet, or offline. Nothing to restore, and connecting must
// not fail because a queue could not be reached — the next connection, or // not fail because a queue could not be reached — the next connection, or
@@ -1113,7 +1113,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder !== null && accountKey(holder) === accountKey(id)) { if (holder !== null && accountKey(holder) === accountKey(id)) {
const caps = getCaps(); const caps = getCaps();
for (const cap of await readStoreCaps(store)) caps.learn(cap); // The holder was decided just above; `readStoreCaps` awaits, and filing resolves the
// holder when it runs — so hand the captured key back rather than trust that the
// identity has not moved. See `caps.holderKey`.
const holderKey = caps.holderKey();
for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
// Which store a document sits in is a registry fact, not one recorded beside the // Which store a document sits in is a registry fact, not one recorded beside the
// caps, so it is re-applied here. Marking only — the caps just came from the Store // caps, so it is re-applied here. Marking only — the caps just came from the Store
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns // branch above, and minting a second one beside them is the trap `holdOwnCap` warns
+14 -1
View File
@@ -403,6 +403,10 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> { export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.read"); const targetInbox = toNuri(targetInboxLike, "inbox.read");
await assertOwnInbox(targetInbox, "read"); await assertOwnInbox(targetInbox, "read");
// WHO this read belongs to, captured with the guard that authorised it — see the note
// beside the filing below, and `caps.holderKey`.
const owner = getCurrentUser();
const ownerKey = getCaps().holderKey();
const sid = await sessionId(); const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it // NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch` // (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
@@ -444,10 +448,19 @@ export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
// view that was empty for want of that cap re-read instead of staying stale. // view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = []; const delivered: Deposit[] = [];
const links: ReadCap[] = []; const links: ReadCap[] = [];
// The ownership guard ran at entry; the filing happens several awaits later, and filing
// resolves WHO is holding at that moment. So an application switching identity in the
// gap could have this inbox's caps land in the NEW holder's ring. A hazard read off the
// code, not a leak anyone reproduced — see `caps.holderKey`.
//
// Abandoning is the faithful answer: upstream an inbox is processed by ITS owner's
// verifier, and switching user is another session. Nothing is lost — an inbox is not
// consumed by reading, so the next connection under the right identity files them.
const stillOwner = getCurrentUser() === owner;
for (const d of deposits) { for (const d of deposits) {
const cap = capOfPayload(d.payload); const cap = capOfPayload(d.payload);
if (cap) { if (cap) {
getCaps().learn(cap); if (stillOwner) getCaps().learnFor(ownerKey, cap);
links.push(cap); links.push(cap);
continue; continue;
} }
+5 -1
View File
@@ -209,8 +209,12 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", (
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim. // Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim }); fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const subj = "urn:ng-eventually:shim:account:dupuser"; const subj = "urn:ng-eventually:shim:account:dupuser";
// The minimum must sit NEITHER first NOR last, or the test cannot tell a canonical
// pick from a positional one. It used to end on `pub-a`, so `rows[rows.length - 1]`
// — an order-dependent pick, precisely the fault this test exists to catch — passed
// it. Only `rows[0]` failed. Found by mutation, 2026-08-10.
const dupPublics = [ const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a", "did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-z",
]; ];
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" }); fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics) for (const p of dupPublics)
+20 -5
View File
@@ -55,6 +55,21 @@ afterEach(() => {
setCurrentUser(null); setCurrentUser(null);
}); });
/**
* Alice creates her note and exposes its cap — the two halves of what `createEntityDoc`
* does for a `public` scope, in that order.
*
* The `mint` is not decoration: exposing writes to the document, and writing needs to
* reach it. Without it this file only passed while the emulation happened to be
* DISARMED, which made its results depend on which test file ran first — it went red in
* `bun test <other-file> test/public-store.test.ts`. A fixture that exposes a cap for a
* document nobody holds describes a state the library never produces.
*/
async function aliceExposesHerNote(): Promise<void> {
getCaps().mint(PUB);
await exposeReadCap(PUB, mintCap(PUB));
}
/** Arm the emulation without giving the current holder anything: some OTHER document. */ /** Arm the emulation without giving the current holder anything: some OTHER document. */
function armEmulation(): void { function armEmulation(): void {
setCurrentUser("someone-else"); setCurrentUser("someone-else");
@@ -66,7 +81,7 @@ const PUB = "did:ng:o:pub" as Nuri;
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => { test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB)); await aliceExposesHerNote();
setCurrentUser("bob"); setCurrentUser("bob");
armEmulation(); armEmulation();
@@ -91,7 +106,7 @@ test("a document that exposes nothing yields nothing — that is the normal case
test("a cap naming ANOTHER document is refused, not filed", async () => { test("a cap naming ANOTHER document is refused, not filed", async () => {
const { quads } = inject(); const { quads } = inject();
setCurrentUser("alice"); setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB)); await aliceExposesHerNote();
// Forge the exposed value so it names a different document. // Forge the exposed value so it names a different document.
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri); quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
@@ -116,7 +131,7 @@ test("inert while no cap has been issued at all — nothing to obtain, nothing a
test("a public store serves every asker, not only the first", async () => { test("a public store serves every asker, not only the first", async () => {
inject(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB)); await aliceExposesHerNote();
armEmulation(); armEmulation();
setCurrentUser("bob"); setCurrentUser("bob");
@@ -131,7 +146,7 @@ test("a public store serves every asker, not only the first", async () => {
test("asked once per document: the outcome is memoised, in both directions", async () => { test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject(); const { sparql_query } = inject();
setCurrentUser("alice"); setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB)); await aliceExposesHerNote();
armEmulation(); armEmulation();
setCurrentUser("bob"); setCurrentUser("bob");
@@ -150,7 +165,7 @@ test("asked once per document: the outcome is memoised, in both directions", asy
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => { test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
const { sparql_query } = inject(); const { sparql_query } = inject();
setCurrentUser("alice"); setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB)); await aliceExposesHerNote();
armEmulation(); armEmulation();
setCurrentUser("bob"); setCurrentUser("bob");
await fetchReadCap(PUB); await fetchReadCap(PUB);