import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { accessCodeRateLimiter } from '@/lib/server/access-code-rate-limit'; import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy'; const cookieSet = vi.fn(); vi.mock('next/headers', () => ({ cookies: vi.fn(async () => ({ set: cookieSet })), })); function loginRequest(code: string) { return new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://ops.example' }, body: JSON.stringify({ code }), }); } function chunkedLoginRequest(chunks: string[]) { const encoder = new TextEncoder(); const body = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk)); } controller.close(); }, }); return new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://ops.example', 'transfer-encoding': 'chunked', }, body, duplex: 'half', } as RequestInit & { duplex: 'half' }); } describe('POST /api/access-code/verify', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-15T00:00:00Z')); accessCodeRateLimiter.clear(); cookieSet.mockClear(); vi.stubEnv('ACCESS_CODE', 'correct-code'); vi.stubEnv('ACCESS_CODE_SESSION_TTL_SECONDS', '3600'); vi.stubEnv('ACCESS_CODE_RATE_LIMIT_MAX_ATTEMPTS', '2'); vi.stubEnv('ACCESS_CODE_RATE_LIMIT_WINDOW_SECONDS', '900'); vi.stubEnv('ACCESS_CODE_TRUST_PROXY_HEADERS', 'false'); }); afterEach(() => { accessCodeRateLimiter.clear(); vi.unstubAllEnvs(); vi.useRealTimers(); }); test('issues a session whose cookie lifetime matches the token TTL', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); const response = await POST(loginRequest('correct-code')); expect(response.status).toBe(200); expect(cookieSet).toHaveBeenCalledWith( ACCESS_CODE_COOKIE_NAME, expect.any(String), expect.objectContaining({ httpOnly: true, sameSite: 'lax', maxAge: 3600 }), ); }); test('rate-limits repeated failed login attempts', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); expect((await POST(loginRequest('wrong-1'))).status).toBe(401); expect((await POST(loginRequest('wrong-2'))).status).toBe(401); const blocked = await POST(loginRequest('wrong-3')); expect(blocked.status).toBe(429); expect(blocked.headers.get('retry-after')).toBe('900'); await expect(blocked.json()).resolves.toMatchObject({ errorCode: 'RATE_LIMITED' }); }); test('cross-origin simple requests cannot consume the shared login bucket', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await POST( new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'text/plain', origin: 'https://attacker.example' }, body: JSON.stringify({ code: 'wrong' }), }), ); expect(response.status).toBe(403); } expect((await POST(loginRequest('correct-code'))).status).toBe(200); }); test('wrong content types cannot consume the shared login bucket', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await POST( new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'text/plain', origin: 'https://ops.example' }, body: JSON.stringify({ code: 'wrong' }), }), ); expect(response.status).toBe(415); } expect((await POST(loginRequest('correct-code'))).status).toBe(200); }); test('rejects an oversized declared content length without consuming the login bucket', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await POST( new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': '8193', origin: 'https://ops.example', }, body: '{}', }), ); expect(response.status).toBe(413); await expect(response.json()).resolves.toMatchObject({ errorCode: 'INVALID_REQUEST', }); } expect((await POST(loginRequest('correct-code'))).status).toBe(200); }); test('caps the actual bytes of a chunked request before parsing or rate limiting', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await POST(chunkedLoginRequest(['{"code":"', 'x'.repeat(8 * 1024), '"}'])); expect(response.status).toBe(413); await expect(response.json()).resolves.toMatchObject({ errorCode: 'INVALID_REQUEST', }); } expect((await POST(loginRequest('correct-code'))).status).toBe(200); }); test('malformed JSON is rejected before consuming a login attempt', async () => { const { POST } = await import('@/app/api/access-code/verify/route'); const malformed = () => new Request('https://ops.example/api/access-code/verify', { method: 'POST', headers: { 'content-type': 'application/json', origin: 'https://ops.example' }, body: '{', }); expect((await POST(malformed())).status).toBe(400); expect((await POST(malformed())).status).toBe(400); expect((await POST(loginRequest('wrong-1'))).status).toBe(401); expect((await POST(loginRequest('wrong-2'))).status).toBe(401); expect((await POST(loginRequest('wrong-3'))).status).toBe(429); }); });