← Provider replacement walkthrough

IAPKit · server/api/commerce/conformance.test.ts

Source snapshot · 2026-10-21

SHA-256 0240d5351e24d177734811282daa7cd70dd0c7fc28b9ce27450e571c44fa8e63
1// IAPKit's dual-binding conformance proof: the spec package's portable runner2// drives the real commerce routes — REST and GraphQL, through the real3// adapters, shared handlers, auth, and validation — against an in-memory4// Convex substitute seeded from the runner's own fixtures. Amazon and Horizon5// purchases run through IAPKit's own purchase functions, answered by fixture6// store responses; no request reaches a store. SPEC.md 11.3 scopes what this7// certifies.8 9import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10import { Hono } from "hono";11import Ajv from "ajv/dist/2020";12import { getFunctionName } from "convex/server";13import {14  createGraphqlAdapter,15  createRestAdapter,16  operationVectors,17  runConformance,18} from "@hyodotdev/openiap-commerce-protocol/conformance";19 20import {21  SIGNATURE_TOLERANCE_SECONDS,22  classifyDeliveryResponse,23  composeDeliveryHeaders,24  signPayload,25} from "../../../convex/commerce/signing";26import {27  commerceEventTypesToEmit,28  type CommerceEventType,29} from "../../../convex/commerce/contract";30import { entitlementActive } from "../../../convex/subscriptions/stateMachine";31import { getProjectByApiKey as getProjectByApiKeyQuery } from "../../../convex/projects/internal";32import { readBoundPurchaseEntitlements as readBoundPurchaseEntitlementsAction } from "../../../convex/purchases/action";33import { verifyAmazonReceiptInternalV1 } from "../../../convex/purchases/amazon";34import { verifyMetaHorizonReceiptInternalV1 } from "../../../convex/purchases/horizon";35import {36  buildAmazonRemoteId,37  buildHorizonRemoteId,38} from "../../../convex/purchases/identity";39import { boundPurchasesForUser as boundPurchasesForUserQuery } from "../../../convex/purchases/internal";40import { bindVerifiedPurchaseAsServer as bindVerifiedPurchaseAsServerMutation } from "../../../convex/purchases/mutation";41import { HarmonizedPurchaseState } from "../../../convex/purchases/purchaseState";42import { isValidState } from "../../../convex/purchases/shared";43import {44  reserveAmazonRvsSequenceHandler,45  type saveAmazonVerdictHandler,46} from "../../../convex/purchases/internal";47import { consume as consumeAdmissionMutation } from "../../../convex/purchases/verificationAdmission";48import { testableFunction } from "../../../convex/test.setup";49 50// IAPKit's descriptor declares the events profile, so dual-binding conformance51// drives the EventsAdapter surface (SPEC.md §11.2/§11.3 scope its coverage;52// §9.2/§9.3/§9.4.4/§9.4.5 are certified by IAPKit's own convex tests, not53// here). Every method but one delegates to SHIPPED code: signing (signPayload),54// the delivery envelope (composeDeliveryHeaders, which the worker in55// convex/commerce/delivery.ts sends), response classification56// (classifyDeliveryResponse, which the worker records), the emission rules57// (commerceEventTypesToEmit), and the entitlement gate (entitlementActive). Only58// `verify` has no production counterpart — IAPKit emits webhooks, consumers59// verify them — so it is written here from the shipped tolerance.60const iapkitEventsAdapter = {61  sign: ({62    secret,63    timestamp,64    body,65  }: {66    secret: string;67    timestamp: number;68    body: string;69  }) => signPayload(secret, timestamp, body),70  verify: async ({71    body,72    timestamp,73    signature,74    secrets,75    now,76  }: {77    body: string;78    timestamp: number;79    signature: string;80    secrets: string[];81    now: number;82  }) => {83    if (Math.abs(now - timestamp) > SIGNATURE_TOLERANCE_SECONDS) return false;84    const held = await Promise.all(85      secrets.map((secret) => signPayload(secret, timestamp, body)),86    );87    return signature88      .split(",")89      .map((part) => part.trim())90      .some((presented) => held.includes(presented));91  },92  delivery: async ({93    event,94    body,95    timestamp,96    secrets,97    deliveryId,98  }: {99    event: { eventId: string };100    body: string;101    timestamp: number;102    secrets: string[];103    deliveryId: string;104  }) => {105    const headers = await composeDeliveryHeaders({106      secrets: { current: secrets[0], previous: secrets[1] },107      timestampSeconds: timestamp,108      body,109      eventId: event.eventId,110      deliveryId,111    });112    // POST is how delivery.ts sends (buildPinnedRequestOptions); there is no113    // method constant to import.114    return { method: "POST", contentType: headers["content-type"], headers };115  },116  // The worker passes `undefined` when no status arrived (its catch path).117  classifyResponse: (status: number | "connection-error" | "timeout") =>118    classifyDeliveryResponse(typeof status === "number" ? status : undefined),119  // Store-agnostic: the kit-only Apple guard is pinned against the same120  // vectors in convex/commerce/spec.conformance.test.ts.121  entitled: ({122    state,123    expiresAt,124    processedAt,125  }: {126    state: string;127    expiresAt?: number;128    processedAt: number;129  }) => entitlementActive({ state, expiresAt }, processedAt),130  emission: ({131    lifecycleEvent,132    entitledBefore,133    entitledAfter,134  }: {135    lifecycleEvent: string | null;136    entitledBefore: boolean;137    entitledAfter: boolean;138  }) =>139    commerceEventTypesToEmit({140      lifecycleType: (lifecycleEvent ?? null) as CommerceEventType | null,141      active: entitledAfter,142      previouslyActive: entitledBefore,143      hasBoundUser: true,144    }),145  coalesceAtBinding: ({ entitledAtBinding }: { entitledAtBinding: boolean }) =>146    commerceEventTypesToEmit({147      lifecycleType: null,148      active: entitledAtBinding,149      previouslyActive: false,150      hasBoundUser: true,151    }),152};153 154const FIXTURES = operationVectors.fixtures as {155  userId: string;156  erasureUserId: string;157  appleJws: string;158  googlePurchaseToken: string;159  amazonUserId: string;160  amazonReceiptId: string;161  horizonUserId: string;162  horizonSku: string;163};164 165const mocks = vi.hoisted(() => ({166  action: vi.fn(),167  mutation: vi.fn(),168  query: vi.fn(),169  handleConvexError: vi.fn(),170}));171 172vi.mock("@/convex", () => ({173  api: {174    purchases: {175      action: {176        readBoundPurchaseEntitlements: "readBoundPurchaseEntitlements",177      },178      mutation: {179        bindVerifiedPurchaseAsServer: "bindVerifiedPurchaseAsServer",180      },181      ios: { verifyAppStoreReceiptInternalV1: "verifyApple" },182      android: { verifyGooglePlayReceiptInternalV1: "verifyGoogle" },183      horizon: { verifyMetaHorizonReceiptInternalV1: "verifyHorizon" },184      amazon: { verifyAmazonReceiptInternalV1: "verifyAmazon" },185    },186    subscriptions: {187      query: {188        subscriptionStatusV2: "subscriptionStatusV2",189        entitlementsV2: "entitlementsV2",190        assertServerAccess: "assertServerAccess",191      },192      mutation: {193        bindUserAsServer: "bindUserAsServer",194        requestUserErasure: "requestUserErasure",195      },196    },197  },198}));199 200vi.mock("../../convex", () => ({201  client: {202    action: mocks.action,203    mutation: mocks.mutation,204    query: mocks.query,205  },206  handleConvexError: mocks.handleConvexError,207}));208 209const { commerceRoutes } = await import("./routes");210 211const CREDENTIALS = {212  verification: "openiap-kit_pk_conformance",213  server: "openiap-kit_sk_conformance",214};215const BASE_URL = "https://kit.conformance.example";216 217function buildApp(): Hono {218  const app = new Hono();219  app.route("/commerce/v1", commerceRoutes);220  return app;221}222 223// A ConvexError the real key check would throw, so an unknown key becomes224// UNAUTHORIZED and a publishable key on a server op becomes FORBIDDEN — the225// authoritative classification the edge prefix check cannot make.226class FakeConvexError extends Error {227  constructor(readonly data: { code: string; message: string }) {228    super(data.message);229  }230}231 232function assertKnownKey(apiKey: unknown): void {233  if (apiKey !== CREDENTIALS.verification && apiKey !== CREDENTIALS.server) {234    throw new FakeConvexError({235      code: "INVALID_API_KEY",236      message: "API key is invalid or inactive",237    });238  }239}240 241function assertServerKey(apiKey: unknown): void {242  assertKnownKey(apiKey);243  if (apiKey !== CREDENTIALS.server) {244    throw new FakeConvexError({245      code: "INSUFFICIENT_SCOPE",246      message: "This operation requires a secret admin key",247    });248  }249}250 251const getProjectByApiKey = testableFunction(getProjectByApiKeyQuery)._handler;252const boundPurchasesForUser = testableFunction(253  boundPurchasesForUserQuery,254)._handler;255const consumeAdmission = testableFunction(consumeAdmissionMutation)._handler;256const readBoundPurchaseEntitlements = testableFunction(257  readBoundPurchaseEntitlementsAction,258)._handler;259const bindVerifiedPurchaseAsServer = testableFunction(260  bindVerifiedPurchaseAsServerMutation,261)._handler;262const verifyAmazonReceipt = testableFunction(263  verifyAmazonReceiptInternalV1,264)._handler;265const verifyHorizonReceipt = testableFunction(266  verifyMetaHorizonReceiptInternalV1,267)._handler;268 269type Row = Record<string, unknown> & { _id: string };270type ConvexReference = Parameters<typeof getFunctionName>[0];271type EqualityRange = { eq(field: string, value: unknown): EqualityRange };272type FieldFilter = {273  field(name: string): string;274  eq(field: string, value: unknown): (row: Row) => boolean;275};276 277const PROJECT_ID = "projects:conformance";278const ORGANIZATION_ID = "organizations:conformance";279const AMAZON_PRODUCT = "conformance.amazon.premium";280 281// The slice of ctx.db the purchase functions use: equality index lookups, a282// field filter, get, insert, and patch.283function memoryDatabase(tables: Record<string, Row[]>) {284  const byId = (id: unknown): Row | null =>285    Object.values(tables)286      .flat()287      .find((row) => row._id === id) ?? null;288  return {289    query(table: string) {290      const matches: Array<(row: Row) => boolean> = [];291      const rows = () =>292        (tables[table] ?? []).filter((row) =>293          matches.every((match) => match(row)),294        );295      const query = {296        withIndex(_index: string, range: (q: EqualityRange) => unknown) {297          const equality: EqualityRange = {298            eq(field, value) {299              matches.push((row) => row[field] === value);300              return equality;301            },302          };303          range(equality);304          return query;305        },306        filter(predicate: (q: FieldFilter) => (row: Row) => boolean) {307          matches.push(308            predicate({309              field: (name) => name,310              eq: (field, value) => (row) => row[field] === value,311            }),312          );313          return query;314        },315        first: async () => rows()[0] ?? null,316        unique: async () => {317          const found = rows();318          if (found.length > 1) throw new Error(`Ambiguous ${table} rows`);319          return found[0] ?? null;320        },321        take: async (count: number) => rows().slice(0, count),322      };323      return query;324    },325    get: async (id: unknown) => byId(id),326    insert: async (table: string, value: Record<string, unknown>) => {327      const rows = (tables[table] ??= []);328      const _id = `${table}:${rows.length}`;329      rows.push({ _id, ...value });330      return _id;331    },332    patch: async (id: unknown, value: Record<string, unknown>) => {333      const row = byId(id);334      if (!row) throw new Error(`Missing row ${String(id)}`);335      Object.assign(row, value);336    },337  };338}339 340// One project whose Amazon and Horizon purchases are already bound to the341// fixture user, so both bindings read the same state in any case order.342function purchaseTables(): Record<string, Row[]> {343  const bound = (344    store: string,345    remoteId: string,346    productId: string,347    requestData: Record<string, unknown>,348  ): Row => ({349    _id: `purchases:${store}`,350    projectId: PROJECT_ID,351    store,352    remoteId,353    requestData: { store, ...requestData },354    productId,355    state: HarmonizedPurchaseState.ENTITLED,356    isValid: true,357    appUserId: FIXTURES.userId,358  });359  const amazon = {360    userId: FIXTURES.amazonUserId,361    receiptId: FIXTURES.amazonReceiptId,362    sandbox: true,363  };364  return {365    organizations: [{ _id: ORGANIZATION_ID }],366    projects: [367      {368        _id: PROJECT_ID,369        organizationId: ORGANIZATION_ID,370        amazonSandboxEnabled: true,371        horizonEnabled: true,372        horizonAppId: "conformance-horizon-app",373        horizonAppSecret: "conformance-horizon-secret",374      },375    ],376    apiKeys: [377      {378        _id: "apiKeys:server",379        projectId: PROJECT_ID,380        organizationId: ORGANIZATION_ID,381        key: CREDENTIALS.server,382        keyType: "secret",383      },384      {385        _id: "apiKeys:verification",386        projectId: PROJECT_ID,387        organizationId: ORGANIZATION_ID,388        key: CREDENTIALS.verification,389        keyType: "publishable",390      },391    ],392    purchases: [393      bound("amazon", buildAmazonRemoteId(amazon), AMAZON_PRODUCT, amazon),394      bound(395        "horizon",396        buildHorizonRemoteId(FIXTURES.horizonUserId, FIXTURES.horizonSku),397        FIXTURES.horizonSku,398        { userId: FIXTURES.horizonUserId, sku: FIXTURES.horizonSku },399      ),400    ],401    subscriptionUserErasureJobs: [],402  };403}404 405// Runs IAPKit's purchase functions over the tables. Persisting a store verdict406// only updates the row; everything else is the real handler. Arguments are407// typed `never` because each call forwards what the calling Convex code sent.408function purchaseFunctions() {409  const tables = purchaseTables();410  const ctx = {411    db: memoryDatabase(tables),412    runQuery: (reference: ConvexReference, args: never) =>413      runInternal(reference, args),414    runMutation: (reference: ConvexReference, args: never) =>415      runInternal(reference, args),416  };417  function recordVerdict(verdict: {418    store: string;419    remoteId?: string;420    state: string;421    isValid: boolean;422  }): null {423    const row = tables.purchases.find(424      (purchase) =>425        purchase.store === verdict.store &&426        purchase.remoteId === verdict.remoteId,427    );428    if (row)429      Object.assign(row, { state: verdict.state, isValid: verdict.isValid });430    return null;431  }432  async function runInternal(433    reference: ConvexReference,434    args: never,435  ): Promise<unknown> {436    switch (getFunctionName(reference)) {437      case "projects/internal:getProjectByApiKey":438        return getProjectByApiKey(ctx, args);439      case "purchases/internal:boundPurchasesForUser":440        return boundPurchasesForUser(ctx, args);441      case "purchases/verificationAdmission:consume":442        return consumeAdmission(ctx, args);443      case "purchases/internal:saveReceiptInternal":444        return recordVerdict(args);445      case "purchases/internal:reserveAmazonRvsSequence":446        return reserveAmazonRvsSequenceHandler(ctx as never, args);447      case "purchases/internal:saveAmazonVerdict": {448        const verdict = args as Parameters<typeof saveAmazonVerdictHandler>[1];449        const isValid = isValidState(verdict.state);450        recordVerdict({451          store: "amazon",452          remoteId: verdict.remoteId,453          state: verdict.state,454          isValid,455        });456        const receipt = JSON.parse(verdict.remoteResponse) as {457          productId?: string;458        };459        return {460          isValid,461          state: verdict.state,462          environment: verdict.environment,463          ...(receipt.productId ? { productId: receipt.productId } : {}),464        };465      }466      default:467        throw new Error(468          `Unexpected Convex function ${getFunctionName(reference)}`,469        );470    }471  }472  return ctx;473}474 475// Fixture store answers for the fixture evidence. Any other host throws, so476// the run never reaches a real store.477const storeRequests: string[] = [];478async function storeFetch(input: string | URL | Request): Promise<Response> {479  const { hostname } = new URL(input instanceof Request ? input.url : input);480  storeRequests.push(hostname);481  if (hostname === "appstore-sdk.amazon.com") {482    return Response.json({483      receiptId: FIXTURES.amazonReceiptId,484      productId: AMAZON_PRODUCT,485      productType: "ENTITLED",486      cancelDate: null,487    });488  }489  if (hostname === "graph.oculus.com") {490    return Response.json({ success: true, grant_time: 1_758_000_000 });491  }492  throw new Error(`Unexpected store request to ${hostname}`);493}494 495// Apple and Google verdicts need signed store payloads, so they stay canned.496function cannedVerdict(name: unknown, args: { apiKey: string }) {497  assertKnownKey(args.apiKey);498  return {499    isValid: true,500    state: "ENTITLED",501    productId: "premium.monthly",502    environment: name === "verifyGoogle" ? "Production" : "Sandbox",503  };504}505 506function seedConvexFixtures() {507  const now = Date.now();508  // Shaped like the real subscriptionV2Shape, id included, so the test proves509  // the handler strips the provider-internal id from tokenless responses.510  const row = {511    id: "subscriptions:mock-row-1",512    productId: "premium.monthly",513    platform: "Android" as const,514    state: "Active",515    expiresAt: now + 30 * 86_400_000,516    willRenew: true,517    startedAt: now - 86_400_000,518    updatedAt: now - 1_000,519  };520  const purchases = purchaseFunctions();521 522  mocks.handleConvexError.mockImplementation((error: unknown) =>523    error instanceof FakeConvexError ? error.data : null,524  );525 526  mocks.action.mockImplementation(async (name: unknown, args: never) => {527    switch (name) {528      case "readBoundPurchaseEntitlements":529        return readBoundPurchaseEntitlements(purchases, args);530      case "verifyAmazon":531        return verifyAmazonReceipt(purchases, args);532      case "verifyHorizon":533        return verifyHorizonReceipt(purchases, args);534      default:535        return cannedVerdict(name, args);536    }537  });538 539  mocks.query.mockImplementation(async (name: unknown, args: unknown) => {540    const { apiKey, userId } = args as { apiKey: string; userId: string };541    assertServerKey(apiKey);542    if (name === "assertServerAccess") {543      return { ok: true };544    }545    const owned = userId === FIXTURES.userId;546    if (name === "subscriptionStatusV2") {547      return { active: owned, subscription: owned ? row : null };548    }549    if (name === "entitlementsV2") {550      return {551        userId,552        productIds: owned ? [row.productId] : [],553        subscriptions: owned ? [row] : [],554      };555    }556    throw new Error(`unexpected query ${String(name)}`);557  });558 559  const erasureJobs = new Map<string, { jobId: string; status: string }>();560  const subscriptionMutation = (name: unknown, args: unknown) => {561    const { apiKey } = args as { apiKey: string };562    assertServerKey(apiKey);563    if (name === "bindUserAsServer") {564      const { purchaseToken, userId } = args as {565        purchaseToken: string;566        userId: string;567      };568      return {569        ok: true,570        bound:571          purchaseToken === FIXTURES.googlePurchaseToken &&572          userId === FIXTURES.userId,573      };574    }575    if (name === "requestUserErasure") {576      const { userId } = args as { userId: string };577      const job = erasureJobs.get(userId) ?? {578        jobId: `job-${erasureJobs.size + 1}`,579        status: "queued",580      };581      erasureJobs.set(userId, job);582      return { ok: true, ...job };583    }584    throw new Error(`unexpected mutation ${String(name)}`);585  };586  mocks.mutation.mockImplementation(async (name: unknown, args: never) =>587    name === "bindVerifiedPurchaseAsServer"588      ? bindVerifiedPurchaseAsServer(purchases, args)589      : subscriptionMutation(name, args),590  );591}592 593describe("IAPKit dual-binding conformance", () => {594  beforeEach(() => {595    mocks.action.mockReset();596    mocks.mutation.mockReset();597    mocks.query.mockReset();598    mocks.handleConvexError.mockReset();599    mocks.handleConvexError.mockReturnValue(null);600    seedConvexFixtures();601    storeRequests.length = 0;602    vi.stubGlobal("fetch", storeFetch);603  });604 605  afterEach(() => {606    vi.unstubAllGlobals();607  });608 609  it("passes every operation vector on both bindings with cross-binding parity", async () => {610    const app = buildApp();611    const fetchApp = async (url: string, options?: RequestInit) =>612      app.request(url, options);613 614    const report = await runConformance({615      adapters: [616        createRestAdapter({617          baseUrl: BASE_URL,618          fetch: fetchApp,619          credentials: CREDENTIALS,620        }),621        createGraphqlAdapter({622          url: `${BASE_URL}/commerce/v1/graphql`,623          fetch: fetchApp,624          credentials: CREDENTIALS,625        }),626      ],627      Ajv,628      eventsAdapter: iapkitEventsAdapter,629      credentials: CREDENTIALS,630    });631 632    expect(report.results.filter((result) => !result.ok)).toEqual([]);633    // The declared events profile was actually verified, not silently skipped.634    expect(635      report.results.some((r) => r.id === "events.profile-verification"),636    ).toBe(true);637    expect(report.parityFailures).toEqual([]);638    expect(report.ok).toBe(true);639    const bindings = new Set(report.results.map((result) => result.binding));640    expect([...bindings].sort()).toEqual(["graphql", "rest"]);641    // The Amazon and Horizon paths reached the fixture store answers.642    expect(new Set(storeRequests)).toEqual(643      new Set(["appstore-sdk.amazon.com", "graph.oculus.com"]),644    );645  });646 647  it("lists store-confirmed Amazon and Horizon products without subscription records", async () => {648    const response = await buildApp().request(649      `/commerce/v1/entitlements?userId=${FIXTURES.userId}`,650      { headers: { Authorization: `Bearer ${CREDENTIALS.server}` } },651    );652    expect(response.status).toBe(200);653    const body = await response.json();654    expect(body.productIds.sort()).toEqual(655      ["premium.monthly", AMAZON_PRODUCT, FIXTURES.horizonSku].sort(),656    );657    expect(658      body.subscriptions.map(659        (record: { productId: string }) => record.productId,660      ),661    ).toEqual(["premium.monthly"]);662  });663 664  // The runner also accepts VERIFICATION_FAILED for these vectors, so pin the665  // verdict itself.666  it.each(["amazon", "horizon"])(667    "verifies the %s fixture purchase against the fixture store answer",668    async (store) => {669      const vector = operationVectors.cases.find(670        (candidate) =>671          candidate.id === `verifyPurchase.success.contract.${store}`,672      );673      const response = await buildApp().request(674        "/commerce/v1/purchases/verify",675        {676          method: "POST",677          headers: {678            Authorization: `Bearer ${CREDENTIALS.verification}`,679            "Content-Type": "application/json",680          },681          body: JSON.stringify(vector?.input),682        },683      );684      expect(response.status).toBe(200);685      expect((await response.json()).isValid).toBe(true);686    },687  );688 689  it("fails the entitlements read with VERIFICATION_FAILED when the store cannot answer", async () => {690    // RVS 496 is a store-side fault, not a verdict about the receipt.691    vi.stubGlobal("fetch", async () => new Response("", { status: 496 }));692    const response = await buildApp().request(693      `/commerce/v1/entitlements?userId=${FIXTURES.userId}`,694      { headers: { Authorization: `Bearer ${CREDENTIALS.server}` } },695    );696    expect(response.status).toBe(502);697    expect((await response.json()).error.code).toBe("VERIFICATION_FAILED");698  });699 700  it("keeps the served capability descriptor equal to the published one, minus the comment", async () => {701    const app = buildApp();702    const response = await app.request("/commerce/v1/capabilities");703    expect(response.status).toBe(200);704    const served = await response.json();705    const { $comment, ...published } = (706      await import("@hyodotdev/openiap-commerce-protocol/examples/provider-capabilities.json")707    ).default as Record<string, unknown>;708    expect($comment).toBeTruthy();709    expect(served).toEqual(published);710  });711});712 713// Synthetic service credential for this isolated verification transport fixture.714beforeEach(() =>715  vi.stubEnv("IAPKIT_VERIFICATION_SOURCE_SECRET", "1".repeat(64)),716);717afterEach(() => vi.unstubAllEnvs());718