← Provider replacement walkthrough

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

Source snapshot · 2026-10-21

SHA-256 5ca898345d70d31ac78861daee479b7b78bf7a42a61bb82eec1c4684b82f5c64
1// Edge behavior the conformance vectors do not pin: Convex error mapping,2// oversized bodies, GraphQL bounds, introspection agreement, and the3// evidence-normalization paths of bindPurchase.4 5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";6import { Hono } from "hono";7import { ReceiptVerificationError } from "../../../convex/purchases/errors";8import { buildSchema, getIntrospectionQuery, printSchema } from "graphql";9import operationsSdl from "@hyodotdev/openiap-commerce-protocol/generated/bindings/operations-sdl.json";10 11const mocks = vi.hoisted(() => ({12  action: vi.fn(),13  mutation: vi.fn(),14  query: vi.fn(),15  handleConvexError: vi.fn(),16}));17 18vi.mock("@/convex", () => ({19  api: {20    purchases: {21      action: {22        readBoundPurchaseEntitlements: "readBoundPurchaseEntitlements",23      },24      mutation: {25        bindVerifiedPurchaseAsServer: "bindVerifiedPurchaseAsServer",26      },27      ios: { verifyAppStoreReceiptInternalV1: "verifyApple" },28      android: { verifyGooglePlayReceiptInternalV1: "verifyGoogle" },29      horizon: { verifyMetaHorizonReceiptInternalV1: "verifyHorizon" },30      amazon: { verifyAmazonReceiptInternalV1: "verifyAmazon" },31    },32    subscriptions: {33      query: {34        subscriptionStatusV2: "subscriptionStatusV2",35        entitlementsV2: "entitlementsV2",36        assertServerAccess: "assertServerAccess",37      },38      mutation: {39        bindUserAsServer: "bindUserAsServer",40        requestUserErasure: "requestUserErasure",41      },42    },43  },44}));45 46vi.mock("../../convex", () => ({47  client: {48    action: mocks.action,49    mutation: mocks.mutation,50    query: mocks.query,51  },52  handleConvexError: mocks.handleConvexError,53}));54 55const { commerceRoutes } = await import("./routes");56const { hashApiKey } = await import("../v1/rate-limit");57const { replayGuardMiddleware } = await import("../v1/replay-guard");58const { commerceGraphqlSchema } = await import("./graphql");59 60const SERVER_KEY = "openiap-kit_sk_unit";61const CLIENT_KEY = "openiap-kit_pk_unit";62const GOOGLE_TOKEN = "unit-google-purchase-token-0000000001";63 64function buildApp(): Hono {65  const app = new Hono();66  app.route("/commerce/v1", commerceRoutes);67  return app;68}69 70function post(71  app: Hono,72  path: string,73  body: unknown,74  key: string | null = SERVER_KEY,75) {76  return app.request(path, {77    method: "POST",78    headers: {79      "Content-Type": "application/json",80      ...(key ? { Authorization: `Bearer ${key}` } : {}),81    },82    body: JSON.stringify(body),83  });84}85 86describe("commerce REST adapter", () => {87  beforeEach(() => {88    mocks.action.mockReset();89    mocks.mutation.mockReset();90    mocks.query.mockReset();91    mocks.handleConvexError.mockReset();92    mocks.handleConvexError.mockReturnValue(null);93  });94 95  it("maps a Convex INVALID_API_KEY onto UNAUTHORIZED with status 401", async () => {96    mocks.query.mockRejectedValue(new Error("boom"));97    mocks.handleConvexError.mockReturnValue({98      code: "INVALID_API_KEY",99      message: "API key is invalid or inactive",100    });101    const response = await buildApp().request(102      "/commerce/v1/subscriptions/status?userId=user-1",103      { headers: { Authorization: `Bearer ${SERVER_KEY}` } },104    );105    expect(response.status).toBe(401);106    expect((await response.json()).error.code).toBe("UNAUTHORIZED");107  });108 109  it("maps a Convex INSUFFICIENT_SCOPE onto FORBIDDEN with status 403", async () => {110    mocks.query.mockRejectedValue(new Error("boom"));111    mocks.handleConvexError.mockReturnValue({112      code: "INSUFFICIENT_SCOPE",113      message: "This operation requires a secret admin key",114    });115    const response = await buildApp().request(116      "/commerce/v1/entitlements?userId=user-1",117      { headers: { Authorization: `Bearer openiap-kit_legacy` } },118    );119    expect(response.status).toBe(403);120    expect((await response.json()).error.code).toBe("FORBIDDEN");121  });122 123  it("evaluates subscription expiry after store ownership refreshes", async () => {124    const clock = vi.spyOn(Date, "now").mockReturnValue(1_000);125    try {126      const subscription = {127        productId: "apple-premium",128        platform: "IOS",129        state: "Active",130        expiresAt: 2_000,131        startedAt: 0,132        updatedAt: 0,133      };134      mocks.action.mockImplementation(async () => {135        clock.mockReturnValue(3_000);136        return { productIds: ["amazon-premium"] };137      });138      mocks.query.mockImplementation(async (name, args) => {139        if (name !== "entitlementsV2") return { ok: true };140        const active = subscription.expiresAt > args.now;141        return {142          userId: args.userId,143          productIds: active ? [subscription.productId] : [],144          subscriptions: active ? [subscription] : [],145        };146      });147      const response = await buildApp().request(148        "/commerce/v1/entitlements?userId=user-1",149        { headers: { Authorization: `Bearer ${SERVER_KEY}` } },150      );151      expect(response.status).toBe(200);152      expect(await response.json()).toEqual({153        userId: "user-1",154        productIds: ["amazon-premium"],155        subscriptions: [],156      });157    } finally {158      clock.mockRestore();159    }160  });161 162  it("observes subscription erasure completed during a store refresh", async () => {163    let erased = false;164    mocks.action.mockImplementation(async () => {165      erased = true;166      return { productIds: [] };167    });168    mocks.query.mockImplementation(async (name, args) => {169      if (name !== "entitlementsV2") return { ok: true };170      return {171        userId: args.userId,172        productIds: erased ? [] : ["apple-premium"],173        subscriptions: [],174      };175    });176    const response = await buildApp().request(177      "/commerce/v1/entitlements?userId=user-1",178      { headers: { Authorization: `Bearer ${SERVER_KEY}` } },179    );180    expect(response.status).toBe(200);181    expect(await response.json()).toEqual({182      userId: "user-1",183      productIds: [],184      subscriptions: [],185    });186  });187 188  it("reports VERIFICATION_FAILED as 502 when the store verdict is unreachable", async () => {189    mocks.action.mockRejectedValue(new Error("upstream down"));190    const response = await post(191      buildApp(),192      "/commerce/v1/purchases/verify",193      { store: "google", google: { purchaseToken: GOOGLE_TOKEN } },194      CLIENT_KEY,195    );196    expect(response.status).toBe(502);197    const body = await response.json();198    expect(body.error.code).toBe("VERIFICATION_FAILED");199    expect(body.error.message).not.toContain("upstream");200  });201 202  it("preserves the Convex admission retry hint", async () => {203    mocks.action.mockRejectedValue(new Error("limited"));204    mocks.handleConvexError.mockReturnValue({205      code: "RATE_LIMITED",206      message: "Too many verification requests",207      retryAfterSec: 2,208    });209 210    const response = await post(211      buildApp(),212      "/commerce/v1/purchases/verify",213      { store: "google", google: { purchaseToken: GOOGLE_TOKEN } },214      CLIENT_KEY,215    );216 217    expect(response.status).toBe(429);218    expect(response.headers.get("Retry-After")).toBe("2");219    expect((await response.json()).error.code).toBe("RATE_LIMITED");220  });221 222  it.each([223    "PLAY_STORE_VERIFICATION_ERROR",224    "META_HORIZON_VERIFICATION_ERROR",225    "AMAZON_RECEIPT_VERIFICATION_ERROR",226    "APP_STORE_TRANSACTION_VERIFICATION_FAILED",227  ])(228    "maps the structured store error %s to VERIFICATION_FAILED 502",229    async (code) => {230      mocks.action.mockRejectedValue(new Error("convex error"));231      mocks.handleConvexError.mockReturnValue({232        code,233        message: `store said no for token ghi789 at /verify/${code}`,234      });235      const response = await post(236        buildApp(),237        "/commerce/v1/purchases/verify",238        { store: "google", google: { purchaseToken: GOOGLE_TOKEN } },239        CLIENT_KEY,240      );241      expect(response.status).toBe(502);242      expect((await response.json()).error.code).toBe("VERIFICATION_FAILED");243    },244  );245 246  it("logs no raw provider detail — only codes and error class", async () => {247    const logged: string[] = [];248    const spy = vi249      .spyOn(console, "error")250      .mockImplementation((...args: unknown[]) => {251        logged.push(args.map(String).join(" "));252      });253    try {254      mocks.query.mockRejectedValue(255        new Error(256          "ConvexError at /srv/convex/subscriptions/query.ts:512 token=ghi789",257        ),258      );259      mocks.handleConvexError.mockReturnValue({260        code: "INTERNAL_ERROR",261        message:262          "row subscriptions:abc for userId=alice token=ghi789 upstream=https://buy.itunes.apple.com failed",263      });264      const response = await buildApp().request(265        "/commerce/v1/subscriptions/status?userId=user-1",266        { headers: { Authorization: `Bearer ${SERVER_KEY}` } },267      );268      expect(response.status).toBe(500);269      const combined = logged.join("\n");270      for (const secret of [271        "ghi789",272        "subscriptions:abc",273        "userId=alice",274        "buy.itunes.apple.com",275        "query.ts:512",276      ]) {277        expect(combined).not.toContain(secret);278      }279    } finally {280      spy.mockRestore();281    }282  });283 284  it("rejects an oversized body before any Convex call", async () => {285    const response = await post(buildApp(), "/commerce/v1/purchases/verify", {286      store: "google",287      google: { purchaseToken: "x".repeat(40_000) },288    });289    expect(response.status).toBe(400);290    expect(mocks.action).not.toHaveBeenCalled();291  });292 293  it("binds an Apple purchase by the transaction id inside the JWS", async () => {294    mocks.mutation.mockResolvedValue({ ok: true, bound: true });295    const payload = Buffer.from(296      JSON.stringify({ originalTransactionId: "2000000123456789" }),297    ).toString("base64url");298    const jws = `eyJhbGciOiJFUzI1NiJ9.${payload}.c2lnbmF0dXJl`;299    const response = await post(buildApp(), "/commerce/v1/purchases/bind", {300      userId: "user-1",301      store: "apple",302      apple: { jws },303    });304    expect(response.status).toBe(200);305    expect(await response.json()).toEqual({ bound: true });306    expect(mocks.mutation).toHaveBeenCalledWith("bindUserAsServer", {307      apiKey: SERVER_KEY,308      purchaseToken: "2000000123456789",309      userId: "user-1",310    });311  });312 313  it("rejects an Apple JWS that carries no transaction identity", async () => {314    const payload = Buffer.from(JSON.stringify({ nothing: true })).toString(315      "base64url",316    );317    const response = await post(buildApp(), "/commerce/v1/purchases/bind", {318      userId: "user-1",319      store: "apple",320      apple: { jws: `eyJhbGciOiJFUzI1NiJ9.${payload}.c2ln` },321    });322    expect(response.status).toBe(400);323    expect(mocks.mutation).not.toHaveBeenCalled();324  });325 326  it("binds Horizon using the verified store user and SKU identity", async () => {327    mocks.mutation.mockResolvedValue({ bound: true });328    const response = await post(buildApp(), "/commerce/v1/purchases/bind", {329      userId: "user-1",330      store: "horizon",331      horizon: { userId: "1234567890", sku: "premium" },332    });333    expect(response.status).toBe(200);334    expect(await response.json()).toEqual({ bound: true });335    expect(mocks.mutation).toHaveBeenCalledWith(336      "bindVerifiedPurchaseAsServer",337      {338        apiKey: SERVER_KEY,339        userId: "user-1",340        store: "horizon",341        remoteId: "1234567890:premium",342      },343    );344  });345 346  it("binds Amazon using the store user, receipt id and environment", async () => {347    mocks.mutation.mockResolvedValue({ bound: true });348    const response = await post(buildApp(), "/commerce/v1/purchases/bind", {349      userId: "user-1",350      store: "amazon",351      amazon: { userId: "amzn1.account.X", receiptId: "rcpt/1", sandbox: true },352    });353    expect(response.status).toBe(200);354    expect(await response.json()).toEqual({ bound: true });355    expect(mocks.mutation).toHaveBeenCalledWith(356      "bindVerifiedPurchaseAsServer",357      {358        apiKey: SERVER_KEY,359        userId: "user-1",360        store: "amazon",361        remoteId: "sandbox:amzn1.account.X:rcpt%2F1",362      },363    );364  });365 366  it.each([367    ["META_HORIZON_VERIFICATION_ERROR", "VERIFICATION_FAILED", 502],368    ["AMAZON_RECEIPT_VERIFICATION_ERROR", "VERIFICATION_FAILED", 502],369    ["INTERNAL_ERROR", "INTERNAL_ERROR", 500],370    ["INSUFFICIENT_SCOPE", "FORBIDDEN", 403],371  ])(372    "answers a %s fault in the store recheck as %s (SPEC.md 4.3)",373    async (convexCode, protocolCode, status) => {374      mocks.action.mockRejectedValue(new Error("upstream down"));375      mocks.handleConvexError.mockReturnValue({376        code: convexCode,377        message: "store detail for token ghi789",378      });379      const response = await buildApp().request(380        "/commerce/v1/entitlements?userId=user-1",381        { headers: { Authorization: `Bearer ${SERVER_KEY}` } },382      );383      expect(response.status).toBe(status);384      const body = await response.json();385      expect(body.error.code).toBe(protocolCode);386      expect(body.error.message).not.toContain("ghi789");387    },388  );389 390  it("answers an unclassified store recheck fault as VERIFICATION_FAILED", async () => {391    mocks.action.mockRejectedValue(new Error("upstream down"));392    mocks.handleConvexError.mockReturnValue(null);393    const response = await buildApp().request(394      "/commerce/v1/entitlements?userId=user-1",395      { headers: { Authorization: `Bearer ${SERVER_KEY}` } },396    );397    expect(response.status).toBe(502);398    const body = await response.json();399    expect(body.error.code).toBe("VERIFICATION_FAILED");400    expect(body.error.message).not.toContain("upstream");401  });402 403  it("authenticates before revealing a non-binding store verdict", async () => {404    // The unknown key clears the edge (it is not a publishable prefix) but fails405    // the authoritative check. It must get UNAUTHORIZED, not `bound: false`.406    mocks.query.mockRejectedValue(new Error("boom"));407    mocks.handleConvexError.mockReturnValue({408      code: "INVALID_API_KEY",409      message: "API key is invalid or inactive",410    });411    const response = await post(412      buildApp(),413      "/commerce/v1/purchases/bind",414      {415        userId: "user-1",416        store: "horizon",417        horizon: { userId: "1", sku: "x" },418      },419      "openiap-kit_sk_unknown",420    );421    expect(response.status).toBe(401);422    expect((await response.json()).error.code).toBe("UNAUTHORIZED");423    // Auth ran first, and no binding happened for an unauthenticated caller.424    expect(mocks.query).toHaveBeenCalledWith("assertServerAccess", {425      apiKey: "openiap-kit_sk_unknown",426    });427    expect(mocks.mutation).not.toHaveBeenCalled();428  });429 430  it("authenticates before evidence parsing: an unknown key with malformed evidence gets 401, not INVALID_REQUEST", async () => {431    mocks.query.mockRejectedValue(new Error("boom"));432    mocks.handleConvexError.mockReturnValue({433      code: "INVALID_API_KEY",434      message: "API key is invalid or inactive",435    });436    const response = await post(437      buildApp(),438      "/commerce/v1/purchases/bind",439      { userId: "user-1", store: "apple", apple: { jws: "not-a-valid-jws" } },440      "openiap-kit_sk_unknown",441    );442    expect(response.status).toBe(401);443    expect((await response.json()).error.code).toBe("UNAUTHORIZED");444    expect(mocks.mutation).not.toHaveBeenCalled();445  });446 447  it("maps an under-scoped key on bind to FORBIDDEN before store validation", async () => {448    mocks.query.mockRejectedValue(new Error("boom"));449    mocks.handleConvexError.mockReturnValue({450      code: "INSUFFICIENT_SCOPE",451      message: "This operation requires a secret admin key",452    });453    const response = await post(454      buildApp(),455      "/commerce/v1/purchases/bind",456      {457        userId: "user-1",458        store: "horizon",459        horizon: { userId: "1", sku: "x" },460      },461      "openiap-kit_legacy",462    );463    expect(response.status).toBe(403);464    expect((await response.json()).error.code).toBe("FORBIDDEN");465    expect(mocks.mutation).not.toHaveBeenCalled();466  });467 468  it("authenticates a server read before input schema validation (REST)", async () => {469    // SPEC.md 5: the transport must not answer INVALID_REQUEST about a470    // privileged read's input to a caller whose credential is unknown.471    mocks.query.mockRejectedValue(new Error("boom"));472    mocks.handleConvexError.mockReturnValue({473      code: "INVALID_API_KEY",474      message: "API key is invalid or inactive",475    });476    const overlong = "x".repeat(600);477    const response = await buildApp().request(478      `/commerce/v1/subscriptions/status?userId=${overlong}`,479      { headers: { Authorization: "Bearer openiap-kit_sk_unknown" } },480    );481    expect(response.status).toBe(401);482    expect((await response.json()).error.code).toBe("UNAUTHORIZED");483    // Exactly the authoritative gate ran — the read itself was never consulted.484    expect(mocks.query).toHaveBeenCalledTimes(1);485    expect(mocks.query).toHaveBeenCalledWith("assertServerAccess", {486      apiKey: "openiap-kit_sk_unknown",487    });488  });489 490  it("authenticates a server read before body parsing: unparseable bind body gets 401", async () => {491    mocks.query.mockRejectedValue(new Error("boom"));492    mocks.handleConvexError.mockReturnValue({493      code: "INVALID_API_KEY",494      message: "API key is invalid or inactive",495    });496    const response = await buildApp().request("/commerce/v1/purchases/bind", {497      method: "POST",498      headers: {499        "Content-Type": "application/json",500        Authorization: "Bearer openiap-kit_sk_unknown",501      },502      body: "this is not json",503    });504    expect(response.status).toBe(401);505    expect((await response.json()).error.code).toBe("UNAUTHORIZED");506    expect(mocks.mutation).not.toHaveBeenCalled();507  });508 509  it("never echoes submitted store evidence in GraphQL request errors", async () => {510    // graphql-js coercion messages embed the submitted variable value — an511    // unknown member on VerifyPurchaseInput would echo the whole JWS back to512    // an unauthenticated caller. SPEC.md 8: no evidence in messages, ever.513    const jws = "eyJhbGciOiJFUzI1NiJ9.SECRET_EVIDENCE_PAYLOAD.c2ln";514    const response = await post(515      buildApp(),516      "/commerce/v1/graphql",517      {518        query:519          "mutation VerifyPurchase($input: VerifyPurchaseInput!) { verifyPurchase(input: $input) { isValid } }",520        operationName: "VerifyPurchase",521        variables: { input: { store: "apple", apple: { jws }, junk: 1 } },522      },523      null,524    );525    expect(response.status).toBe(200);526    const text = await response.text();527    expect(text).not.toContain("SECRET_EVIDENCE_PAYLOAD");528    expect(text).not.toContain(jws);529    const body = JSON.parse(text);530    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");531  });532 533  it("never echoes evidence pasted as a document literal (validation path)", async () => {534    const response = await post(535      buildApp(),536      "/commerce/v1/graphql",537      {538        query:539          'mutation { verifyPurchase(input: {store: "amazon", amazon: {receiptId: "r", userId: "u", sandbox: "LITERAL_EVIDENCE.SIG"}}) { isValid } }',540      },541      null,542    );543    const text = await response.text();544    expect(text).not.toContain("LITERAL_EVIDENCE");545    expect(JSON.parse(text).errors[0].extensions.code).toBe("INVALID_REQUEST");546  });547 548  it("authorizes before variable coercion: structurally invalid server input gets 401, not a field-by-field verdict", async () => {549    // graphql-js coercion runs before any resolver, so the pre-execute auth in550    // executeCommerceGraphql is what keeps SPEC.md 5 ordering on GraphQL. An551    // uncredentialed caller probing which members EntitlementsInput requires552    // must learn nothing.553    const response = await post(554      buildApp(),555      "/commerce/v1/graphql",556      {557        query:558          "query Entitlements($input: EntitlementsInput!) { entitlements(input: $input) { userId } }",559        operationName: "Entitlements",560        variables: { input: {} },561      },562      null,563    );564    expect(response.status).toBe(200);565    const body = await response.json();566    expect(body.errors[0].extensions.code).toBe("UNAUTHORIZED");567    expect(body.errors[0].message).not.toContain("userId");568  });569 570  it("authorizes before variable coercion with an unknown server key too", async () => {571    mocks.query.mockRejectedValue(new Error("boom"));572    mocks.handleConvexError.mockReturnValue({573      code: "INVALID_API_KEY",574      message: "API key is invalid or inactive",575    });576    const response = await post(577      buildApp(),578      "/commerce/v1/graphql",579      {580        query:581          "query Entitlements($input: EntitlementsInput!) { entitlements(input: $input) { userId } }",582        operationName: "Entitlements",583        variables: { input: {} },584      },585      "openiap-kit_sk_unknown",586    );587    const body = await response.json();588    expect(body.errors[0].extensions.code).toBe("UNAUTHORIZED");589    expect(mocks.query).toHaveBeenCalledWith("assertServerAccess", {590      apiKey: "openiap-kit_sk_unknown",591    });592  });593 594  it("authenticates a server read before input schema validation (GraphQL)", async () => {595    mocks.query.mockRejectedValue(new Error("boom"));596    mocks.handleConvexError.mockReturnValue({597      code: "INVALID_API_KEY",598      message: "API key is invalid or inactive",599    });600    const response = await post(601      buildApp(),602      "/commerce/v1/graphql",603      {604        query:605          "query SubscriptionStatus($input: SubscriptionStatusInput!) { subscriptionStatus(input: $input) { active } }",606        operationName: "SubscriptionStatus",607        variables: { input: { userId: "x".repeat(600) } },608      },609      "openiap-kit_sk_unknown",610    );611    expect(response.status).toBe(200);612    const body = await response.json();613    expect(body.errors[0].extensions.code).toBe("UNAUTHORIZED");614    expect(mocks.query).toHaveBeenCalledTimes(1);615    expect(mocks.query).toHaveBeenCalledWith("assertServerAccess", {616      apiKey: "openiap-kit_sk_unknown",617    });618  });619 620  it("answers unknown commerce paths with a protocol 404", async () => {621    const response = await buildApp().request("/commerce/v1/does-not-exist");622    expect(response.status).toBe(404);623    expect((await response.json()).error.code).toBe("NOT_FOUND");624  });625});626 627describe("commerce GraphQL adapter", () => {628  beforeEach(() => {629    mocks.action.mockReset();630    mocks.mutation.mockReset();631    mocks.query.mockReset();632    mocks.handleConvexError.mockReset();633    mocks.handleConvexError.mockReturnValue(null);634  });635 636  it("serves the generated projection", () => {637    // operations-sdl.json.sdl is pinned byte-identical to operations.graphql638    // (the normative projection per SPEC.md 7) in the spec package's639    // operations.test.mjs, so building the served schema from it is equivalent640    // to building it from the .graphql file.641    expect(printSchema(commerceGraphqlSchema)).toBe(642      printSchema(buildSchema(operationsSdl.sdl)),643    );644    expect(commerceGraphqlSchema.getSubscriptionType()).toBeUndefined();645  });646 647  it("answers the standard introspection query through the real endpoint", async () => {648    // Exercise the HTTP path (bounds + validate + execute), not graphqlSync —649    // the bounds check must let a single-root-field introspection through.650    const response = await post(buildApp(), "/commerce/v1/graphql", {651      query: getIntrospectionQuery(),652      operationName: "IntrospectionQuery",653    });654    expect(response.status).toBe(200);655    const body = await response.json();656    expect(body.errors).toBeUndefined();657    expect(body.data.__schema).toBeTruthy();658  });659 660  it("blocks alias amplification hidden in an inline fragment", async () => {661    const response = await post(buildApp(), "/commerce/v1/graphql", {662      query:663        "query Amplify { ... on Query { a: providerCapabilities { commerceProtocolVersion } b: providerCapabilities { commerceProtocolVersion } c: providerCapabilities { commerceProtocolVersion } } }",664      operationName: "Amplify",665    });666    expect(response.status).toBe(200);667    const body = await response.json();668    expect(body.data).toBeUndefined();669    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");670    expect(body.errors[0].message).toContain("one operation field");671  });672 673  it("blocks alias amplification hidden in a named fragment spread", async () => {674    const response = await post(buildApp(), "/commerce/v1/graphql", {675      query:676        "query Amplify { ...F } fragment F on Query { a: providerCapabilities { commerceProtocolVersion } b: providerCapabilities { commerceProtocolVersion } }",677      operationName: "Amplify",678    });679    const body = await response.json();680    expect(body.data).toBeUndefined();681    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");682  });683 684  it("rejects a request with more than one operation", async () => {685    const response = await post(buildApp(), "/commerce/v1/graphql", {686      query:687        "query A { providerCapabilities { commerceProtocolVersion } } query B { providerCapabilities { commerceProtocolVersion } }",688      operationName: "A",689    });690    expect(response.status).toBe(200);691    const body = await response.json();692    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");693  });694 695  it("rejects a subscription operation at the door", async () => {696    const response = await post(buildApp(), "/commerce/v1/graphql", {697      query: "subscription { anything }",698    });699    const body = await response.json();700    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");701    expect(body.errors[0].message).toContain("no subscriptions");702  });703 704  it("returns a fixed safe message, never the raw provider detail (GraphQL)", async () => {705    mocks.query.mockRejectedValue(706      new Error("ConvexHttpClient exploded at /very/private/path.ts"),707    );708    // A Convex error whose message carries diagnostic detail.709    mocks.handleConvexError.mockReturnValue({710      code: "INVALID_INPUT",711      message:712        "row subscriptions:abc123 for project proj_secret failed at :512",713    });714    const response = await post(buildApp(), "/commerce/v1/graphql", {715      query:716        "query SubscriptionStatus($input: SubscriptionStatusInput!) { subscriptionStatus(input: $input) { active } }",717      operationName: "SubscriptionStatus",718      variables: { input: { userId: "user-1" } },719    });720    const body = await response.json();721    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");722    expect(body.errors[0].message).toBe("The request is invalid");723    const serialized = JSON.stringify(body);724    expect(serialized).not.toContain("subscriptions:abc123");725    expect(serialized).not.toContain("proj_secret");726    expect(serialized).not.toContain("private/path");727  });728 729  it("refuses a verification credential on a server operation", async () => {730    const response = await post(731      buildApp(),732      "/commerce/v1/graphql",733      {734        query:735          "mutation EraseUser($input: EraseUserInput!) { eraseUser(input: $input) { accepted } }",736        operationName: "EraseUser",737        variables: { input: { userId: "user-1" } },738      },739      CLIENT_KEY,740    );741    const body = await response.json();742    expect(body.errors[0].extensions.code).toBe("FORBIDDEN");743    expect(mocks.mutation).not.toHaveBeenCalled();744  });745 746  // The two bindings must return the same protocol code for the same input747  // (SPEC.md 8). GraphQL scalar coercion does not check the generated schema's748  // patterns and bounds, so the binding runs the same Ajv validation REST does.749  it("rejects a value-space violation with INVALID_REQUEST, matching REST", async () => {750    const badStore = {751      query:752        "mutation VerifyPurchase($input: VerifyPurchaseInput!) { verifyPurchase(input: $input) { isValid } }",753      operationName: "VerifyPurchase",754      variables: { input: { store: "APPLE", apple: { jws: "x".repeat(200) } } },755    };756    const gql = await post(757      buildApp(),758      "/commerce/v1/graphql",759      badStore,760      CLIENT_KEY,761    );762    const gqlBody = await gql.json();763    expect(gqlBody.errors[0].extensions.code).toBe("INVALID_REQUEST");764 765    const rest = await post(766      buildApp(),767      "/commerce/v1/purchases/verify",768      { store: "APPLE", apple: { jws: "x".repeat(200) } },769      CLIENT_KEY,770    );771    expect(rest.status).toBe(400);772    expect((await rest.json()).error.code).toBe("INVALID_REQUEST");773    expect(mocks.action).not.toHaveBeenCalled();774  });775 776  it("rejects an empty JWS with INVALID_REQUEST rather than forwarding it", async () => {777    const response = await post(778      buildApp(),779      "/commerce/v1/graphql",780      {781        query:782          "mutation VerifyPurchase($input: VerifyPurchaseInput!) { verifyPurchase(input: $input) { isValid } }",783        operationName: "VerifyPurchase",784        variables: { input: { store: "apple", apple: { jws: "" } } },785      },786      CLIENT_KEY,787    );788    expect((await response.json()).errors[0].extensions.code).toBe(789      "INVALID_REQUEST",790    );791    expect(mocks.action).not.toHaveBeenCalled();792  });793 794  it("caps a single request to one root field, blocking alias amplification", async () => {795    const aliases = Array.from(796      { length: 5 },797      (_unused, index) =>798        `a${index}: entitlements(input: {userId: "user-1"}) { userId }`,799    ).join(" ");800    const response = await post(buildApp(), "/commerce/v1/graphql", {801      query: `query Amplify { ${aliases} }`,802      operationName: "Amplify",803    });804    const body = await response.json();805    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");806    expect(mocks.query).not.toHaveBeenCalled();807  });808 809  it("answers every GraphQL failure with HTTP 200 (single status policy)", async () => {810    // SPEC.md 7: the GraphQL binding's operation and request-level failures are811    // HTTP 200 with the code in extensions — an oversized body included, so the812    // endpoint never splits its own status contract (429 vs 200).813    const oversized = await post(buildApp(), "/commerce/v1/graphql", {814      query: `query { providerCapabilities { commerceProtocolVersion } } # ${"x".repeat(40_000)}`,815    });816    expect(oversized.status).toBe(200);817    const body = await oversized.json();818    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");819  });820 821  it("rejects an exponential fragment DAG in bounded time (no CPU blowup)", async () => {822    // f0 spreads f1 twice, f1 spreads f2 twice … — naive full expansion is823    // 2^N. A ~1.3 KB request must be rejected in milliseconds, not seconds.824    const depth = 24;825    let query =826      "query Dos { providerCapabilities { commerceProtocolVersion ...f0 } }\n";827    for (let i = 0; i < depth; i += 1) {828      const next =829        i + 1 < depth ? `...f${i + 1} ...f${i + 1}` : "commerceProtocolVersion";830      query += `fragment f${i} on ProviderCapabilities { ${next} }\n`;831    }832    const start = performance.now();833    const response = await post(buildApp(), "/commerce/v1/graphql", {834      query,835      operationName: "Dos",836    });837    const elapsedMs = performance.now() - start;838    const body = await response.json();839    expect(body.data).toBeUndefined();840    expect(body.errors[0].extensions.code).toBe("INVALID_REQUEST");841    expect(elapsedMs).toBeLessThan(250);842    expect(mocks.query).not.toHaveBeenCalled();843  });844});845 846// The verify admission (replay burst + in-flight cap + stable-failure cooldown)847// must apply on BOTH bindings, since verifyPurchase reaches the same848// store-hitting Convex actions as /v1. These use the process-global replay849// store, so each test uses a distinct payload to stay isolated.850describe("commerce verify admission on both bindings", () => {851  let tokenSeq = 0;852  const uniqueToken = () =>853    `admission-google-token-${(tokenSeq += 1)}-${"z".repeat(30)}`;854 855  beforeEach(() => {856    mocks.action.mockReset();857    mocks.mutation.mockReset();858    mocks.query.mockReset();859    mocks.handleConvexError.mockReset();860    mocks.handleConvexError.mockReturnValue(null);861    mocks.action.mockResolvedValue({862      isValid: true,863      state: "ENTITLED",864      productId: "premium.monthly",865      environment: "Production",866    });867  });868 869  const restVerify = (app: Hono, token: string) =>870    post(871      app,872      "/commerce/v1/purchases/verify",873      { store: "google", google: { purchaseToken: token } },874      CLIENT_KEY,875    );876  const gqlVerify = (app: Hono, token: string) =>877    post(878      app,879      "/commerce/v1/graphql",880      {881        query:882          "mutation VerifyPurchase($input: VerifyPurchaseInput!) { verifyPurchase(input: $input) { isValid } }",883        operationName: "VerifyPurchase",884        variables: {885          input: { store: "google", google: { purchaseToken: token } },886        },887      },888      CLIENT_KEY,889    );890 891  it("keeps source-cutover failures as errors and refunds replay on both bindings", async () => {892    vi.stubEnv("IAPKIT_VERIFICATION_SOURCE_SECRET", undefined);893    mocks.handleConvexError.mockImplementation((error: unknown) =>894      error instanceof ReceiptVerificationError895        ? {896            code: error.errorCode,897            message: error.errorMessage,898            retryAfterSec: error.retryAfterSec,899          }900        : null,901    );902    const app = buildApp();903    const token = uniqueToken();904    for (let attempt = 0; attempt < 32; attempt++) {905      const rest = await restVerify(app, token);906      expect(rest.status).toBe(502);907      expect(rest.headers.get("retry-after")).toBeNull();908      expect((await rest.json()).error.code).toBe("VERIFICATION_FAILED");909      const gql = await gqlVerify(app, token);910      expect(gql.status).toBe(200);911      expect((await gql.json()).errors[0].extensions.code).toBe(912        "VERIFICATION_FAILED",913      );914    }915    expect(mocks.action).not.toHaveBeenCalled();916    vi.stubEnv("IAPKIT_VERIFICATION_SOURCE_SECRET", "f".repeat(64));917    expect((await restVerify(app, token)).status).toBe(200);918    expect((await (await gqlVerify(app, token)).json()).errors).toBeUndefined();919    expect(mocks.action).toHaveBeenCalledTimes(2);920  });921 922  it("applies the per-payload replay burst cap to the GraphQL binding", async () => {923    const app = buildApp();924    const token = uniqueToken();925    // Replay guard capacity is 30 per (key, payload). Attempt 31 must be denied.926    let lastBody: { errors?: Array<{ extensions: { code: string } }> } = {};927    for (let attempt = 0; attempt < 31; attempt += 1) {928      lastBody = await (await gqlVerify(app, token)).json();929    }930    expect(lastBody.errors?.[0].extensions.code).toBe("RATE_LIMITED");931  });932 933  it("shares one replay bucket across REST and GraphQL for the same receipt", async () => {934    const app = buildApp();935    const token = uniqueToken();936    // Spend 30 tokens over REST, then the first GraphQL attempt is denied —937    // proving both bindings key the same bucket.938    for (let attempt = 0; attempt < 30; attempt += 1) {939      const res = await restVerify(app, token);940      expect(res.status).toBe(200);941    }942    const gql = await (await gqlVerify(app, token)).json();943    expect(gql.errors?.[0].extensions.code).toBe("RATE_LIMITED");944  });945 946  it("arms the stable-failure cooldown on both bindings", async () => {947    const app = buildApp();948    const token = uniqueToken();949    // A store verdict that is a stable rejection (INAUTHENTIC) must arm the950    // negative cooldown so the next same-payload verify is short-circuited —951    // this was never recorded on the commerce surface before.952    mocks.action.mockResolvedValue({953      isValid: false,954      state: "INAUTHENTIC",955      stableRejection: true,956    });957    const first = await restVerify(app, token);958    expect(first.status).toBe(200);959    expect((await first.json()).isValid).toBe(false);960 961    // The next attempt for the same payload — on the OTHER binding — is denied962    // by the cooldown before reaching the store again, carrying the retry hint963    // in the GraphQL error extensions (SPEC.md 7: operation failure is 200).964    mocks.action.mockClear();965    const secondResponse = await gqlVerify(app, token);966    expect(secondResponse.status).toBe(200);967    const second = await secondResponse.json();968    expect(second.errors?.[0].extensions.code).toBe("RATE_LIMITED");969    expect(second.errors?.[0].extensions.retryAfterSec).toBeGreaterThan(0);970    expect(mocks.action).not.toHaveBeenCalled();971  });972 973  it("returns the retry hint as a REST Retry-After header on cooldown", async () => {974    const app = buildApp();975    const token = uniqueToken();976    mocks.action.mockResolvedValue({977      isValid: false,978      state: "INAUTHENTIC",979      stableRejection: true,980    });981    await restVerify(app, token);982    mocks.action.mockClear();983    const second = await restVerify(app, token);984    expect(second.status).toBe(429);985    expect(Number(second.headers.get("Retry-After"))).toBeGreaterThan(0);986    expect((await second.json()).error.code).toBe("RATE_LIMITED");987    expect(mocks.action).not.toHaveBeenCalled();988  });989 990  it("never arms the cooldown for a valid verdict, even in a stable-looking state", async () => {991    const app = buildApp();992    const token = uniqueToken();993    // isValid: true must never arm the cooldown, regardless of the state token.994    mocks.action.mockResolvedValue({995      isValid: true,996      state: "EXPIRED",997      stableRejection: true,998    });999    const first = await restVerify(app, token);1000    expect((await first.json()).isValid).toBe(true);1001    mocks.action.mockClear();1002    mocks.action.mockResolvedValue({ isValid: true, state: "EXPIRED" });1003    const second = await restVerify(app, token);1004    expect(second.status).toBe(200); // not RATE_LIMITED1005    expect(mocks.action).toHaveBeenCalled();1006  });1007 1008  it("does not cool down Horizon, whose ownership can flip immediately", async () => {1009    const app = buildApp();1010    const userId = `hz-${(tokenSeq += 1)}`;1011    const horizonVerify = () =>1012      post(1013        app,1014        "/commerce/v1/purchases/verify",1015        { store: "horizon", horizon: { userId, sku: "premium.addon" } },1016        CLIENT_KEY,1017      );1018    // A stable INAUTHENTIC on Horizon must stay retryable.1019    mocks.action.mockResolvedValue({1020      isValid: false,1021      state: "INAUTHENTIC",1022      stableRejection: true,1023    });1024    await horizonVerify();1025    mocks.action.mockClear();1026    mocks.action.mockResolvedValue({ isValid: true, state: "ENTITLED" });1027    const second = await horizonVerify();1028    expect(second.status).toBe(200);1029    expect((await second.json()).isValid).toBe(true);1030    expect(mocks.action).toHaveBeenCalled();1031  });1032 1033  it("keys the Amazon replay bucket on sandbox so a sandbox failure spares production", async () => {1034    const app = buildApp();1035    const userId = `az-${(tokenSeq += 1)}`;1036    const receiptId = `receipt-${(tokenSeq += 1)}-${"9".repeat(10)}`;1037    const amazonVerify = (sandbox: boolean) =>1038      post(1039        app,1040        "/commerce/v1/purchases/verify",1041        { store: "amazon", amazon: { userId, receiptId, sandbox } },1042        CLIENT_KEY,1043      );1044    // A stable failure in sandbox arms its own bucket…1045    mocks.action.mockResolvedValue({1046      isValid: false,1047      state: "INAUTHENTIC",1048      stableRejection: true,1049    });1050    await amazonVerify(true);1051    // …and must NOT block the production request for the same receipt id.1052    mocks.action.mockClear();1053    mocks.action.mockResolvedValue({ isValid: true, state: "ENTITLED" });1054    const prod = await amazonVerify(false);1055    expect(prod.status).toBe(200);1056    expect((await prod.json()).isValid).toBe(true);1057    expect(mocks.action).toHaveBeenCalled();1058  });1059 1060  // The same receipt through the /v1 replay guard, which shares the bucket.1061  const v1ReplayStatus = async (token: string) => {1062    const v1 = new Hono<{ Variables: { apiKeyHash: string } }>();1063    v1.use("*", async (c, next) => {1064      c.set("apiKeyHash", hashApiKey(CLIENT_KEY));1065      await next();1066    });1067    v1.post(1068      "/verify",1069      replayGuardMiddleware({1070        getPayload: () => ({ store: "google", purchaseToken: token }),1071      }),1072      (c) => c.json({ isValid: true }, 200),1073    );1074    return (await v1.request("/verify", { method: "POST" })).status;1075  };1076 1077  it("does not charge store outages, so the payload still verifies on /v1 afterwards", async () => {1078    const app = buildApp();1079    const token = uniqueToken();1080    mocks.action.mockRejectedValue(new Error("store down"));1081    mocks.handleConvexError.mockReturnValue({1082      code: "PLAY_STORE_VERIFICATION_FAILED",1083      message: "Google Play did not respond",1084      retryAfterSec: 5,1085    });1086    // Exactly the 30-token budget: charged attempts would leave /v1 nothing.1087    for (let attempt = 0; attempt < 30; attempt += 1) {1088      const response = await restVerify(app, token);1089      expect(response.status).toBe(502);1090      expect((await response.json()).error.code).toBe("VERIFICATION_FAILED");1091      // The protocol defines Retry-After for RATE_LIMITED only.1092      expect(response.headers.get("Retry-After")).toBeNull();1093    }1094    expect(await v1ReplayStatus(token)).toBe(200);1095  });1096 1097  it("still charges failures that are not outages, on both surfaces", async () => {1098    const app = buildApp();1099    const token = uniqueToken();1100    mocks.action.mockRejectedValue(new Error("misconfigured"));1101    mocks.handleConvexError.mockReturnValue({1102      code: "PROJECT_ANDROID_PACKAGE_NAME_NOT_CONFIGURED",1103      message: "Android package name is not configured",1104    });1105    for (let attempt = 0; attempt < 30; attempt += 1)1106      expect((await restVerify(app, token)).status).not.toBe(429);1107    const denied = await restVerify(app, token);1108    expect(denied.status).toBe(429);1109    expect((await denied.json()).error.code).toBe("RATE_LIMITED");1110    // /v1 sees the same spent bucket.1111    expect(await v1ReplayStatus(token)).toBe(429);1112  });1113 1114  it("charges a store-side RATE_LIMITED, so the payload bucket still runs out", async () => {1115    const app = buildApp();1116    const token = uniqueToken();1117    mocks.action.mockRejectedValue(new Error("limited"));1118    // RATE_LIMITED also carries retryAfterSec, but it is a verdict, not an outage.1119    mocks.handleConvexError.mockReturnValue({1120      code: "RATE_LIMITED",1121      message: "Too many verifications",1122      retryAfterSec: 1,1123    });1124    for (let attempt = 0; attempt < 30; attempt += 1)1125      expect((await restVerify(app, token)).status).toBe(429);1126    expect(mocks.action).toHaveBeenCalledTimes(30);1127 1128    // The payload bucket denies the next attempt before it reaches the store.1129    expect((await restVerify(app, token)).status).toBe(429);1130    expect(mocks.action).toHaveBeenCalledTimes(30);1131  });1132});1133 1134describe("commerce entitlement rechecks", () => {1135  beforeEach(() => {1136    mocks.action.mockReset();1137    mocks.mutation.mockReset();1138    mocks.query.mockReset();1139    mocks.handleConvexError.mockReset();1140    mocks.handleConvexError.mockReturnValue(null);1141  });1142 1143  it("reports an exhausted recheck budget as 429 with the retry hint", async () => {1144    mocks.action.mockRejectedValue(new Error("limited"));1145    mocks.handleConvexError.mockReturnValue({1146      code: "RATE_LIMITED",1147      message: "Too many entitlement rechecks",1148      retryAfterSec: 4,1149    });1150    const response = await buildApp().request(1151      "/commerce/v1/entitlements?userId=user-1",1152      { headers: { Authorization: `Bearer ${SERVER_KEY}` } },1153    );1154    expect(response.status).toBe(429);1155    expect(response.headers.get("Retry-After")).toBe("4");1156    expect((await response.json()).error.code).toBe("RATE_LIMITED");1157  });1158});1159 1160// Synthetic service credential for this isolated verification transport fixture.1161beforeEach(() =>1162  vi.stubEnv("IAPKIT_VERIFICATION_SOURCE_SECRET", "f".repeat(64)),1163);1164afterEach(() => vi.unstubAllEnvs());1165