|
6 | 6 | import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing' |
7 | 7 | import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
8 | 8 |
|
| 9 | +const { mockCheckRateLimitDirect } = vi.hoisted(() => ({ |
| 10 | + mockCheckRateLimitDirect: vi.fn(), |
| 11 | +})) |
| 12 | + |
| 13 | +/** |
| 14 | + * Mocked at the storage boundary rather than at `route-helpers`, so the real |
| 15 | + * key derivation (normalize + hash) is exercised through the route. |
| 16 | + */ |
| 17 | +vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({ |
| 18 | + RateLimiter: class { |
| 19 | + checkRateLimitDirect = mockCheckRateLimitDirect |
| 20 | + }, |
| 21 | +})) |
| 22 | + |
| 23 | +const allowAll = () => ({ allowed: true, resetAt: new Date(Date.now() + 60_000) }) |
| 24 | + |
| 25 | +function exhaust(dimension: string, resetAt: Date) { |
| 26 | + mockCheckRateLimitDirect.mockImplementation(async (key: string) => |
| 27 | + key.includes(dimension) ? { allowed: false, resetAt } : allowAll() |
| 28 | + ) |
| 29 | +} |
| 30 | + |
| 31 | +function recipientKeys(): string[] { |
| 32 | + return mockCheckRateLimitDirect.mock.calls |
| 33 | + .map(([key]) => key as string) |
| 34 | + .filter((key) => key.includes(':recipient:')) |
| 35 | +} |
| 36 | + |
9 | 37 | const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => { |
10 | 38 | const logger = { |
11 | 39 | info: vi.fn(), |
@@ -42,6 +70,7 @@ describe('Forget Password API Route', () => { |
42 | 70 | vi.clearAllMocks() |
43 | 71 | setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.example.com' }) |
44 | 72 | mockRequestPasswordReset.mockResolvedValue(undefined) |
| 73 | + mockCheckRateLimitDirect.mockImplementation(async () => allowAll()) |
45 | 74 | }) |
46 | 75 |
|
47 | 76 | afterAll(() => { |
@@ -73,6 +102,42 @@ describe('Forget Password API Route', () => { |
73 | 102 | }) |
74 | 103 | }) |
75 | 104 |
|
| 105 | + it('rejects with 429 once the recipient budget is spent, without sending mail', async () => { |
| 106 | + const resetAt = new Date(Date.now() + 900_000) |
| 107 | + exhaust(':recipient:', resetAt) |
| 108 | + |
| 109 | + const response = await POST(createMockRequest('POST', { email: 'test@example.com' })) |
| 110 | + |
| 111 | + expect(response.status).toBe(429) |
| 112 | + expect(response.headers.get('Retry-After')).toBe('900') |
| 113 | + expect(mockRequestPasswordReset).not.toHaveBeenCalled() |
| 114 | + }) |
| 115 | + |
| 116 | + it('buckets addresses that normalize to the same recipient together', async () => { |
| 117 | + await POST(createMockRequest('POST', { email: 'Test@Example.com' })) |
| 118 | + await POST(createMockRequest('POST', { email: 'test@example.com' })) |
| 119 | + |
| 120 | + const [first, second] = recipientKeys() |
| 121 | + expect(first).toBe(second) |
| 122 | + }) |
| 123 | + |
| 124 | + it('keys the recipient bucket by hash, never the raw address', async () => { |
| 125 | + await POST(createMockRequest('POST', { email: 'test@example.com' })) |
| 126 | + |
| 127 | + const [key] = recipientKeys() |
| 128 | + expect(key).toMatch(/^route:forget-password:recipient:[0-9a-f]{64}$/) |
| 129 | + }) |
| 130 | + |
| 131 | + it('short-circuits on the per-IP budget before spending the recipient budget', async () => { |
| 132 | + exhaust(':ip:', new Date(Date.now() + 60_000)) |
| 133 | + |
| 134 | + const response = await POST(createMockRequest('POST', { email: 'test@example.com' })) |
| 135 | + |
| 136 | + expect(response.status).toBe(429) |
| 137 | + expect(recipientKeys()).toHaveLength(0) |
| 138 | + expect(mockRequestPasswordReset).not.toHaveBeenCalled() |
| 139 | + }) |
| 140 | + |
76 | 141 | it('should reject external redirectTo URL', async () => { |
77 | 142 | const req = createMockRequest('POST', { |
78 | 143 | email: 'test@example.com', |
|
0 commit comments