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