@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/dist/modules/checkout/api/pay/[slug]/route.js +10 -9
- package/dist/modules/checkout/api/pay/[slug]/route.js.map +2 -2
- package/dist/modules/checkout/api/pay/[slug]/status/[transactionId]/route.js +10 -9
- package/dist/modules/checkout/api/pay/[slug]/status/[transactionId]/route.js.map +2 -2
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js +20 -10
- package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
- package/dist/modules/checkout/api/pay/[slug]/verify-password/route.js +20 -10
- package/dist/modules/checkout/api/pay/[slug]/verify-password/route.js.map +2 -2
- package/dist/modules/checkout/lib/rateLimiter.js +45 -2
- package/dist/modules/checkout/lib/rateLimiter.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/checkout/api/pay/[slug]/route.ts +11 -11
- package/src/modules/checkout/api/pay/[slug]/status/[transactionId]/route.ts +11 -11
- package/src/modules/checkout/api/pay/[slug]/submit/__tests__/route.test.ts +99 -0
- package/src/modules/checkout/api/pay/[slug]/submit/route.ts +20 -11
- package/src/modules/checkout/api/pay/[slug]/verify-password/__tests__/route.test.ts +121 -0
- package/src/modules/checkout/api/pay/[slug]/verify-password/route.ts +20 -11
- package/src/modules/checkout/api/pay/__tests__/openapi-rate-limit-responses.test.ts +41 -0
- package/src/modules/checkout/lib/__tests__/rateLimiter.test.ts +117 -1
- package/src/modules/checkout/lib/rateLimiter.ts +85 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/checkout",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
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.
|
|
66
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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
|
+
})
|