@open-mercato/checkout 0.6.8-develop.6963.1.04a6d320e9 → 0.6.8-develop.6964.1.36b364cfd8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/checkout",
3
- "version": "0.6.8-develop.6963.1.04a6d320e9",
3
+ "version": "0.6.8-develop.6964.1.36b364cfd8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,18 +62,18 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.6.8-develop.6963.1.04a6d320e9",
66
- "@open-mercato/ui": "0.6.8-develop.6963.1.04a6d320e9",
65
+ "@open-mercato/core": "0.6.8-develop.6964.1.36b364cfd8",
66
+ "@open-mercato/ui": "0.6.8-develop.6964.1.36b364cfd8",
67
67
  "bcryptjs": "^3.0.3"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/shared": "0.6.8-develop.6963.1.04a6d320e9",
71
+ "@open-mercato/shared": "0.6.8-develop.6964.1.36b364cfd8",
72
72
  "react": "^19.0.0",
73
73
  "react-dom": "^19.0.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.8-develop.6963.1.04a6d320e9",
76
+ "@open-mercato/shared": "0.6.8-develop.6964.1.36b364cfd8",
77
77
  "@types/jest": "^30.0.0",
78
78
  "@types/react": "^19.2.17",
79
79
  "@types/react-dom": "^19.2.3",
@@ -2,12 +2,10 @@ import { NextResponse } from 'next/server'
2
2
  import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
- import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
6
- import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
7
5
  import { CheckoutLink } from '../../../data/entities'
8
6
  import { CHECKOUT_ENTITY_IDS } from '../../../lib/constants'
9
7
  import { resolveCheckoutPublicCustomFields } from '../../../lib/customFields'
10
- import { buildCheckoutRateLimitKey, checkoutPublicViewRateLimitConfig } from '../../../lib/rateLimiter'
8
+ import { checkoutPublicViewRateLimitConfig, enforceCheckoutRateLimit } from '../../../lib/rateLimiter'
11
9
  import { handleCheckoutRouteError, readCheckoutAccessCookie, requirePreviewContext } from '../../helpers'
