Files
openmaic/OpenMAIC/tests/server/access-code-route.test.ts
2026-08-16 14:58:47 +08:00

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);
});
});