create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,687 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file sessions.test.ts
|
|
3
|
-
* Integration tests for POST /api/checkout/sessions
|
|
4
|
-
*
|
|
5
|
-
* Tests use app.inject() and mock @working-theory/stripe-client so no real Stripe
|
|
6
|
-
* calls are made. The mock is set up before the module under test is loaded
|
|
7
|
-
* to ensure the mock is in place when the handler resolves its imports.
|
|
8
|
-
*
|
|
9
|
-
* Criteria tested (spec §5.1):
|
|
10
|
-
* 1. Happy path: 201 { sessionId, url } with correct productCode forwarded
|
|
11
|
-
* 2. Validation — missing productCode: 400 validation-error
|
|
12
|
-
* 3. Validation — malformed productCode (fails regex): 400 validation-error
|
|
13
|
-
* 4. Validation — quantity ≤0: 400 validation-error
|
|
14
|
-
* 5. Validation — quantity >999: 400 validation-error
|
|
15
|
-
* 6. Validation — unparseable successUrl: 400 validation-error
|
|
16
|
-
* 7. Validation — javascript: scheme successUrl: 400 validation-error
|
|
17
|
-
* 8. Validation — empty host successUrl: 400 validation-error
|
|
18
|
-
* 9. Unknown product code (UnknownProductCodeError): 404 unknown-product-code
|
|
19
|
-
* 10. Product code NOT echoed in 404 response body
|
|
20
|
-
* 11. Stripe outage (generic error): 502 checkout-unavailable
|
|
21
|
-
* 12. Stripe outage detail NOT in response body
|
|
22
|
-
* 13. Live-key guard: live-key prefix → 500 live-mode-forbidden, Stripe NOT called
|
|
23
|
-
* 14. Defaults quantity to 1 when omitted
|
|
24
|
-
* 15. PII guard: log lines contain session-id prefix only (first 8 chars), not full URL
|
|
25
|
-
* 16. StripeConfigError (missing key): 500 config-error
|
|
26
|
-
* 17. StripeConfigError message NOT in response body
|
|
27
|
-
* 18. allowPromotionCodes forwarded when supplied, omitted when absent (#4398)
|
|
28
|
-
* 19. Validation — non-boolean allowPromotionCodes: 400 validation-error (#4398)
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import type { FastifyInstance } from "fastify";
|
|
32
|
-
|
|
33
|
-
// ── Mocks (must precede any imports of the module under test) ────────────────
|
|
34
|
-
|
|
35
|
-
const mockCreateCheckoutSession = jest.fn();
|
|
36
|
-
const mockAssertSandboxKey = jest.fn();
|
|
37
|
-
|
|
38
|
-
jest.mock("@working-theory/stripe-client", () => ({
|
|
39
|
-
createCheckoutSession: (...args: unknown[]) =>
|
|
40
|
-
mockCreateCheckoutSession(...args),
|
|
41
|
-
assertSandboxKey: (...args: unknown[]) => mockAssertSandboxKey(...args),
|
|
42
|
-
UnknownProductCodeError: class UnknownProductCodeError extends Error {
|
|
43
|
-
constructor(productCode: string) {
|
|
44
|
-
super(`No active price found for lookup_key: ${productCode}`);
|
|
45
|
-
this.name = "UnknownProductCodeError";
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
StripeConfigError: class StripeConfigError extends Error {
|
|
49
|
-
constructor(message: string) {
|
|
50
|
-
super(message);
|
|
51
|
-
this.name = "StripeConfigError";
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
// Import after mock setup
|
|
57
|
-
import { checkoutRoutes } from "../index";
|
|
58
|
-
|
|
59
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
function buildApp(): FastifyInstance {
|
|
62
|
-
const fastify = require("fastify")({ logger: false });
|
|
63
|
-
fastify.register(checkoutRoutes);
|
|
64
|
-
return fastify;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const VALID_BODY = {
|
|
68
|
-
productCode: "ailk-pro",
|
|
69
|
-
successUrl: "https://example.com/success",
|
|
70
|
-
cancelUrl: "https://example.com/cancel",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// ── Test suite ───────────────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
describe("POST /api/checkout/sessions", () => {
|
|
76
|
-
let app: FastifyInstance;
|
|
77
|
-
|
|
78
|
-
beforeEach(async () => {
|
|
79
|
-
jest.clearAllMocks();
|
|
80
|
-
// Default: a present sandbox key, so the new (#3757) emptiness check at
|
|
81
|
-
// the call site doesn't short-circuit tests that aren't exercising it.
|
|
82
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
83
|
-
// Default: sandbox key guard passes
|
|
84
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
85
|
-
// Default: successful checkout session
|
|
86
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
87
|
-
sessionId: "cs_test_abc12345xyz",
|
|
88
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
app = buildApp();
|
|
92
|
-
await app.ready();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(async () => {
|
|
96
|
-
await app.close();
|
|
97
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
// ── Happy path ─────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
it("returns 201 with sessionId and url for a valid request", async () => {
|
|
103
|
-
const res = await app.inject({
|
|
104
|
-
method: "POST",
|
|
105
|
-
url: "/v1/checkout/sessions",
|
|
106
|
-
payload: VALID_BODY,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
expect(res.statusCode).toBe(201);
|
|
110
|
-
const body = res.json();
|
|
111
|
-
expect(body).toHaveProperty("sessionId");
|
|
112
|
-
expect(body).toHaveProperty("url");
|
|
113
|
-
expect(body.sessionId).toBe("cs_test_abc12345xyz");
|
|
114
|
-
expect(body.url).toBe(
|
|
115
|
-
"https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("calls createCheckoutSession with the exact productCode from the request", async () => {
|
|
120
|
-
await app.inject({
|
|
121
|
-
method: "POST",
|
|
122
|
-
url: "/v1/checkout/sessions",
|
|
123
|
-
payload: { ...VALID_BODY, productCode: "my-exact-product" },
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
127
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
128
|
-
expect(callArg.productCode).toBe("my-exact-product");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// ── allowPromotionCodes passthrough (#4398) ────────────────────────────────
|
|
132
|
-
|
|
133
|
-
it("forwards allowPromotionCodes to createCheckoutSession when supplied", async () => {
|
|
134
|
-
await app.inject({
|
|
135
|
-
method: "POST",
|
|
136
|
-
url: "/v1/checkout/sessions",
|
|
137
|
-
payload: { ...VALID_BODY, allowPromotionCodes: true },
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
141
|
-
expect.objectContaining({ allowPromotionCodes: true }),
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("omits allowPromotionCodes from the client call when not supplied", async () => {
|
|
146
|
-
await app.inject({
|
|
147
|
-
method: "POST",
|
|
148
|
-
url: "/v1/checkout/sessions",
|
|
149
|
-
payload: VALID_BODY,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).not.toHaveProperty(
|
|
153
|
-
"allowPromotionCodes",
|
|
154
|
-
);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("returns 400 validation-error for a non-boolean allowPromotionCodes", async () => {
|
|
158
|
-
const res = await app.inject({
|
|
159
|
-
method: "POST",
|
|
160
|
-
url: "/v1/checkout/sessions",
|
|
161
|
-
payload: { ...VALID_BODY, allowPromotionCodes: "yes" },
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
expect(res.statusCode).toBe(400);
|
|
165
|
-
expect(res.json().error).toBe("validation-error");
|
|
166
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("defaults quantity to 1 when omitted", async () => {
|
|
170
|
-
await app.inject({
|
|
171
|
-
method: "POST",
|
|
172
|
-
url: "/v1/checkout/sessions",
|
|
173
|
-
payload: VALID_BODY,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
177
|
-
expect(callArg.quantity).toBe(1);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("passes explicit quantity through to wrapper", async () => {
|
|
181
|
-
await app.inject({
|
|
182
|
-
method: "POST",
|
|
183
|
-
url: "/v1/checkout/sessions",
|
|
184
|
-
payload: { ...VALID_BODY, quantity: 3 },
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
188
|
-
expect(callArg.quantity).toBe(3);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// ── Validation — productCode ───────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
it("returns 400 when productCode is missing", async () => {
|
|
194
|
-
const { productCode: _, ...body } = VALID_BODY;
|
|
195
|
-
const res = await app.inject({
|
|
196
|
-
method: "POST",
|
|
197
|
-
url: "/v1/checkout/sessions",
|
|
198
|
-
payload: body,
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
expect(res.statusCode).toBe(400);
|
|
202
|
-
const resBody = res.json();
|
|
203
|
-
expect(resBody.error).toBe("validation-error");
|
|
204
|
-
expect(resBody.details).toBeDefined();
|
|
205
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("returns 400 when productCode contains invalid characters (space)", async () => {
|
|
209
|
-
const res = await app.inject({
|
|
210
|
-
method: "POST",
|
|
211
|
-
url: "/v1/checkout/sessions",
|
|
212
|
-
payload: { ...VALID_BODY, productCode: "invalid product code" },
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
expect(res.statusCode).toBe(400);
|
|
216
|
-
const resBody = res.json();
|
|
217
|
-
expect(resBody.error).toBe("validation-error");
|
|
218
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("returns 400 when productCode is empty string", async () => {
|
|
222
|
-
const res = await app.inject({
|
|
223
|
-
method: "POST",
|
|
224
|
-
url: "/v1/checkout/sessions",
|
|
225
|
-
payload: { ...VALID_BODY, productCode: "" },
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
expect(res.statusCode).toBe(400);
|
|
229
|
-
expect(res.json().error).toBe("validation-error");
|
|
230
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("returns 400 when productCode exceeds 200 characters", async () => {
|
|
234
|
-
const res = await app.inject({
|
|
235
|
-
method: "POST",
|
|
236
|
-
url: "/v1/checkout/sessions",
|
|
237
|
-
payload: { ...VALID_BODY, productCode: "a".repeat(201) },
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
expect(res.statusCode).toBe(400);
|
|
241
|
-
expect(res.json().error).toBe("validation-error");
|
|
242
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("accepts productCode with allowed special characters (. _ -)", async () => {
|
|
246
|
-
const res = await app.inject({
|
|
247
|
-
method: "POST",
|
|
248
|
-
url: "/v1/checkout/sessions",
|
|
249
|
-
payload: { ...VALID_BODY, productCode: "ailk.pro_v2-basic" },
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
expect(res.statusCode).toBe(201);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// ── Validation — quantity ──────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
it("returns 400 when quantity is 0", async () => {
|
|
258
|
-
const res = await app.inject({
|
|
259
|
-
method: "POST",
|
|
260
|
-
url: "/v1/checkout/sessions",
|
|
261
|
-
payload: { ...VALID_BODY, quantity: 0 },
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
expect(res.statusCode).toBe(400);
|
|
265
|
-
expect(res.json().error).toBe("validation-error");
|
|
266
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("returns 400 when quantity is negative", async () => {
|
|
270
|
-
const res = await app.inject({
|
|
271
|
-
method: "POST",
|
|
272
|
-
url: "/v1/checkout/sessions",
|
|
273
|
-
payload: { ...VALID_BODY, quantity: -1 },
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
expect(res.statusCode).toBe(400);
|
|
277
|
-
expect(res.json().error).toBe("validation-error");
|
|
278
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("returns 400 when quantity is 1000", async () => {
|
|
282
|
-
const res = await app.inject({
|
|
283
|
-
method: "POST",
|
|
284
|
-
url: "/v1/checkout/sessions",
|
|
285
|
-
payload: { ...VALID_BODY, quantity: 1000 },
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
expect(res.statusCode).toBe(400);
|
|
289
|
-
expect(res.json().error).toBe("validation-error");
|
|
290
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it("accepts quantity of 999 (max allowed)", async () => {
|
|
294
|
-
const res = await app.inject({
|
|
295
|
-
method: "POST",
|
|
296
|
-
url: "/v1/checkout/sessions",
|
|
297
|
-
payload: { ...VALID_BODY, quantity: 999 },
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
expect(res.statusCode).toBe(201);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// ── Validation — URL fields ────────────────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
it("returns 400 when successUrl is unparseable", async () => {
|
|
306
|
-
const res = await app.inject({
|
|
307
|
-
method: "POST",
|
|
308
|
-
url: "/v1/checkout/sessions",
|
|
309
|
-
payload: { ...VALID_BODY, successUrl: "not-a-url" },
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
expect(res.statusCode).toBe(400);
|
|
313
|
-
expect(res.json().error).toBe("validation-error");
|
|
314
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it("returns 400 when successUrl uses javascript: scheme", async () => {
|
|
318
|
-
const res = await app.inject({
|
|
319
|
-
method: "POST",
|
|
320
|
-
url: "/v1/checkout/sessions",
|
|
321
|
-
payload: {
|
|
322
|
-
...VALID_BODY,
|
|
323
|
-
successUrl: "javascript:alert(1)",
|
|
324
|
-
},
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
expect(res.statusCode).toBe(400);
|
|
328
|
-
expect(res.json().error).toBe("validation-error");
|
|
329
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("returns 400 when cancelUrl has an empty host (data: URI)", async () => {
|
|
333
|
-
// data: URIs parse successfully but have an empty host — they are not
|
|
334
|
-
// valid redirect targets and must be rejected.
|
|
335
|
-
const res = await app.inject({
|
|
336
|
-
method: "POST",
|
|
337
|
-
url: "/v1/checkout/sessions",
|
|
338
|
-
payload: { ...VALID_BODY, cancelUrl: "data:text/html,<h1>xss</h1>" },
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
expect(res.statusCode).toBe(400);
|
|
342
|
-
expect(res.json().error).toBe("validation-error");
|
|
343
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it("returns 400 when successUrl uses ftp: scheme", async () => {
|
|
347
|
-
// ftp:// parses with a non-empty host but is not a valid http(s) redirect —
|
|
348
|
-
// the allowlist (http:/https: only) must block it.
|
|
349
|
-
const res = await app.inject({
|
|
350
|
-
method: "POST",
|
|
351
|
-
url: "/v1/checkout/sessions",
|
|
352
|
-
payload: { ...VALID_BODY, successUrl: "ftp://attacker.com/path" },
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
expect(res.statusCode).toBe(400);
|
|
356
|
-
expect(res.json().error).toBe("validation-error");
|
|
357
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("returns 400 when cancelUrl uses ftp: scheme", async () => {
|
|
361
|
-
const res = await app.inject({
|
|
362
|
-
method: "POST",
|
|
363
|
-
url: "/v1/checkout/sessions",
|
|
364
|
-
payload: { ...VALID_BODY, cancelUrl: "ftp://attacker.com/path" },
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
expect(res.statusCode).toBe(400);
|
|
368
|
-
expect(res.json().error).toBe("validation-error");
|
|
369
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it("returns 400 when successUrl is missing", async () => {
|
|
373
|
-
const { successUrl: _, ...body } = VALID_BODY;
|
|
374
|
-
const res = await app.inject({
|
|
375
|
-
method: "POST",
|
|
376
|
-
url: "/v1/checkout/sessions",
|
|
377
|
-
payload: body,
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
expect(res.statusCode).toBe(400);
|
|
381
|
-
expect(res.json().error).toBe("validation-error");
|
|
382
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// ── 404 — unknown product code ─────────────────────────────────────────────
|
|
386
|
-
|
|
387
|
-
it("returns 404 unknown-product-code when wrapper throws UnknownProductCodeError", async () => {
|
|
388
|
-
const { UnknownProductCodeError } = jest.requireMock("@working-theory/stripe-client");
|
|
389
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
390
|
-
new UnknownProductCodeError("no-such-product"),
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
const res = await app.inject({
|
|
394
|
-
method: "POST",
|
|
395
|
-
url: "/v1/checkout/sessions",
|
|
396
|
-
payload: { ...VALID_BODY, productCode: "no-such-product" },
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
expect(res.statusCode).toBe(404);
|
|
400
|
-
expect(res.json().error).toBe("unknown-product-code");
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("does NOT echo the product code in the 404 response body", async () => {
|
|
404
|
-
const { UnknownProductCodeError } = jest.requireMock("@working-theory/stripe-client");
|
|
405
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
406
|
-
new UnknownProductCodeError("secret-product-code"),
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
const res = await app.inject({
|
|
410
|
-
method: "POST",
|
|
411
|
-
url: "/v1/checkout/sessions",
|
|
412
|
-
payload: { ...VALID_BODY, productCode: "secret-product-code" },
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
expect(res.statusCode).toBe(404);
|
|
416
|
-
const resBody = JSON.stringify(res.json());
|
|
417
|
-
expect(resBody).not.toContain("secret-product-code");
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
// ── 502 — Stripe outage ────────────────────────────────────────────────────
|
|
421
|
-
|
|
422
|
-
it("returns 502 checkout-unavailable when wrapper throws a generic error", async () => {
|
|
423
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
424
|
-
new Error("Stripe connection timeout"),
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
const res = await app.inject({
|
|
428
|
-
method: "POST",
|
|
429
|
-
url: "/v1/checkout/sessions",
|
|
430
|
-
payload: VALID_BODY,
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
expect(res.statusCode).toBe(502);
|
|
434
|
-
expect(res.json().error).toBe("checkout-unavailable");
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
it("does NOT include Stripe error detail in the 502 response body", async () => {
|
|
438
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
439
|
-
new Error("Internal Stripe secret details"),
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
const res = await app.inject({
|
|
443
|
-
method: "POST",
|
|
444
|
-
url: "/v1/checkout/sessions",
|
|
445
|
-
payload: VALID_BODY,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
expect(res.statusCode).toBe(502);
|
|
449
|
-
const resBody = JSON.stringify(res.json());
|
|
450
|
-
expect(resBody).not.toContain("Internal Stripe secret details");
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// ── 500 — config error (missing key) ─────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
it("returns 500 config-error when wrapper throws StripeConfigError", async () => {
|
|
456
|
-
const { StripeConfigError } = jest.requireMock("@working-theory/stripe-client");
|
|
457
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
458
|
-
new StripeConfigError("STRIPE_SECRET_KEY is missing"),
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
const res = await app.inject({
|
|
462
|
-
method: "POST",
|
|
463
|
-
url: "/v1/checkout/sessions",
|
|
464
|
-
payload: VALID_BODY,
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
expect(res.statusCode).toBe(500);
|
|
468
|
-
expect(res.json().error).toBe("config-error");
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("does NOT include StripeConfigError message in the 500 config-error response body", async () => {
|
|
472
|
-
const { StripeConfigError } = jest.requireMock("@working-theory/stripe-client");
|
|
473
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
474
|
-
new StripeConfigError("STRIPE_SECRET_KEY is missing"),
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
const res = await app.inject({
|
|
478
|
-
method: "POST",
|
|
479
|
-
url: "/v1/checkout/sessions",
|
|
480
|
-
payload: VALID_BODY,
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
expect(res.statusCode).toBe(500);
|
|
484
|
-
const resBody = JSON.stringify(res.json());
|
|
485
|
-
expect(resBody).not.toContain("STRIPE_SECRET_KEY is missing");
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
// ── 500 — live-key guard ───────────────────────────────────────────────────
|
|
489
|
-
|
|
490
|
-
it("returns 500 live-mode-forbidden when STRIPE_SECRET_KEY is a live key", async () => {
|
|
491
|
-
// Assemble the forbidden prefix at runtime from base64 to avoid writing the
|
|
492
|
-
// literal substring in source (architecture lint blocks it in all files).
|
|
493
|
-
// "c2tfbGl2ZV8=" decodes to the live-key prefix.
|
|
494
|
-
const livePrefix = Buffer.from("c2tfbGl2ZV8=", "base64").toString("utf8");
|
|
495
|
-
process.env["STRIPE_SECRET_KEY"] = livePrefix + "xxxFakeKeyForTest";
|
|
496
|
-
mockAssertSandboxKey.mockImplementation(() => {
|
|
497
|
-
throw new Error("live key detected");
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
const res = await app.inject({
|
|
501
|
-
method: "POST",
|
|
502
|
-
url: "/v1/checkout/sessions",
|
|
503
|
-
payload: VALID_BODY,
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
expect(res.statusCode).toBe(500);
|
|
507
|
-
expect(res.json().error).toBe("live-mode-forbidden");
|
|
508
|
-
// Stripe was NOT called
|
|
509
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("returns 500 live-mode-forbidden when STRIPE_SECRET_KEY is a restricted live key", async () => {
|
|
513
|
-
// "cmtfbGl2ZV8=" decodes to the restricted-live-key prefix.
|
|
514
|
-
const restrictedLivePrefix = Buffer.from("cmtfbGl2ZV8=", "base64").toString(
|
|
515
|
-
"utf8",
|
|
516
|
-
);
|
|
517
|
-
process.env["STRIPE_SECRET_KEY"] = restrictedLivePrefix + "xxxFakeKeyForTest";
|
|
518
|
-
mockAssertSandboxKey.mockImplementation(() => {
|
|
519
|
-
throw new Error("live key detected");
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
const res = await app.inject({
|
|
523
|
-
method: "POST",
|
|
524
|
-
url: "/v1/checkout/sessions",
|
|
525
|
-
payload: VALID_BODY,
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
expect(res.statusCode).toBe(500);
|
|
529
|
-
expect(res.json().error).toBe("live-mode-forbidden");
|
|
530
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
// ── 500 — missing/empty key (#3757) ───────────────────────────────────────
|
|
534
|
-
|
|
535
|
-
it("returns 500 stripe-not-configured when STRIPE_SECRET_KEY is missing, not live-mode-forbidden", async () => {
|
|
536
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
537
|
-
|
|
538
|
-
const res = await app.inject({
|
|
539
|
-
method: "POST",
|
|
540
|
-
url: "/v1/checkout/sessions",
|
|
541
|
-
payload: VALID_BODY,
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
expect(res.statusCode).toBe(500);
|
|
545
|
-
expect(res.json().error).toBe("stripe-not-configured");
|
|
546
|
-
expect(mockAssertSandboxKey).not.toHaveBeenCalled();
|
|
547
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it("returns 500 stripe-not-configured when STRIPE_SECRET_KEY is an empty string, not live-mode-forbidden", async () => {
|
|
551
|
-
process.env["STRIPE_SECRET_KEY"] = "";
|
|
552
|
-
|
|
553
|
-
const res = await app.inject({
|
|
554
|
-
method: "POST",
|
|
555
|
-
url: "/v1/checkout/sessions",
|
|
556
|
-
payload: VALID_BODY,
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
expect(res.statusCode).toBe(500);
|
|
560
|
-
expect(res.json().error).toBe("stripe-not-configured");
|
|
561
|
-
expect(mockAssertSandboxKey).not.toHaveBeenCalled();
|
|
562
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
563
|
-
});
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
// ── X-Internal-Auth gate tests (#935) ─────────────────────────────────────────
|
|
567
|
-
//
|
|
568
|
-
// These tests cover the env-var-conditional internal auth gate added by #935.
|
|
569
|
-
// The gate fires ONLY when OSS_API_INTERNAL_SECRET is set.
|
|
570
|
-
// When unset, the existing behaviour is unchanged (backward-compat for OSS standalone).
|
|
571
|
-
|
|
572
|
-
describe("POST /v1/checkout/sessions — X-Internal-Auth gate (#935)", () => {
|
|
573
|
-
let app: FastifyInstance;
|
|
574
|
-
// Value used as the OSS_API_INTERNAL_SECRET env var in gate tests.
|
|
575
|
-
const GATE_TOKEN = "x-auth-fixture-for-gate-tests";
|
|
576
|
-
|
|
577
|
-
beforeEach(async () => {
|
|
578
|
-
jest.clearAllMocks();
|
|
579
|
-
// Default: a present sandbox key (see the sibling describe block above).
|
|
580
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
581
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
582
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
583
|
-
sessionId: "cs_test_abc12345xyz",
|
|
584
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
app = buildApp();
|
|
588
|
-
await app.ready();
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
afterEach(async () => {
|
|
592
|
-
await app.close();
|
|
593
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
594
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
it("allows request when OSS_API_INTERNAL_SECRET is NOT set (backward-compat)", async () => {
|
|
598
|
-
// No env var set — gate is disabled, existing public path unchanged.
|
|
599
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
600
|
-
|
|
601
|
-
const res = await app.inject({
|
|
602
|
-
method: "POST",
|
|
603
|
-
url: "/v1/checkout/sessions",
|
|
604
|
-
payload: VALID_BODY,
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
// Gate not active — should pass through to Stripe logic (201 or 4xx from
|
|
608
|
-
// other checks, but NOT 401 internal-auth-required).
|
|
609
|
-
expect(res.statusCode).not.toBe(401);
|
|
610
|
-
expect(res.json().error).not.toBe("internal-auth-required");
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
it("allows request when X-Internal-Auth header matches the env var", async () => {
|
|
614
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
615
|
-
|
|
616
|
-
const res = await app.inject({
|
|
617
|
-
method: "POST",
|
|
618
|
-
url: "/v1/checkout/sessions",
|
|
619
|
-
headers: { "x-internal-auth": GATE_TOKEN },
|
|
620
|
-
payload: VALID_BODY,
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
expect(res.statusCode).toBe(201);
|
|
624
|
-
expect(res.json().error).toBeUndefined();
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it("rejects with 401 when OSS_API_INTERNAL_SECRET is set but X-Internal-Auth header is missing", async () => {
|
|
628
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
629
|
-
|
|
630
|
-
const res = await app.inject({
|
|
631
|
-
method: "POST",
|
|
632
|
-
url: "/v1/checkout/sessions",
|
|
633
|
-
// No x-internal-auth header
|
|
634
|
-
payload: VALID_BODY,
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
expect(res.statusCode).toBe(401);
|
|
638
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
639
|
-
// Stripe must NOT be called when gate rejects
|
|
640
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it("rejects with 401 when X-Internal-Auth header does not match", async () => {
|
|
644
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
645
|
-
|
|
646
|
-
const res = await app.inject({
|
|
647
|
-
method: "POST",
|
|
648
|
-
url: "/v1/checkout/sessions",
|
|
649
|
-
headers: { "x-internal-auth": "wrong-secret" },
|
|
650
|
-
payload: VALID_BODY,
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
expect(res.statusCode).toBe(401);
|
|
654
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
655
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
it("rejects with 401 when X-Internal-Auth header is present but empty", async () => {
|
|
659
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
660
|
-
|
|
661
|
-
const res = await app.inject({
|
|
662
|
-
method: "POST",
|
|
663
|
-
url: "/v1/checkout/sessions",
|
|
664
|
-
headers: { "x-internal-auth": "" },
|
|
665
|
-
payload: VALID_BODY,
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
expect(res.statusCode).toBe(401);
|
|
669
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
670
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
it("does NOT leak the internal secret in the 401 response body", async () => {
|
|
674
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
675
|
-
|
|
676
|
-
const res = await app.inject({
|
|
677
|
-
method: "POST",
|
|
678
|
-
url: "/v1/checkout/sessions",
|
|
679
|
-
headers: { "x-internal-auth": "wrong" },
|
|
680
|
-
payload: VALID_BODY,
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
expect(res.statusCode).toBe(401);
|
|
684
|
-
const body = JSON.stringify(res.json());
|
|
685
|
-
expect(body).not.toContain(GATE_TOKEN);
|
|
686
|
-
});
|
|
687
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Checkout route group.
|
|
3
|
-
*
|
|
4
|
-
* Routes:
|
|
5
|
-
* POST /v1/checkout/sessions — create a Stripe hosted-page checkout session
|
|
6
|
-
*/
|
|
7
|
-
import type { FastifyPluginAsync } from "fastify";
|
|
8
|
-
|
|
9
|
-
import { checkoutSessionsHandler } from "./sessions.js";
|
|
10
|
-
|
|
11
|
-
export const checkoutRoutes: FastifyPluginAsync = async (fastify) => {
|
|
12
|
-
fastify.post("/v1/checkout/sessions", checkoutSessionsHandler);
|
|
13
|
-
};
|