cms.c2sgmbh/tests/int/security-api.int.spec.ts
Martin Porwoll 0cdc25c4f0 feat: comprehensive security test suite
Add 143 security tests covering all security modules:

Unit Tests (125 tests):
- rate-limiter.unit.spec.ts: limiter creation, request tracking,
  blocking, window reset, IP extraction, header generation
- csrf.unit.spec.ts: token generation/validation, origin checking,
  double submit cookie pattern, referer validation
- ip-allowlist.unit.spec.ts: CIDR matching, wildcards, endpoint-
  specific allowlist/blocklist rules, IP extraction
- data-masking.unit.spec.ts: field detection, pattern matching,
  recursive masking, JWT/connection string/private key handling

API Integration Tests (18 tests):
- security-api.int.spec.ts: rate limiting responses, IP blocking,
  CSRF protection on state-changing endpoints

Test Infrastructure:
- tests/helpers/security-test-utils.ts: CSRF token generators,
  mock request builders, environment setup utilities
- vitest.config.mts: updated to include unit tests
- package.json: added test:unit and test:security scripts
- .github/workflows/security.yml: added security-tests CI job

Also updated detect-secrets.sh to ignore .spec.ts and .test.ts
files which may contain example secrets for testing purposes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 00:20:47 +00:00

518 lines
15 KiB
TypeScript

/**
* 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: '<p>Test</p>',
}),
})
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)
})
})
})