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