169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
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<Uint8Array>({
|
|
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);
|
|
});
|
|
});
|