← Provider replacement walkthrough

IAPKit · scripts/docs/commerce-store-coverage.mjs

Source snapshot · 2026-10-21

SHA-256 bec5ee158580fa0b654bd3abaf1b988cd020a559389deb62e22ca3b4e0d1baad
1import assert from "node:assert/strict";2import { join } from "node:path";3 4// Invoked only inside run-commerce-interop's isolated deployment.5export async function runStoreCoverage({6  example,7  output,8  key,9  kitUrl,10  receiver,11  appleJws,12  storeState,13  inspect,14  check,15  trace,16}) {17  const { createProvider, CREDENTIALS } = await import(18    join(example, "provider.mjs")19  );20  const { createCommerceClient } = await import(21    join(example, "composition/commerce-client.mjs")22  );23  const { startAppBackend } = await import(24    join(example, "composition/app-backend.mjs")25  );26  const kit = createCommerceClient({27    baseUrl: kitUrl,28    credential: `Bearer ${key}`,29  });30  let sqlite;31  const providerServer = Bun.serve({32    hostname: "127.0.0.1",33    port: 0,34    fetch: (request) => sqlite.fetch(request),35  });36  const exampleUrl = `http://127.0.0.1:${providerServer.port}`;37  const fixtureClient = createCommerceClient({38    baseUrl: exampleUrl,39    credential: CREDENTIALS.server,40  });41  const app = startAppBackend({42    path: join(output, "all-stores-app.sqlite"),43    receiver,44    providers: {45      example: { baseUrl: exampleUrl, credential: CREDENTIALS.server },46      iapkit: { baseUrl: kitUrl, credential: `Bearer ${key}` },47    },48    resolveSession: (request) =>49      request.headers.get("authorization")?.replace("Bearer ", ""),50    resolveStoreUser: (request, store) =>51      request.headers.get("authorization") === `Bearer store_${store}`52        ? `store-user-${store}`53        : null,54  });55  const endpoints = [app.url, receiver.url];56  const results = {};57  async function request(58    store,59    path,60    method = "GET",61    input,62    session = `store_${store}`,63  ) {64    const response = await fetch(app.url + path, {65      method,66      headers: {67        authorization: `Bearer ${session}`,68        "content-type": "application/json",69      },70      ...(input ? { body: JSON.stringify(input) } : {}),71    });72    const result = await response.json().catch(() => null);73    trace.push({ store, path, method, status: response.status, result });74    return { status: response.status, result };75  }76  try {77    for (const store of ["apple", "amazon", "horizon"]) {78      const input =79        store === "apple"80          ? { store, apple: { jws: appleJws } }81          : store === "amazon"82            ? {83                store,84                amazon: {85                  userId: "store-user-amazon",86                  receiptId: "local-amazon-receipt",87                  sandbox: true,88                },89              }90            : {91                store,92                horizon: {93                  userId: "store-user-horizon",94                  sku: "premium.monthly",95                },96              };97      const userId = `store_${store}`;98      const evidence =99        store === "apple"100          ? appleJws101          : store === "amazon"102            ? JSON.stringify([103                input.amazon.userId,104                input.amazon.receiptId,105                true,106              ])107            : JSON.stringify([input.horizon.userId, input.horizon.sku]);108      const fixture = {109        store,110        evidence,111        userId,112        productId: "premium.monthly",113        startsAt: Date.now(),114        expiresAt: Date.now() + 3600000,115        pointInTime: store !== "apple",116        currentVerdict: () => storeState[store],117      };118      sqlite = createProvider(119        join(output, `store-${store}.sqlite`),120        Date.now,121        fixture,122      );123      results[store] = {};124      for (const [name, client] of [125        ["example", fixtureClient],126        ["iapkit", kit],127      ]) {128        app.select(name);129        check(130          `${store}/${name}: keeps the same app and receiver`,131          [app.url, receiver.url],132          endpoints,133        );134        const before = await client.call("entitlements", { userId });135        check(`${store}/${name}: starts without access`, before.productIds, []);136        check(137          `${store}/${name}: unverified evidence cannot bind`,138          (await client.call("bindPurchase", { ...input, userId })).bound,139          false,140        );141        const verified = await client.call("verifyPurchase", input);142        check(143          `${store}/${name}: verifies the store-specific input`,144          verified.isValid,145          true,146        );147        check(148          `${store}/${name}: verification alone grants no account access`,149          (await client.call("entitlements", { userId })).productIds,150          [],151        );152        const bought = await request(store, "/purchase", "POST", input);153        check(`${store}/${name}: app purchase succeeds`, bought.status, 200);154        check(155          `${store}/${name}: app receives access`,156          bought.result?.productIds,157          ["premium.monthly"],158        );159        check(160          `${store}/${name}: binding is repeatable`,161          (await client.call("bindPurchase", { ...input, userId })).bound,162          true,163        );164        check(165          `${store}/${name}: another account cannot claim the purchase`,166          (await client.call("bindPurchase", { ...input, userId: "store_bob" }))167            .bound,168          false,169        );170        const access = await client.call("entitlements", { userId });171        check(172          `${store}/${name}: access includes only verified products`,173          access.productIds,174          ["premium.monthly"],175        );176        check(177          `${store}/${name}: tokenless access does not leak store evidence`,178          JSON.stringify(access).includes("store-user-") ||179            JSON.stringify(access).includes("local-amazon-receipt"),180          false,181        );182        if (store !== "apple") {183          if (store === "amazon" && name === "iapkit") {184            check(185              "amazon/iapkit: returns the RVS-verified subscription",186              access.subscriptions.map(187                ({ productId, store, state, active }) => ({188                  productId,189                  store,190                  state,191                  active,192                }),193              ),194              [195                {196                  productId: "premium.monthly",197                  store,198                  state: "Active",199                  active: true,200                },201              ],202            );203            check(204              "amazon/iapkit: does not invent expiry or renewal status",205              access.subscriptions.map(({ expiresAt, willRenew }) => ({206                expiresAt,207                willRenew,208              })),209              [{ expiresAt: undefined, willRenew: undefined }],210            );211          } else {212            check(213              `${store}/${name}: does not invent subscription lifecycle records`,214              access.subscriptions,215              [],216            );217          }218          const forged = {219            store,220            [store]: { ...input[store], userId: "someone-else" },221          };222          check(223            `${store}/${name}: rejects another store account in the app session`,224            (await request(store, "/purchase", "POST", forged)).status,225            403,226          );227          storeState[store] = "outage";228          check(229            `${store}/${name}: an upstream outage fails the access read`,230            (await request(store, "/access")).status,231            503,232          );233          storeState[store] = false;234          const rejected = await client.call("entitlements", { userId });235          check(236            `${store}/${name}: a negative store recheck removes access`,237            rejected.productIds,238            [],239          );240          check(241            `${store}/${name}: a negative recheck leaves no entitled subscription`,242            rejected.subscriptions,243            [],244          );245          storeState[store] = true;246          check(247            `${store}/${name}: rechecked ownership restores access`,248            (await client.call("entitlements", { userId })).productIds,249            ["premium.monthly"],250          );251          if (name === "iapkit") {252            const before = await inspect();253            await client.call("entitlements", { userId });254            const after = await inspect();255            if (store === "amazon") {256              const canonicalState = (snapshot) => ({257                purchases: snapshot.purchases258                  .filter((row) => row.appUserId === userId)259                  .map(({ _id, state, isValid, productId }) => ({260                    _id,261                    state,262                    isValid,263                    productId,264                  })),265                subscriptions: snapshot.subscriptions266                  .filter((row) => row.userId === userId)267                  .map(({ _id, state, productId, store }) => ({268                    _id,269                    state,270                    productId,271                    store,272                  })),273                events: snapshot.events.map(({ _id }) => _id),274              });275              check(276                "amazon/iapkit: unchanged rechecks preserve canonical rows and events",277                canonicalState(after),278                canonicalState(before),279              );280            } else {281              check(282                `${store}/iapkit: an unchanged recheck verdict is not written back`,283                after.purchases.find((row) => row.appUserId === userId)284                  ?.updatedAt,285                before.purchases.find((row) => row.appUserId === userId)286                  ?.updatedAt,287              );288            }289          }290        }291        results[store][name] = {292          verified: verified.isValid,293          productIds: access.productIds,294          subscriptions: access.subscriptions.length,295          storeRecheck: store !== "apple",296        };297      }298      app.select("example");299      check(300        `${store}: switching back needs no consumer edit`,301        (await request(store, "/access")).result.productIds,302        ["premium.monthly"],303      );304      const deleted = await request(store, "/account", "DELETE");305      check(306        `${store}: the app accepts account erasure`,307        deleted.result?.accepted,308        true,309      );310      let pending = 1;311      for (let attempt = 0; attempt < 100 && pending; attempt++) {312        pending = await app.drainErasure();313        if (pending) await Bun.sleep(100);314      }315      check(`${store}: both providers complete erasure`, pending, 0);316      for (const [name, client] of [317        ["example", fixtureClient],318        ["iapkit", kit],319      ]) {320        check(321          `${store}/${name}: erased account has no access`,322          (await client.call("entitlements", { userId })).productIds,323          [],324        );325        check(326          `${store}/${name}: erased user id cannot bind again`,327          (await client.call("bindPurchase", { ...input, userId })).bound,328          false,329        );330      }331      // The erased user id stays refused while its job is retained; IAPKit's332      // Amazon/Horizon evidence does not, unlike the example's permanent mark.333      if (["amazon", "horizon"].includes(store)) {334        const next = `store_next_${store}`;335        check(336          `${store}/example: erased evidence cannot bind to another account`,337          (await fixtureClient.call("bindPurchase", { ...input, userId: next }))338            .bound,339          false,340        );341        check(342          `${store}/iapkit: another account can bind the erased evidence`,343          (await kit.call("bindPurchase", { ...input, userId: next })).bound,344          true,345        );346        check(347          `${store}/iapkit: the new account receives the rechecked access`,348          (await kit.call("entitlements", { userId: next })).productIds,349          ["premium.monthly"],350        );351      }352      check(353        `${store}: an erased session cannot submit another purchase`,354        (await request(store, "/purchase", "POST", input)).status,355        401,356      );357      const state = await inspect();358      check(359        `${store}: IAPKit removes account identity from persisted purchases`,360        state.purchases.some((row) => row.appUserId === userId),361        false,362      );363      sqlite.close();364      sqlite = null;365    }366    assert.equal(Object.keys(results).length, 3);367    return results;368  } finally {369    await app.close();370    await providerServer.stop(true);371    sqlite?.close();372  }373}374