1import assert from "node:assert/strict";2import { rmSync } from "node:fs";3import vectors from "openiap-commerce-protocol/vectors/signatures.json";4import { SUBSCRIPTION_STATES, WEBHOOK } from "openiap-commerce-protocol";5import { FIXTURE, isEntitled } from "./provider.mjs";6import { requestOperation } from "./scenario.mjs";7import { runConsumerDemo } from "./consumer.mjs";8import { runBridgeDemo } from "./client-bridge.mjs";9import { startLab } from "./server.mjs";10import { authentic, createReceiver, deliver, sign } from "./webhooks.mjs";11import { verifyStores } from "./verify-stores.mjs";12import { verifyErasure } from "./verify-erasure.mjs";13 14export async function verifyLab({ compareSigner } = {}) {15 const lab = startLab();16 const checks = [];17 const check = (label, actual, expected) => {18 assert.deepEqual(actual, expected, label);19 checks.push(label);20 };21 try {22 for (let i = 0; i < 6; i++) await lab.scenario.advance();23 checks.push(...lab.scenario.history.flatMap((entry) => entry.checks));24 for (const vector of vectors.cases) {25 const signature = sign(vector.secret, vector.timestamp, vector.body);26 const expected = vector.previousSecret27 ? `${signature},${sign(vector.previousSecret, vector.timestamp, vector.body)}`28 : signature;29 check(`Signature vector: ${vector.name}`, expected, vector.expected);30 if (compareSigner) {31 check(32 `IAPKit signs the same bytes: ${vector.name}`,33 signature,34 await compareSigner(vector.secret, vector.timestamp, vector.body),35 );36 }37 check(38 `Receiver accepts vector: ${vector.name}`,39 authentic(40 [vector.secret],41 String(vector.timestamp),42 vector.body,43 vector.presentedHeader ?? expected,44 vector.timestamp,45 ),46 true,47 );48 }49 for (const vector of vectors.rejections) {50 check(51 `Receiver rejects: ${vector.name}`,52 authentic(53 [vector.secret],54 String(vector.timestamp),55 vector.body,56 vector.presentedSignature,57 vector.receiverNow ?? vector.timestamp,58 ),59 false,60 );61 }62 for (const state of [...SUBSCRIPTION_STATES, "FutureState"]) {63 const eligible = state === "Active" || state === "InGracePeriod";64 check(`${state}: before expiry`, isEntitled(state, 100, 99), eligible);65 check(`${state}: at expiry`, isEntitled(state, 100, 100), false);66 check(67 `${state}: no deadline`,68 isEntitled(state, undefined, 100),69 eligible,70 );71 }72 const call = (name, input, role) =>73 requestOperation(lab.runtime.baseUrl, name, input, role);74 check(75 "Missing credentials are refused",76 (await call("entitlements", { userId: FIXTURE.userId }, null)).httpStatus,77 401,78 );79 check(80 "Verification role cannot enumerate users",81 (await call("entitlements", { userId: FIXTURE.userId }, "verification"))82 .httpStatus,83 403,84 );85 check(86 "Malformed input is refused",87 (await call("bindPurchase", { store: FIXTURE.store })).httpStatus,88 400,89 );90 check(91 "A real store is not falsely accepted",92 (93 await call("verifyPurchase", {94 store: "google",95 google: { purchaseToken: "fixture" },96 })97 ).body.error.code,98 "UNSUPPORTED_STORE",99 );100 check(101 "Erasure rejects verification credentials",102 (await call("eraseUser", { userId: FIXTURE.userId }, "verification"))103 .httpStatus,104 403,105 );106 check(107 "Cancellation after expiry is ignored",108 lab.runtime.provider.observe({109 id: "late-cancel",110 kind: "cancel",111 occurredAt: FIXTURE.expiresAt,112 }),113 false,114 );115 const expiredVerdict = await call("verifyPurchase", {116 store: FIXTURE.store,117 evidence: FIXTURE.evidence,118 });119 check(120 "Expired fixture evidence has an expired verdict",121 [expiredVerdict.body.isValid, expiredVerdict.body.state],122 [false, "EXPIRED"],123 );124 const body = lab.runtime.provider.db125 .query("SELECT body FROM outbox LIMIT 1")126 .get().body;127 const timestamp = Math.floor(lab.runtime.time / 1000).toString();128 const invalid = await lab.runtime.receiver.fetch(129 new Request(`${lab.runtime.baseUrl}/demo/receiver`, {130 method: "POST",131 body: body + " ",132 headers: {133 [WEBHOOK.timestampHeader]: timestamp,134 [WEBHOOK.signatureHeader]: sign(lab.runtime.secret, timestamp, body),135 },136 }),137 );138 check("Tampered HTTP body has no inbox effect", invalid.status, 401);139 const byteReceiver = createReceiver(140 ":memory:",141 lab.runtime.secret,142 () => lab.runtime.time,143 );144 try {145 const event = { ...JSON.parse(body), userId: "demo_\uFFFD" };146 const unicode = Buffer.from(JSON.stringify(event));147 const offset = unicode.indexOf(Buffer.from("\uFFFD"));148 const malformed = Buffer.concat([149 unicode.subarray(0, offset),150 Buffer.from([0xff]),151 unicode.subarray(offset + 3),152 ]);153 const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), unicode]);154 const send = (bytes, signedBytes = unicode) =>155 byteReceiver.fetch(156 new Request(`${lab.runtime.baseUrl}/demo/receiver`, {157 method: "POST",158 body: bytes,159 headers: {160 [WEBHOOK.timestampHeader]: timestamp,161 [WEBHOOK.signatureHeader]: sign(162 lab.runtime.secret,163 timestamp,164 signedBytes,165 ),166 [WEBHOOK.eventIdHeader]: event.eventId,167 },168 }),169 );170 for (const [label, bytes] of [171 ["Changed UTF-8 bytes", malformed],172 ["Inserted UTF-8 BOM", withBom],173 ]) {174 check(175 `${label} cannot reuse a signature`,176 [(await send(bytes)).status, byteReceiver.count()],177 [401, 0],178 );179 }180 check(181 "Authenticated malformed UTF-8 is rejected before storage",182 [(await send(malformed, malformed)).status, byteReceiver.count()],183 [400, 0],184 );185 check(186 "Authentic Unicode bytes are accepted and stored",187 [(await send(unicode)).status, byteReceiver.count()],188 [200, 1],189 );190 check(191 "Authentic BOM bytes are verified before decoding",192 [(await send(withBom, withBom)).status, byteReceiver.count()],193 [200, 1],194 );195 } finally {196 byteReceiver.close();197 }198 check(199 "Receiver still has exactly four events",200 lab.runtime.receiver.count(),201 4,202 );203 check(204 "Cross-origin demo mutations are refused",205 (206 await fetch(`${lab.runtime.baseUrl}/demo/advance`, {207 method: "POST",208 headers: { origin: "https://untrusted.example" },209 })210 ).status,211 403,212 );213 } finally {214 await lab.close();215 rmSync(lab.directory, { recursive: true, force: true });216 }217 218 const fresh = startLab();219 try {220 const call = (name, input) =>221 requestOperation(fresh.runtime.baseUrl, name, input);222 const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };223 await call("verifyPurchase", evidence);224 const bindings = await Promise.all(225 ["alice", "bob"].map((userId) =>226 call("bindPurchase", { ...evidence, userId }),227 ),228 );229 check(230 "Overlapping HTTP ownership claims have one winner",231 bindings.filter((row) => row.body.bound).length,232 1,233 );234 check(235 "A cancellation older than the active row is ignored",236 fresh.runtime.provider.observe({237 id: "old-active-cancel",238 kind: "cancel",239 occurredAt: FIXTURE.startsAt - 1,240 }),241 false,242 );243 check(244 "Ignoring an old cancellation preserves renewal",245 fresh.runtime.provider.inspect().purchases[0].willRenew,246 1,247 );248 assert.throws(249 () =>250 fresh.runtime.provider.observe({251 id: "early-expiry",252 kind: "expire",253 occurredAt: FIXTURE.startsAt,254 }),255 /reconciliation/,256 );257 check(258 "Conflicting expiry is not consumed",259 fresh.runtime.provider.db260 .query(261 "SELECT COUNT(*) AS count FROM observations WHERE id = 'early-expiry'",262 )263 .get().count,264 0,265 );266 fresh.runtime.time += 1000;267 const observation = {268 id: "atomic-cancel",269 kind: "cancel",270 occurredAt: fresh.runtime.time,271 };272 fresh.runtime.provider.db.exec(273 "CREATE TRIGGER fail_outbox BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;",274 );275 assert.throws(() => fresh.runtime.provider.observe(observation));276 check(277 "Outbox failure rolls back subscription state",278 fresh.runtime.provider.inspect().purchases[0].willRenew,279 1,280 );281 fresh.runtime.provider.db.exec("DROP TRIGGER fail_outbox");282 check(283 "Failed transaction leaves the observation retryable",284 fresh.runtime.provider.observe(observation),285 true,286 );287 for (let attempt = 0; attempt < 3; attempt++) {288 fresh.runtime.time += 120_000;289 await deliver(290 fresh.runtime.provider,291 fresh.runtime.secret,292 fresh.runtime.now,293 async () => new Response(null, { status: 503 }),294 );295 }296 check(297 "Exhausted retries enter dead-letter",298 fresh.runtime.provider.inspect().deliveries[0].status,299 "dead-letter",300 );301 } finally {302 await fresh.close();303 rmSync(fresh.directory, { recursive: true, force: true });304 }305 const binding = startLab();306 try {307 const call = (name, input) =>308 requestOperation(binding.runtime.baseUrl, name, input);309 const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };310 await call("verifyPurchase", evidence);311 binding.runtime.provider.db.exec(312 "CREATE TRIGGER fail_grant BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'injected disk failure'); END;",313 );314 check(315 "Grant failure rejects binding",316 (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId }))317 .httpStatus,318 500,319 );320 check(321 "Grant failure rolls back ownership",322 binding.runtime.provider.inspect().purchases[0].userId,323 null,324 );325 binding.runtime.provider.db.exec("DROP TRIGGER fail_grant");326 binding.runtime.time = FIXTURE.expiresAt;327 check(328 "An expired purchase can be bound",329 (await call("bindPurchase", { ...evidence, userId: FIXTURE.userId })).body330 .bound,331 true,332 );333 check(334 "Binding expired evidence emits no grant",335 binding.runtime.provider.inspect().deliveries,336 [],337 );338 } finally {339 await binding.close();340 rmSync(binding.directory, { recursive: true, force: true });341 }342 const delayed = startLab();343 try {344 const call = (name, input) =>345 requestOperation(delayed.runtime.baseUrl, name, input);346 const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };347 await call("verifyPurchase", evidence);348 delayed.runtime.time += 3_600_000;349 await call("bindPurchase", { ...evidence, userId: FIXTURE.userId });350 const grant = JSON.parse(351 delayed.runtime.provider.db.query("SELECT body FROM outbox LIMIT 1").get()352 .body,353 );354 check(355 "Delayed binding retains the store occurrence",356 grant.occurredAt,357 FIXTURE.startsAt,358 );359 check(360 "Delayed binding records its actual processing time",361 grant.processedAt,362 delayed.runtime.time,363 );364 delayed.runtime.restart();365 delayed.runtime.time = FIXTURE.expiresAt + 60_000;366 delayed.runtime.provider.observe({367 id: "late-expiry",368 kind: "expire",369 occurredAt: delayed.runtime.time,370 });371 check(372 "Late expiry revokes a persisted grant exactly once",373 delayed.runtime.provider.inspect().deliveries.map((row) => row.eventType),374 ["entitlement.granted", "subscription.expired", "entitlement.revoked"],375 );376 delayed.runtime.provider.observe({377 id: "another-expiry",378 kind: "expire",379 occurredAt: delayed.runtime.time,380 });381 check(382 "Repeated expiry emits no second revocation",383 delayed.runtime.provider.inspect().deliveries.length,384 3,385 );386 } finally {387 await delayed.close();388 rmSync(delayed.directory, { recursive: true, force: true });389 }390 const equalTime = startLab();391 try {392 const call = (name, input) =>393 requestOperation(equalTime.runtime.baseUrl, name, input);394 const evidence = { store: FIXTURE.store, evidence: FIXTURE.evidence };395 await call("verifyPurchase", evidence);396 await call("bindPurchase", { ...evidence, userId: FIXTURE.userId });397 equalTime.runtime.time = FIXTURE.expiresAt;398 equalTime.runtime.provider.observe({399 id: "cancel-at-deadline",400 kind: "cancel",401 occurredAt: FIXTURE.expiresAt,402 });403 equalTime.runtime.provider.observe({404 id: "expire-at-deadline",405 kind: "expire",406 occurredAt: FIXTURE.expiresAt,407 });408 check(409 "Equal-time expiry retains its lifecycle transition",410 equalTime.runtime.provider.inspect().purchases[0].state,411 "Expired",412 );413 check(414 "Equal-time observations revoke once and preserve both transitions",415 equalTime.runtime.provider416 .inspect()417 .deliveries.map((row) => row.eventType),418 [419 "entitlement.granted",420 "subscription.canceled",421 "entitlement.revoked",422 "subscription.expired",423 ],424 );425 } finally {426 await equalTime.close();427 rmSync(equalTime.directory, { recursive: true, force: true });428 }429 checks.push(...(await runConsumerDemo()).checks);430 checks.push(...runBridgeDemo());431 checks.push(...(await verifyStores()));432 checks.push(...(await verifyErasure()));433 return checks;434}435 436if (import.meta.main) {437 const checks = await verifyLab();438 console.log(439 `Commerce Protocol Example: ${checks.length} checks passed. No store or production service contacted.`,440 );441}442