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