← Provider replacement walkthrough

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

Source snapshot · 2026-09-09

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