← Provider replacement walkthrough

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

Source snapshot · 2026-09-09

SHA-256 899281919296be4f38de4e6b48b059b6e0a6cca90558bc3758dc9bcd559e1435
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. Store credentials5// are not involved; SPEC.md 11.3 scopes what this certifies.6 7import { beforeEach, describe, expect, it, vi } from "vitest";8import { Hono } from "hono";9import Ajv from "ajv/dist/2020";10import {11  createGraphqlAdapter,12  createRestAdapter,13  operationVectors,14  runConformance,15} from "openiap-commerce-protocol/conformance";16 17import {18  CONTENT_TYPE,19  DELIVERY_ID_HEADER,20  EVENT_ID_HEADER,21  SIGNATURE_HEADER,22  SIGNATURE_TOLERANCE_SECONDS,23  TIMESTAMP_HEADER,24  isRetryableStatus,25  signPayload,26  signPayloadWithRotation,27} from "../../../convex/commerce/signing";28import {29  commerceEventTypesToEmit,30  type CommerceEventType,31} from "../../../convex/commerce/contract";32import { isEntitledAt } from "../../../convex/subscriptions/query";33 34// IAPKit's descriptor declares the events profile, so dual-binding conformance35// drives the EventsAdapter surface (SPEC.md §11.2/§11.3 scope its coverage;36// §9.2/§9.3/§9.4.4/§9.4.5 are certified by IAPKit's own convex tests, not37// here). What delegates to SHIPPED code: sign/rotation (signPayload,38// signPayloadWithRotation), response classification (isRetryableStatus), the39// emission rules (commerceEventTypesToEmit), the entitlement gate40// (isEntitledAt), and the envelope constants (CONTENT_TYPE, the four header41// names, the tolerance). The production envelope SENDER lives in42// convex/commerce/delivery.ts and is covered by its own tests; this adapter43// re-composes the same envelope from those shipped constants because the44// worker's composition is not factored as a callable unit. Only `verify` has45// no production counterpart at all — IAPKit emits webhooks, consumers verify46// them — so it is written here from the same constants.47const iapkitEventsAdapter = {48  sign: ({49    secret,50    timestamp,51    body,52  }: {53    secret: string;54    timestamp: number;55    body: string;56  }) => signPayload(secret, timestamp, body),57  verify: async ({58    body,59    timestamp,60    signature,61    secrets,62    now,63  }: {64    body: string;65    timestamp: number;66    signature: string;67    secrets: string[];68    now: number;69  }) => {70    if (Math.abs(now - timestamp) > SIGNATURE_TOLERANCE_SECONDS) return false;71    const held = await Promise.all(72      secrets.map((secret) => signPayload(secret, timestamp, body)),73    );74    return signature75      .split(",")76      .map((part) => part.trim())77      .some((presented) => held.includes(presented));78  },79  delivery: async ({80    event,81    body,82    timestamp,83    secrets,84    deliveryId,85  }: {86    event: { eventId: string };87    body: string;88    timestamp: number;89    secrets: string[];90    deliveryId: string;91  }) => ({92    // POST is how delivery.ts sends (postJsonToAddress) — there is no method93    // constant to import; the content type IS the shipped constant.94    method: "POST",95    contentType: CONTENT_TYPE,96    headers: {97      [SIGNATURE_HEADER]: await signPayloadWithRotation(98        { current: secrets[0], previous: secrets[1] },99        timestamp,100        body,101      ),102      [TIMESTAMP_HEADER]: String(timestamp),103      [EVENT_ID_HEADER]: event.eventId,104      [DELIVERY_ID_HEADER]: deliveryId,105    },106  }),107  classifyResponse: (status: number | "connection-error" | "timeout") => {108    // The real worker's fetch catch path retries on timeout/connection error.109    if (status === "connection-error" || status === "timeout") return "retry";110    if (status >= 200 && status < 300) return "delivered";111    if (isRetryableStatus(status)) return "retry";112    return "permanent-failure";113  },114  entitled: ({115    state,116    expiresAt,117    processedAt,118  }: {119    state: string;120    expiresAt?: number;121    processedAt: number;122  }) => isEntitledAt(state, expiresAt, processedAt),123  emission: ({124    lifecycleEvent,125    entitledBefore,126    entitledAfter,127  }: {128    lifecycleEvent: string | null;129    entitledBefore: boolean;130    entitledAfter: boolean;131  }) =>132    commerceEventTypesToEmit({133      lifecycleType: (lifecycleEvent ?? null) as CommerceEventType | null,134      active: entitledAfter,135      previouslyActive: entitledBefore,136      hasBoundUser: true,137    }),138  coalesceAtBinding: ({ entitledAtBinding }: { entitledAtBinding: boolean }) =>139    commerceEventTypesToEmit({140      lifecycleType: null,141      active: entitledAtBinding,142      previouslyActive: false,143      hasBoundUser: true,144    }),145};146 147const FIXTURES = operationVectors.fixtures as {148  userId: string;149  erasureUserId: string;150  appleJws: string;151  googlePurchaseToken: string;152};153 154const mocks = vi.hoisted(() => ({155  action: vi.fn(),156  mutation: vi.fn(),157  query: vi.fn(),158  handleConvexError: vi.fn(),159}));160 161vi.mock("@/convex", () => ({162  api: {163    purchases: {164      action: {165        readBoundPurchaseEntitlements: "readBoundPurchaseEntitlements",166      },167      mutation: {168        bindVerifiedPurchaseAsServer: "bindVerifiedPurchaseAsServer",169      },170      ios: { verifyAppStoreReceiptInternalV1: "verifyApple" },171      android: { verifyGooglePlayReceiptInternalV1: "verifyGoogle" },172      horizon: { verifyMetaHorizonReceiptInternalV1: "verifyHorizon" },173      amazon: { verifyAmazonReceiptInternalV1: "verifyAmazon" },174    },175    subscriptions: {176      query: {177        subscriptionStatusV2: "subscriptionStatusV2",178        entitlementsV2: "entitlementsV2",179        assertServerAccess: "assertServerAccess",180      },181      mutation: {182        bindUserAsServer: "bindUserAsServer",183        requestUserErasure: "requestUserErasure",184      },185    },186  },187}));188 189vi.mock("../../convex", () => ({190  client: {191    action: mocks.action,192    mutation: mocks.mutation,193    query: mocks.query,194  },195  handleConvexError: mocks.handleConvexError,196}));197 198const { commerceRoutes } = await import("./routes");199 200const CREDENTIALS = {201  verification: "openiap-kit_pk_conformance",202  server: "openiap-kit_sk_conformance",203};204const BASE_URL = "https://kit.conformance.example";205 206function buildApp(): Hono {207  const app = new Hono();208  app.route("/commerce/v1", commerceRoutes);209  return app;210}211 212// A ConvexError the real key check would throw, so an unknown key becomes213// UNAUTHORIZED and a publishable key on a server op becomes FORBIDDEN — the214// authoritative classification the edge prefix check cannot make.215class FakeConvexError extends Error {216  constructor(readonly data: { code: string; message: string }) {217    super(data.message);218  }219}220 221function assertKnownKey(apiKey: unknown): void {222  if (apiKey !== CREDENTIALS.verification && apiKey !== CREDENTIALS.server) {223    throw new FakeConvexError({224      code: "INVALID_API_KEY",225      message: "API key is invalid or inactive",226    });227  }228}229 230function assertServerKey(apiKey: unknown): void {231  assertKnownKey(apiKey);232  if (apiKey !== CREDENTIALS.server) {233    throw new FakeConvexError({234      code: "INSUFFICIENT_SCOPE",235      message: "This operation requires a secret admin key",236    });237  }238}239 240function seedConvexFixtures() {241  const now = Date.now();242  // Shaped like the real subscriptionV2Shape, id included, so the test proves243  // the handler strips the provider-internal id from tokenless responses.244  const row = {245    id: "subscriptions:mock-row-1",246    productId: "premium.monthly",247    platform: "Android" as const,248    state: "Active",249    expiresAt: now + 30 * 86_400_000,250    willRenew: true,251    startedAt: now - 86_400_000,252    updatedAt: now - 1_000,253  };254 255  mocks.handleConvexError.mockImplementation((error: unknown) =>256    error instanceof FakeConvexError ? error.data : null,257  );258 259  mocks.action.mockImplementation(async (name: unknown, args: unknown) => {260    assertKnownKey((args as { apiKey: string }).apiKey);261    if (name === "readBoundPurchaseEntitlements") {262      assertServerKey((args as { apiKey: string }).apiKey);263      return { productIds: [] };264    }265    return {266      isValid: true,267      state: "ENTITLED",268      productId: "premium.monthly",269      environment: name === "verifyGoogle" ? "Production" : "Sandbox",270    };271  });272 273  mocks.query.mockImplementation(async (name: unknown, args: unknown) => {274    const { apiKey, userId } = args as { apiKey: string; userId: string };275    assertServerKey(apiKey);276    if (name === "assertServerAccess") {277      return { ok: true };278    }279    const owned = userId === FIXTURES.userId;280    if (name === "subscriptionStatusV2") {281      return { active: owned, subscription: owned ? row : null };282    }283    if (name === "entitlementsV2") {284      return {285        userId,286        productIds: owned ? [row.productId] : [],287        subscriptions: owned ? [row] : [],288      };289    }290    throw new Error(`unexpected query ${String(name)}`);291  });292 293  const erasureJobs = new Map<string, { jobId: string; status: string }>();294  mocks.mutation.mockImplementation(async (name: unknown, args: unknown) => {295    const { apiKey } = args as { apiKey: string };296    assertServerKey(apiKey);297    if (name === "bindVerifiedPurchaseAsServer") return { bound: false };298    if (name === "bindUserAsServer") {299      const { purchaseToken, userId } = args as {300        purchaseToken: string;301        userId: string;302      };303      return {304        ok: true,305        bound:306          purchaseToken === FIXTURES.googlePurchaseToken &&307          userId === FIXTURES.userId,308      };309    }310    if (name === "requestUserErasure") {311      const { userId } = args as { userId: string };312      const job = erasureJobs.get(userId) ?? {313        jobId: `job-${erasureJobs.size + 1}`,314        status: "queued",315      };316      erasureJobs.set(userId, job);317      return { ok: true, ...job };318    }319    throw new Error(`unexpected mutation ${String(name)}`);320  });321}322 323describe("IAPKit dual-binding conformance", () => {324  beforeEach(() => {325    mocks.action.mockReset();326    mocks.mutation.mockReset();327    mocks.query.mockReset();328    mocks.handleConvexError.mockReset();329    mocks.handleConvexError.mockReturnValue(null);330    seedConvexFixtures();331  });332 333  it("passes every operation vector on both bindings with cross-binding parity", async () => {334    const app = buildApp();335    const fetchApp = async (url: string, options?: RequestInit) =>336      app.request(url, options);337 338    const report = await runConformance({339      adapters: [340        createRestAdapter({341          baseUrl: BASE_URL,342          fetch: fetchApp,343          credentials: CREDENTIALS,344        }),345        createGraphqlAdapter({346          url: `${BASE_URL}/commerce/v1/graphql`,347          fetch: fetchApp,348          credentials: CREDENTIALS,349        }),350      ],351      Ajv,352      eventsAdapter: iapkitEventsAdapter,353      credentials: CREDENTIALS,354    });355 356    expect(report.results.filter((result) => !result.ok)).toEqual([]);357    // The declared events profile was actually verified, not silently skipped.358    expect(359      report.results.some((r) => r.id === "events.profile-verification"),360    ).toBe(true);361    expect(report.parityFailures).toEqual([]);362    expect(report.ok).toBe(true);363    const bindings = new Set(report.results.map((result) => result.binding));364    expect([...bindings].sort()).toEqual(["graphql", "rest"]);365  });366 367  it("keeps the served capability descriptor equal to the published one, minus the comment", async () => {368    const app = buildApp();369    const response = await app.request("/commerce/v1/capabilities");370    expect(response.status).toBe(200);371    const served = await response.json();372    const { $comment, ...published } = (373      await import("openiap-commerce-protocol/examples/provider-capabilities.json")374    ).default as Record<string, unknown>;375    expect($comment).toBeTruthy();376    expect(served).toEqual(published);377  });378});379