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