/** * Security API Integration Tests * * Tests for security enforcement on API endpoints. * Covers rate limiting, CSRF protection, and IP blocking. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { NextRequest, NextResponse } from 'next/server' import { generateTestCsrfToken, generateExpiredCsrfToken, generateInvalidCsrfToken, createMockRequest, randomIp, uniqueTestId, } from '../helpers/security-test-utils' // ============================================================================ // Mock Setup // ============================================================================ // Mock Redis (not available in test environment) vi.mock('ioredis', () => ({ default: vi.fn().mockImplementation(() => ({ incr: vi.fn().mockResolvedValue(1), pexpire: vi.fn().mockResolvedValue(1), pttl: vi.fn().mockResolvedValue(60000), keys: vi.fn().mockResolvedValue([]), del: vi.fn().mockResolvedValue(0), quit: vi.fn(), on: vi.fn(), })), })) vi.mock('@/lib/redis', () => ({ getRedisClient: vi.fn(() => null), isRedisAvailable: vi.fn(() => false), })) // Mock Payload for endpoints that need it vi.mock('payload', () => ({ getPayload: vi.fn().mockResolvedValue({ auth: vi.fn().mockResolvedValue({ user: null }), find: vi.fn().mockResolvedValue({ docs: [], totalDocs: 0, page: 1, totalPages: 0 }), create: vi.fn().mockResolvedValue({ id: 1 }), login: vi.fn().mockRejectedValue(new Error('Invalid credentials')), }), })) // Mock config vi.mock('@payload-config', () => ({ default: Promise.resolve({}), })) describe('Security API Integration', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() // Set up environment vi.stubEnv('CSRF_SECRET', 'test-csrf-secret-for-testing') vi.stubEnv('PAYLOAD_PUBLIC_SERVER_URL', 'https://test.example.com') vi.stubEnv('NEXT_PUBLIC_SERVER_URL', 'https://test.example.com') }) afterEach(() => { vi.useRealTimers() vi.unstubAllEnvs() }) describe('Rate Limiting', () => { describe('searchLimiter on /api/posts', () => { it('includes rate limit headers on success', async () => { // Reset modules to get fresh rate limiter state vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const testIp = uniqueTestId('ip') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: testIp, }) const response = await GET(req) expect(response.headers.get('X-RateLimit-Limit')).toBe('30') expect(response.headers.get('X-RateLimit-Remaining')).toBeDefined() expect(response.headers.get('X-RateLimit-Reset')).toBeDefined() }) it('returns 429 when rate limit exceeded', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const testIp = uniqueTestId('blocked-ip') const req = () => createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: testIp, }) // Exhaust rate limit (30 requests) for (let i = 0; i < 30; i++) { await GET(req()) } // 31st request should be blocked const response = await GET(req()) expect(response.status).toBe(429) expect(response.headers.get('Retry-After')).toBeDefined() }) }) describe('searchLimiter on /api/search', () => { it('includes rate limit headers', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/search/route') const testIp = uniqueTestId('search-ip') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/search?q=test', ip: testIp, }) const response = await GET(req) expect(response.headers.has('X-RateLimit-Remaining')).toBe(true) }) }) }) describe('IP Blocking', () => { it('blocks requests from blocked IPs on /api/posts', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.100.50') vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: '192.168.100.50', }) const response = await GET(req) expect(response.status).toBe(403) const body = await response.json() expect(body.error).toContain('Access denied') }) it('blocks requests from blocked CIDR range', async () => { vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8') vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: '10.50.100.200', }) const response = await GET(req) expect(response.status).toBe(403) }) it('allows requests from non-blocked IPs', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.100.50') vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: '8.8.8.8', }) const response = await GET(req) expect(response.status).not.toBe(403) }) }) describe('CSRF Protection', () => { describe('Login Endpoints', () => { it('validates CSRF on /api/auth/login', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') // Request without CSRF token const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', }, ip: randomIp(), }) // Mock json() method Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) expect(response.status).toBe(403) const body = await response.json() expect(body.error || body.errors?.[0]?.message).toContain('CSRF') }) it('allows login with valid CSRF token', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') const token = generateTestCsrfToken('test-csrf-secret-for-testing') const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', 'X-CSRF-Token': token, }, cookies: { 'csrf-token': token, }, ip: randomIp(), }) // Mock json() method Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) // Should proceed past CSRF check (may fail auth, but that's expected) expect(response.status).not.toBe(403) }) it('allows server-to-server login requests without CSRF', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') // Server-to-server: Authorization header, JSON content-type, no Origin const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', authorization: 'Bearer api-token', // No origin header }, ip: randomIp(), }) // Mock json() method Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) // Should not be CSRF blocked expect(response.status).not.toBe(403) }) }) describe('Send Email Endpoint', () => { it('requires CSRF for browser requests', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/send-email/route') const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/send-email', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', }, ip: randomIp(), }) // Mock json() method Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ tenantId: 1, to: 'recipient@example.com', subject: 'Test', html: '
Test
', }), }) const response = await POST(req) expect(response.status).toBe(403) }) }) describe('Token Validation', () => { it('rejects expired CSRF tokens', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing') const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', 'X-CSRF-Token': expiredToken, }, cookies: { 'csrf-token': expiredToken, }, ip: randomIp(), }) Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) expect(response.status).toBe(403) }) it('rejects invalid CSRF tokens', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') const invalidToken = generateInvalidCsrfToken() const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', 'X-CSRF-Token': invalidToken, }, cookies: { 'csrf-token': invalidToken, }, ip: randomIp(), }) Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) expect(response.status).toBe(403) }) it('rejects mismatched header and cookie tokens', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') const token1 = generateTestCsrfToken('test-csrf-secret-for-testing') const token2 = generateTestCsrfToken('test-csrf-secret-for-testing') const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://test.example.com', 'X-CSRF-Token': token1, }, cookies: { 'csrf-token': token2, // Different token }, ip: randomIp(), }) Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) expect(response.status).toBe(403) }) }) }) describe('CSRF Token Endpoint', () => { it('returns new CSRF token', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/csrf-token/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/csrf-token', ip: randomIp(), }) const response = await GET(req) expect(response.status).toBe(200) const body = await response.json() expect(body.success).toBe(true) expect(body.token).toBeDefined() expect(body.header).toBe('X-CSRF-Token') expect(body.cookie).toBe('csrf-token') }) it('sets CSRF cookie in response', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/csrf-token/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/csrf-token', ip: randomIp(), }) const response = await GET(req) // Check for Set-Cookie header const setCookie = response.headers.get('set-cookie') expect(setCookie).toContain('csrf-token') }) it('includes rate limit headers', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/csrf-token/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/csrf-token', ip: randomIp(), }) const response = await GET(req) expect(response.headers.has('X-RateLimit-Remaining')).toBe(true) }) }) describe('Safe Methods', () => { it('allows GET requests without CSRF token', async () => { vi.resetModules() const { GET } = await import('@/app/(frontend)/api/posts/route') const req = createMockRequest({ method: 'GET', url: 'https://test.example.com/api/posts', ip: randomIp(), }) const response = await GET(req) // GET should work without CSRF expect(response.status).not.toBe(403) }) }) describe('Origin Validation', () => { it('rejects requests from unauthorized origins', async () => { vi.resetModules() const { POST } = await import('@/app/(payload)/api/auth/login/route') const token = generateTestCsrfToken('test-csrf-secret-for-testing') const req = createMockRequest({ method: 'POST', url: 'https://test.example.com/api/auth/login', headers: { 'content-type': 'application/json', origin: 'https://malicious-site.com', 'X-CSRF-Token': token, }, cookies: { 'csrf-token': token, }, ip: randomIp(), }) Object.defineProperty(req, 'json', { value: vi.fn().mockResolvedValue({ email: 'test@example.com', password: 'password123', }), }) const response = await POST(req) expect(response.status).toBe(403) }) }) })