← Provider replacement walkthrough

Commerce Protocol Example · verify-erasure.mjs

Source snapshot · 2026-09-09

SHA-256 65feaff2e8eb0a3247ecdbc0f511c8eef5e07a4c2c63155b07409ba7cb19be88
1import assert from "node:assert/strict";2import { rmSync } from "node:fs";3import { startLab } from "./server.mjs";4import { FIXTURE } from "./provider.mjs";5import { requestOperation, STAGES } from "./scenario.mjs";6import { deliver, sign } from "./webhooks.mjs";7import { WEBHOOK } from "openiap-commerce-protocol";8 9export async function verifyErasure() {10  const checks = [];11  const check = (name, actual, expected) => {12    assert.deepEqual(actual, expected, name);13    checks.push(name);14  };15  const lab = startLab();16  try {17    const { runtime } = lab;18    const call = (name, input, role) =>19      requestOperation(runtime.baseUrl, name, input, role);20    const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };21    await call("verifyPurchase", evidence);22    await call("bindPurchase", { ...evidence, userId: FIXTURE.userId });23    const pending = runtime.provider.db24      .query("SELECT body FROM outbox LIMIT 1")25      .get().body;26    const post = (body) => {27      const timestamp = String(Math.floor(runtime.now() / 1000));28      return runtime.post({29        method: "POST",30        body,31        headers: {32          [WEBHOOK.timestampHeader]: timestamp,33          [WEBHOOK.signatureHeader]: sign(runtime.secret, timestamp, body),34          [WEBHOOK.eventIdHeader]: JSON.parse(body).eventId,35        },36      });37    };38    await post(pending);39    check(40      "An active purchase has a delivered event copy",41      runtime.receiver.count(),42      1,43    );44    runtime.receiver.eraseUser(FIXTURE.userId);45    let erased;46    await deliver(47      runtime.provider,48      runtime.secret,49      runtime.now,50      async (init) => {51        erased = await call("eraseUser", { userId: FIXTURE.userId });52        return runtime.post(init);53      },54    );55    check("Erasure during delivery completes", erased.body.status, "completed");56    check(57      "An in-flight event cannot resurrect receiver data",58      runtime.receiver.count(),59      0,60    );61    check(62      "An in-flight acknowledgement cannot resurrect the outbox",63      runtime.provider.inspect().deliveries,64      [],65    );66    runtime.restart();67    check(68      "Repeated erase survives restart",69      (await call("eraseUser", { userId: FIXTURE.userId })).body,70      erased.body,71    );72    check(73      "An old signed event remains discarded after restart",74      (await (await post(pending)).json()).discarded,75      "erased-user",76    );77    const late = JSON.stringify({78      ...JSON.parse(pending),79      eventId: "late-new-event",80    });81    check(82      "A new event ID cannot bypass erasure",83      (await (await post(late)).json()).discarded,84      "erased-user",85    );86    check(87      "Verification cannot bind an erased purchase",88      (await call("verifyPurchase", evidence)).body.isValid,89      true,90    );91    check(92      "Stale binding cannot restore an erased account",93      (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body94        .bound,95      false,96    );97    check(98      "Another account cannot claim erased evidence",99      (await call("bindPurchase", { ...evidence, userId: "demo_bob" })).body100        .bound,101      false,102    );103    check(104      "Erased account is inactive before paid expiry",105      (await call("subscriptionStatus", { userId: FIXTURE.userId })).body,106      { active: false },107    );108    runtime.time = FIXTURE.expiresAt;109    runtime.provider.observe({110      id: "expiry-after-deletion",111      kind: "expire",112      occurredAt: runtime.time,113    });114    await deliver(runtime.provider, runtime.secret, runtime.now, runtime.post);115    check(116      "Late lifecycle processing carries no erased identity",117      runtime.receiver.inspect().map((event) => event.userId),118      [undefined],119    );120    const serialized = JSON.stringify([121      runtime.provider.db.query("SELECT * FROM purchases").all(),122      runtime.provider.db.query("SELECT * FROM outbox").all(),123      runtime.provider.db.query("SELECT * FROM erased_users").all(),124      runtime.receiver.inspect(),125    ]);126    check(127      "Persisted protocol records contain no erased user ID",128      serialized.includes(FIXTURE.userId),129      false,130    );131    check(132      "Unknown-user erasure is accepted",133      (await call("eraseUser", { userId: "missing_user" })).body.accepted,134      true,135    );136  } finally {137    await lab.close();138    rmSync(lab.directory, { recursive: true, force: true });139  }140  const walkthrough = startLab();141  try {142    for (let i = 0; i < STAGES.length; i++)143      await walkthrough.scenario.advance();144    checks.push(...walkthrough.scenario.history.at(-1).checks);145  } finally {146    await walkthrough.close();147    rmSync(walkthrough.directory, { recursive: true, force: true });148  }149  return checks;150}151 152if (import.meta.main)153  console.log(`${(await verifyErasure()).length} erasure checks passed.`);154