12
10
  import {
13
11
  isCheckoutLinkPublic,
@@ -30,14 +28,16 @@ export async function GET(req: Request, { params }: { params: Promise<{ slug: st
30
28
  const container = await createRequestContainer()
31
29
  if (previewRequested) await requirePreviewContext(req)
32
30
  if (!previewRequested) {
33
- try {
34
- const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
35
- const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-public-view')
36
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPublicViewRateLimitConfig, key, 'Too many checkout page requests. Please try again later.')
37
- if (rateLimitResponse) return rateLimitResponse
38
- } catch {
39
- // Rate limiting is fail-open
40
- }
31
+ // Fail-open: this endpoint is read-only.
32
+ const rateLimitResponse = await enforceCheckoutRateLimit({
33
+ req,
34
+ container,
35
+ config: checkoutPublicViewRateLimitConfig,
36
+ namespace: 'checkout-public-view',
37
+ errorMessage: 'Too many checkout page requests. Please try again later.',
38
+ posture: 'fail-open',
39
+ })
40
+ if (rateLimitResponse) return rateLimitResponse
41
41
  }
42
42
  const em = container.resolve('em')
43
43
  const link = await findOneWithDecryption(em, CheckoutLink, {
@@ -1,11 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
- import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
- import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
6
4
  import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
7
5
  import { CheckoutLink, CheckoutTransaction } from '../../../../../data/entities'
8
- import { buildCheckoutRateLimitKey, checkoutStatusRateLimitConfig } from '../../../../../lib/rateLimiter'
6
+ import { checkoutStatusRateLimitConfig, enforceCheckoutRateLimit } from '../../../../../lib/rateLimiter'
9
7
  import { handleCheckoutRouteError, requireCheckoutPasswordSession } from '../../../../helpers'
10
8
  import { checkoutTag } from '../../../../openapi'
11
9
 
@@ -18,14 +16,16 @@ export async function GET(req: Request, { params }: { params: Promise<{ slug: st
18
16
  try {
19
17
  const resolvedParams = await params
20
18
  const container = await createRequestContainer()
21
- try {
22
- const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
23
- const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-status')
24
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutStatusRateLimitConfig, key, 'Too many checkout status requests. Please try again later.')
25
- if (rateLimitResponse) return rateLimitResponse
26
- } catch {
27
- // Rate limiting is fail-open
28
- }
19
+ // Fail-open: this endpoint is read-only.
20
+ const rateLimitResponse = await enforceCheckoutRateLimit({
21
+ req,
22
+ container,
23
+ config: checkoutStatusRateLimitConfig,
24
+ namespace: 'checkout-status',
25
+ errorMessage: 'Too many checkout status requests. Please try again later.',
26
+ posture: 'fail-open',
27
+ })
28
+ if (rateLimitResponse) return rateLimitResponse
29
29
  const em = container.resolve('em')
30
30
  const paymentGatewayService = container.resolve('paymentGatewayService') as PaymentGatewayService
31
31
  const link = await findOneWithDecryption(em, CheckoutLink, {
@@ -26,6 +26,7 @@ jest.mock('@open-mercato/shared/lib/ratelimit/helpers', () => ({
26
26
  checkRateLimit: jest.fn(),
27
27
  getClientIp: jest.fn(),
28
28
  RATE_LIMIT_FALLBACK_KEY: 'global',
29
+ RATE_LIMIT_UNAVAILABLE_FALLBACK: 'Service temporarily unavailable. Please try again later.',
29
30
  }))
30
31
 
31
32
  jest.mock('../../../../../events', () => ({
@@ -375,4 +376,102 @@ describe('POST /api/checkout/pay/[slug]/submit', () => {
375
376
  const payload = await response.json()
376
377
  expect(payload.transactionId).toBe(TRANSACTION_ID)
377
378
  })
379
+
380
+ describe('rate limiting is fail-closed', () => {
381
+ function submitRequest() {
382
+ return POST(
383
+ new Request('https://merchant.example/api/checkout/pay/donate/submit', {
384
+ method: 'POST',
385
+ headers: {
386
+ 'content-type': 'application/json',
387
+ 'Idempotency-Key': 'rate-limit-key1',
388
+ origin: 'https://merchant.example',
389
+ },
390
+ body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
391
+ }),
392
+ { params: { slug: 'donate' } },
393
+ )
394
+ }
395
+
396
+ it('asks the limiter to fail closed', async () => {
397
+ ;(findOneWithDecryption as jest.Mock).mockResolvedValue(createLink())
398
+
399
+ await submitRequest()
400
+
401
+ expect(checkRateLimit).toHaveBeenCalledWith(
402
+ expect.anything(),
403
+ expect.anything(),
404
+ expect.any(String),
405
+ expect.any(String),
406
+ expect.objectContaining({ failClosed: true }),
407
+ )
408
+ })
409
+
410
+ it('returns 503 without creating a payment session when the limiter cannot decide', async () => {
411
+ ;(findOneWithDecryption as jest.Mock).mockResolvedValue(createLink())
412
+ ;(checkRateLimit as jest.Mock).mockRejectedValue(new Error('backing store unreachable'))
413
+
414
+ const response = await submitRequest()
415
+
416
+ expect(response.status).toBe(503)
417
+ expect(await response.json()).toEqual({
418
+ error: 'Service temporarily unavailable. Please try again later.',
419
+ })
420
+ expect(mockCreatePaymentSession).not.toHaveBeenCalled()
421
+ expect(mockCommandExecute).not.toHaveBeenCalled()
422
+ })
423
+
424
+ it('passes a 503 the limiter itself produced straight through', async () => {
425
+ ;(findOneWithDecryption as jest.Mock).mockResolvedValue(createLink())
426
+ ;(checkRateLimit as jest.Mock).mockResolvedValue(
427
+ new Response(JSON.stringify({ error: 'unavailable' }), { status: 503 }),
428
+ )
429
+
430
+ const response = await submitRequest()
431
+
432
+ expect(response.status).toBe(503)
433
+ expect(mockCreatePaymentSession).not.toHaveBeenCalled()
434
+ expect(mockCommandExecute).not.toHaveBeenCalled()
435
+ })
436
+
437
+ // A rate limiter that is not registered at all is a configuration error, not a
438
+ // transient outage: the request is served (as it was before rate limiting was made
439
+ // fail-closed) instead of being rejected as "temporarily unavailable" indefinitely.
440
+ it('serves the request when no rate limiter is registered', async () => {
441
+ ;(findOneWithDecryption as jest.Mock)
442
+ .mockResolvedValueOnce(createLink())
443
+ .mockResolvedValueOnce(createTransaction())
444
+ .mockResolvedValueOnce(createTransaction())
445
+ .mockResolvedValueOnce(createTransaction({ gatewayTransactionId: GATEWAY_TRANSACTION_ID }))
446
+ ;(createRequestContainer as jest.Mock).mockResolvedValue({
447
+ hasRegistration: (name: string) => name !== 'rateLimiterService',
448
+ resolve: (name: string) => {
449
+ if (name === 'rateLimiterService') throw new Error('rate limiter is not registered')
450
+ if (name === 'em') return { findOne: mockEmFindOne }
451
+ if (name === 'commandBus') return { execute: mockCommandExecute }
452
+ if (name === 'paymentGatewayService') {
453
+ return { createPaymentSession: mockCreatePaymentSession }
454
+ }
455
+ throw new Error(`Unknown dependency: ${name}`)
456
+ },
457
+ })
458
+
459
+ const response = await POST(
460
+ new Request('https://merchant.example/api/checkout/pay/donate/submit', {
461
+ method: 'POST',
462
+ headers: {
463
+ 'content-type': 'application/json',
464
+ 'Idempotency-Key': 'no-limiter-key-1234',
465
+ origin: 'https://merchant.example',
466
+ },
467
+ body: JSON.stringify({ customerData: {}, acceptedLegalConsents: {}, amount: 1 }),
468
+ }),
469
+ { params: { slug: 'donate' } },
470
+ )
471
+
472
+ expect(response.status).toBe(201)
473
+ expect(checkRateLimit).not.toHaveBeenCalled()
474
+ expect(mockCommandExecute).toHaveBeenCalled()
475
+ })
476
+ })
378
477
  })
@@ -5,8 +5,6 @@ import type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
5
5
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
6
  import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
- import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
9
- import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
10
8
  import type { PaymentGatewayClientSession } from '@open-mercato/shared/modules/payment_gateways/types'
11
9
  import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
12
10
  import { GatewayTransaction } from '@open-mercato/core/modules/payment_gateways/data/entities'
@@ -22,7 +20,8 @@ import {
22
20
  validateDescriptorCurrencies,
23
21
  } from '../../../../lib/utils'
24
22
  import { validateCheckoutCustomerData } from '../../../../lib/customerDataValidation'
25
- import { buildCheckoutRateLimitKey, checkoutSubmitRateLimitConfig } from '../../../../lib/rateLimiter'
23
+ import { checkoutSubmitRateLimitConfig, enforceCheckoutRateLimit } from '../../../../lib/rateLimiter'
24
+ import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
26
25
  import { checkoutTag } from '../../../openapi'
27
26
  import { createLogger } from '@open-mercato/shared/lib/logger'
28
27
 
@@ -274,14 +273,16 @@ export const metadata = {
274
273
  export async function POST(req: Request, { params }: { params: Promise<{ slug: string }> | { slug: string } }) {
275
274
  try {
276
275
  const container = await createRequestContainer()
277
- try {
278
- const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
279
- const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-submit')
280
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutSubmitRateLimitConfig, key, 'Too many payment attempts. Please try again later.')
281
- if (rateLimitResponse) return rateLimitResponse
282
- } catch {
283
- // Rate limiting is fail-open
284
- }
276
+ // Fail-closed: this endpoint creates payment sessions that can charge cards.
277
+ const rateLimitResponse = await enforceCheckoutRateLimit({
278
+ req,
279
+ container,
280
+ config: checkoutSubmitRateLimitConfig,
281
+ namespace: 'checkout-submit',
282
+ errorMessage: 'Too many payment attempts. Please try again later.',
283
+ posture: 'fail-closed',
284
+ })
285
+ if (rateLimitResponse) return rateLimitResponse
285
286
  const resolvedParams = await params
286
287
 
287
288
  const allowedOrigins = buildAllowedOrigins()
@@ -593,6 +594,14 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
593
594
 
594
595
  export const openApi = {
595
596
  tags: [checkoutTag],
597
+ methods: {
598
+ POST: {
599
+ errors: [
600
+ { status: 429, description: 'Too many payment attempts', schema: rateLimitErrorSchema },
601
+ { status: 503, description: 'Rate limiting could not be enforced, so the payment was rejected', schema: rateLimitErrorSchema },
602
+ ],
603
+ },
604
+ },
596
605
  }
597
606
 
598
607
  export default POST
@@ -0,0 +1,121 @@
1
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
4
+ import { verifyCheckoutPassword } from '../../../../../lib/utils'
5
+ import { POST } from '../route'
6
+
7
+ const LINK_ID = '11111111-1111-4111-8111-111111111111'
8
+
9
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
10
+ createRequestContainer: jest.fn(),
11
+ }))
12
+
13
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
14
+ findOneWithDecryption: jest.fn(),
15
+ }))
16
+
17
+ jest.mock('@open-mercato/shared/lib/ratelimit/helpers', () => ({
18
+ checkRateLimit: jest.fn(),
19
+ getClientIp: jest.fn(() => '127.0.0.1'),
20
+ RATE_LIMIT_FALLBACK_KEY: 'global',
21
+ RATE_LIMIT_UNAVAILABLE_FALLBACK: 'Service temporarily unavailable. Please try again later.',
22
+ }))
23
+
24
+ jest.mock('../../../../../lib/utils', () => ({
25
+ ...jest.requireActual('../../../../../lib/utils'),
26
+ verifyCheckoutPassword: jest.fn(),
27
+ signCheckoutAccessToken: jest.fn(() => 'signed-token'),
28
+ }))
29
+
30
+ function verifyRequest() {
31
+ return POST(
32
+ new Request('https://merchant.example/api/checkout/pay/donate/verify-password', {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json' },
35
+ body: JSON.stringify({ password: 'letmein' }),
36
+ }),
37
+ { params: { slug: 'donate' } },
38
+ )
39
+ }
40
+
41
+ describe('POST /api/checkout/pay/[slug]/verify-password', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks()
44
+ ;(checkRateLimit as jest.Mock).mockResolvedValue(null)
45
+ ;(verifyCheckoutPassword as jest.Mock).mockResolvedValue(true)
46
+ ;(createRequestContainer as jest.Mock).mockResolvedValue({
47
+ resolve: (name: string) => {
48
+ if (name === 'rateLimiterService') return { trustProxyDepth: 0 }
49
+ if (name === 'em') return {}
50
+ throw new Error(`Unknown dependency: ${name}`)
51
+ },
52
+ })
53
+ ;(findOneWithDecryption as jest.Mock).mockResolvedValue({
54
+ id: LINK_ID,
55
+ slug: 'donate',
56
+ status: 'active',
57
+ passwordHash: 'hashed',
58
+ })
59
+ })
60
+
61
+ it('accepts a correct password when the limiter allows the request', async () => {
62
+ const response = await verifyRequest()
63
+
64
+ expect(response.status).toBe(200)
65
+ expect(await response.json()).toEqual({ ok: true })
66
+ })
67
+
68
+ it('asks the limiter to fail closed', async () => {
69
+ await verifyRequest()
70
+
71
+ expect(checkRateLimit).toHaveBeenCalledWith(
72
+ expect.anything(),
73
+ expect.anything(),
74
+ expect.any(String),
75
+ expect.any(String),
76
+ expect.objectContaining({ failClosed: true }),
77
+ )
78
+ })
79
+
80
+ it('returns 503 without verifying the password when the limiter cannot decide', async () => {
81
+ ;(checkRateLimit as jest.Mock).mockRejectedValue(new Error('backing store unreachable'))
82
+
83
+ const response = await verifyRequest()
84
+
85
+ expect(response.status).toBe(503)
86
+ expect(await response.json()).toEqual({
87
+ error: 'Service temporarily unavailable. Please try again later.',
88
+ })
89
+ expect(verifyCheckoutPassword).not.toHaveBeenCalled()
90
+ })
91
+
92
+ // "Not registered" is a configuration error rather than a transient outage, so the
93
+ // request is served instead of being rejected as temporarily unavailable forever.
94
+ it('verifies the password when no rate limiter is registered', async () => {
95
+ ;(createRequestContainer as jest.Mock).mockResolvedValue({
96
+ hasRegistration: (name: string) => name !== 'rateLimiterService',
97
+ resolve: (name: string) => {
98
+ if (name === 'rateLimiterService') throw new Error('rate limiter is not registered')
99
+ if (name === 'em') return {}
100
+ throw new Error(`Unknown dependency: ${name}`)
101
+ },
102
+ })
103
+
104
+ const response = await verifyRequest()
105
+
106
+ expect(response.status).toBe(200)
107
+ expect(checkRateLimit).not.toHaveBeenCalled()
108
+ expect(verifyCheckoutPassword).toHaveBeenCalled()
109
+ })
110
+
111
+ it('passes a 503 from the limiter straight through', async () => {
112
+ ;(checkRateLimit as jest.Mock).mockResolvedValue(
113
+ new Response(JSON.stringify({ error: 'unavailable' }), { status: 503 }),
114
+ )
115
+
116
+ const response = await verifyRequest()
117
+
118
+ expect(response.status).toBe(503)
119
+ expect(verifyCheckoutPassword).not.toHaveBeenCalled()
120
+ })
121
+ })
@@ -1,8 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
- import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
- import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
6
4
  import { CheckoutLink } from '../../../../data/entities'
7
5
  import { CHECKOUT_PASSWORD_COOKIE } from '../../../../lib/constants'
8
6
  import { publicPasswordVerifySchema } from '../../../../data/validators'
@@ -12,7 +10,8 @@ import {
12
10
  signCheckoutAccessToken,
13
11
  verifyCheckoutPassword,
14
12
  } from '../../../../lib/utils'
15
- import { buildCheckoutRateLimitKey, checkoutPasswordRateLimitConfig } from '../../../../lib/rateLimiter'
13
+ import { checkoutPasswordRateLimitConfig, enforceCheckoutRateLimit } from '../../../../lib/rateLimiter'
14
+ import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
16
15
  import { checkoutTag } from '../../../openapi'
17
16
 
18
17
  export const metadata = {
@@ -23,14 +22,16 @@ export const metadata = {
23
22
  export async function POST(req: Request, { params }: { params: Promise<{ slug: string }> | { slug: string } }) {
24
23
  try {
25
24
  const container = await createRequestContainer()
26
- try {
27
- const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
28
- const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-password')
29
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPasswordRateLimitConfig, key, 'Too many password attempts. Please try again later.')
30
- if (rateLimitResponse) return rateLimitResponse
31
- } catch {
32
- // Rate limiting is fail-open
33
- }
25
+ // Fail-closed: an unenforced limit allows password brute force.
26
+ const rateLimitResponse = await enforceCheckoutRateLimit({
27
+ req,
28
+ container,
29
+ config: checkoutPasswordRateLimitConfig,
30
+ namespace: 'checkout-password',
31
+ errorMessage: 'Too many password attempts. Please try again later.',
32
+ posture: 'fail-closed',
33
+ })
34
+ if (rateLimitResponse) return rateLimitResponse
34
35
  const resolvedParams = await params
35
36
  const body = publicPasswordVerifySchema.parse(await req.json().catch(() => ({})))
36
37
  const em = container.resolve('em')
@@ -64,6 +65,14 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
64
65
 
65
66
  export const openApi = {
66
67
  tags: [checkoutTag],
68
+ methods: {
69
+ POST: {
70
+ errors: [
71
+ { status: 429, description: 'Too many password attempts', schema: rateLimitErrorSchema },
72
+ { status: 503, description: 'Rate limiting could not be enforced, so the attempt was rejected', schema: rateLimitErrorSchema },
73
+ ],
74
+ },
75
+ },
67
76
  }
68
77
 
69
78
  export default POST
@@ -0,0 +1,41 @@
1
+ import { buildOpenApiDocument } from '@open-mercato/shared/lib/openapi/generator'
2
+ import type { Module, ModuleApiRouteFile } from '@open-mercato/shared/modules/registry'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
4
+ import { openApi as submitOpenApi } from '../[slug]/submit/route'
5
+ import { openApi as verifyPasswordOpenApi } from '../[slug]/verify-password/route'
6
+
7
+ type ResponseEntry = { description?: string; content?: Record<string, unknown> }
8
+
9
+ function operationFor(openApi: unknown, path: string): { responses: Record<string, ResponseEntry> } {
10
+ const api: ModuleApiRouteFile = {
11
+ path,
12
+ handlers: { POST: async () => new Response(null) },
13
+ docs: openApi as OpenApiRouteDoc,
14
+ }
15
+ const module: Module = { id: 'checkout', apis: [api] }
16
+ return buildOpenApiDocument([module]).paths[path]?.post as { responses: Record<string, ResponseEntry> }
17
+ }
18
+
19
+ describe('public checkout pay routes document their rate-limit responses', () => {
20
+ it.each([
21
+ {
22
+ name: 'submit',
23
+ openApi: submitOpenApi,
24
+ path: '/api/checkout/pay/{slug}/submit',
25
+ rejected: 'Rate limiting could not be enforced, so the payment was rejected',
26
+ throttled: 'Too many payment attempts',
27
+ },
28
+ {
29
+ name: 'verify-password',
30
+ openApi: verifyPasswordOpenApi,
31
+ path: '/api/checkout/pay/{slug}/verify-password',
32
+ rejected: 'Rate limiting could not be enforced, so the attempt was rejected',
33
+ throttled: 'Too many password attempts',
34
+ },
35
+ ])('exposes 429 and 503 in the generated document for $name', ({ openApi, path, rejected, throttled }) => {
36
+ const operation = operationFor(openApi, path)
37
+
38
+ expect(operation.responses['429'].description).toBe(throttled)
39
+ expect(operation.responses['503'].description).toBe(rejected)
40
+ })
41
+ })
@@ -1,5 +1,10 @@
1
+ import type { RateLimitResult } from '@open-mercato/shared/lib/ratelimit/types'
1
2
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
2
- import { buildCheckoutRateLimitKey } from '../rateLimiter'
3
+ import {
4
+ buildCheckoutRateLimitKey,
5
+ checkoutSubmitRateLimitConfig,
6
+ enforceCheckoutRateLimit,
7
+ } from '../rateLimiter'
3
8
 
4
9
  function makeRateLimiter(trustProxyDepth: number): RateLimiterService {
5
10
  return { trustProxyDepth } as RateLimiterService
@@ -45,3 +50,114 @@ describe('buildCheckoutRateLimitKey', () => {
45
50
  .toBe('checkout-public-view:global')
46
51
  })
47
52
  })
53
+
54
+ describe('enforceCheckoutRateLimit', () => {
55
+ const allowedResult: RateLimitResult = {
56
+ allowed: true,
57
+ remainingPoints: 9,
58
+ msBeforeNext: 0,
59
+ consumedPoints: 1,
60
+ }
61
+ const degradedResult: RateLimitResult = { ...allowedResult, degraded: true }
62
+ const rejectedResult: RateLimitResult = {
63
+ allowed: false,
64
+ remainingPoints: 0,
65
+ msBeforeNext: 30_000,
66
+ consumedPoints: 11,
67
+ }
68
+
69
+ function makeContainer(consume: jest.Mock | null) {
70
+ return {
71
+ hasRegistration: (name: string) => name !== 'rateLimiterService' || consume !== null,
72
+ resolve: (name: string) => {
73
+ if (name === 'rateLimiterService' && consume) return { trustProxyDepth: 0, consume }
74
+ throw new Error(`Unknown dependency: ${name}`)
75
+ },
76
+ }
77
+ }
78
+
79
+ type TestContainer = {
80
+ resolve: (name: string) => unknown
81
+ hasRegistration?: (name: string) => boolean
82
+ }
83
+
84
+ function enforce(container: TestContainer, posture: 'fail-open' | 'fail-closed') {
85
+ return enforceCheckoutRateLimit({
86
+ req: new Request('https://merchant.example/api/checkout/pay/donate/submit', { method: 'POST' }),
87
+ container,
88
+ config: checkoutSubmitRateLimitConfig,
89
+ namespace: 'checkout-submit',
90
+ errorMessage: 'Too many payment attempts. Please try again later.',
91
+ posture,
92
+ })
93
+ }
94
+
95
+ it('rejects with 503 when the limiter reports a degraded decision and the endpoint fails closed', async () => {
96
+ const consume = jest.fn().mockResolvedValue(degradedResult)
97
+
98
+ const response = await enforce(makeContainer(consume), 'fail-closed')
99
+
100
+ expect(response?.status).toBe(503)
101
+ expect(await response?.json()).toEqual({
102
+ error: 'Service temporarily unavailable. Please try again later.',
103
+ })
104
+ })
105
+
106
+ it('rejects with 503 when the limiter throws and the endpoint fails closed', async () => {
107
+ const consume = jest.fn().mockRejectedValue(new Error('backing store unreachable'))
108
+
109
+ const response = await enforce(makeContainer(consume), 'fail-closed')
110
+
111
+ expect(response?.status).toBe(503)
112
+ })
113
+
114
+ // The read-only endpoints deliberately keep the permissive posture: a limiter that
115
+ // cannot decide must not take the public checkout page or its status polling down.
116
+ it('serves the request when the limiter is degraded and the endpoint fails open', async () => {
117
+ const consume = jest.fn().mockResolvedValue(degradedResult)
118
+
119
+ expect(await enforce(makeContainer(consume), 'fail-open')).toBeNull()
120
+ })
121
+
122
+ it('serves the request when the limiter throws and the endpoint fails open', async () => {
123
+ const consume = jest.fn().mockRejectedValue(new Error('backing store unreachable'))
124
+
125
+ expect(await enforce(makeContainer(consume), 'fail-open')).toBeNull()
126
+ })
127
+
128
+ // A missing DI registration means the rate-limit configuration is invalid, which lasts
129
+ // until an operator fixes it. Reporting that as "temporarily unavailable" would take
130
+ // checkout down indefinitely, so the check is skipped for both postures instead.
131
+ it('skips the check for both postures when no limiter is registered', async () => {
132
+ expect(await enforce(makeContainer(null), 'fail-closed')).toBeNull()
133
+ expect(await enforce(makeContainer(null), 'fail-open')).toBeNull()
134
+ })
135
+
136
+ it('skips the check when resolving the limiter throws', async () => {
137
+ const container = {
138
+ resolve: () => {
139
+ throw new Error('AwilixResolutionError: could not resolve rateLimiterService')
140
+ },
141
+ }
142
+
143
+ expect(await enforce(container, 'fail-closed')).toBeNull()
144
+ })
145
+
146
+ it('returns the limiter 429 when the quota is exhausted', async () => {
147
+ const consume = jest.fn().mockResolvedValue(rejectedResult)
148
+
149
+ const response = await enforce(makeContainer(consume), 'fail-closed')
150
+
151
+ expect(response?.status).toBe(429)
152
+ expect(await response?.json()).toEqual({
153
+ error: 'Too many payment attempts. Please try again later.',
154
+ })
155
+ })
156
+
157
+ it('allows the request when the limiter decides there is quota left', async () => {
158
+ const consume = jest.fn().mockResolvedValue(allowedResult)
159
+
160
+ expect(await enforce(makeContainer(consume), 'fail-closed')).toBeNull()
161
+ expect(consume).toHaveBeenCalledWith('checkout-submit:global', checkoutSubmitRateLimitConfig)
162
+ })
163
+ })