cms.c2sgmbh/tests/helpers/security-test-utils.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

375 lines
10 KiB
TypeScript

/**
* Security Test Utilities
*
* Helper functions for testing security features.
* Provides mock request builders, CSRF token utilities, and environment setup.
*/
import { NextRequest, NextResponse } from 'next/server'
import { createHmac, randomBytes } from 'crypto'
// ============================================================================
// CSRF Token Utilities
// ============================================================================
const DEFAULT_CSRF_SECRET = 'test-csrf-secret-for-testing-only'
/**
* Generate a valid CSRF token for testing
*/
export function generateTestCsrfToken(secret: string = DEFAULT_CSRF_SECRET): string {
const timestamp = Date.now().toString(36)
const random = randomBytes(16).toString('hex')
const data = `${timestamp}:${random}`
const hmac = createHmac('sha256', secret)
hmac.update(data)
const signature = hmac.digest('hex').substring(0, 16)
return `${data}:${signature}`
}
/**
* Generate an expired CSRF token for testing
*/
export function generateExpiredCsrfToken(secret: string = DEFAULT_CSRF_SECRET): string {
// Use timestamp from 2 hours ago
const timestamp = (Date.now() - 2 * 60 * 60 * 1000).toString(36)
const random = randomBytes(16).toString('hex')
const data = `${timestamp}:${random}`
const hmac = createHmac('sha256', secret)
hmac.update(data)
const signature = hmac.digest('hex').substring(0, 16)
return `${data}:${signature}`
}
/**
* Generate an invalid CSRF token (wrong signature)
*/
export function generateInvalidCsrfToken(): string {
const timestamp = Date.now().toString(36)
const random = randomBytes(16).toString('hex')
return `${timestamp}:${random}:invalidsignature`
}
// ============================================================================
// Mock Request Builders
// ============================================================================
export interface MockRequestOptions {
method?: string
url?: string
headers?: Record<string, string>
body?: unknown
cookies?: Record<string, string>
ip?: string
}
/**
* Create a mock NextRequest for testing
*/
export function createMockRequest(options: MockRequestOptions = {}): NextRequest {
const {
method = 'GET',
url = 'https://test.example.com/api/test',
headers = {},
ip,
} = options
const requestHeaders = new Headers()
// Set IP headers if provided
if (ip) {
requestHeaders.set('x-forwarded-for', ip)
requestHeaders.set('x-real-ip', ip)
}
// Set custom headers
Object.entries(headers).forEach(([key, value]) => {
requestHeaders.set(key, value)
})
const request = new NextRequest(url, {
method,
headers: requestHeaders,
})
// Mock cookies
if (options.cookies) {
Object.defineProperty(request, 'cookies', {
value: {
get: (name: string) => {
const value = options.cookies?.[name]
return value ? { value } : undefined
},
getAll: () =>
Object.entries(options.cookies || {}).map(([name, value]) => ({ name, value })),
},
})
}
return request
}
/**
* Create a mock request with CSRF protection
*/
export function createCsrfProtectedRequest(
method: string,
options: Omit<MockRequestOptions, 'method'> = {},
): NextRequest {
const token = generateTestCsrfToken()
return createMockRequest({
...options,
method,
headers: {
...options.headers,
'X-CSRF-Token': token,
origin: 'https://test.example.com',
},
cookies: {
...options.cookies,
'csrf-token': token,
},
})
}
/**
* Create a mock request simulating server-to-server call
*/
export function createServerToServerRequest(
method: string,
options: Omit<MockRequestOptions, 'method'> = {},
): NextRequest {
return createMockRequest({
...options,
method,
headers: {
...options.headers,
authorization: 'Bearer test-api-token',
'content-type': 'application/json',
// No origin header for server-to-server
},
})
}
/**
* Create a mock request from specific IP
*/
export function createRequestFromIp(ip: string, options: MockRequestOptions = {}): NextRequest {
return createMockRequest({
...options,
ip,
})
}
// ============================================================================
// Environment Setup
// ============================================================================
export interface SecurityTestEnv {
CSRF_SECRET?: string
PAYLOAD_SECRET?: string
BLOCKED_IPS?: string
SEND_EMAIL_ALLOWED_IPS?: string
ADMIN_ALLOWED_IPS?: string
WEBHOOK_ALLOWED_IPS?: string
REDIS_HOST?: string
REDIS_PORT?: string
}
/**
* Setup environment variables for security tests
*/
export function setupSecurityEnv(env: SecurityTestEnv): void {
Object.entries(env).forEach(([key, value]) => {
if (value !== undefined) {
process.env[key] = value
}
})
}
/**
* Clear security-related environment variables
*/
export function clearSecurityEnv(): void {
const securityEnvVars = [
'CSRF_SECRET',
'BLOCKED_IPS',
'SEND_EMAIL_ALLOWED_IPS',
'ADMIN_ALLOWED_IPS',
'WEBHOOK_ALLOWED_IPS',
]
securityEnvVars.forEach((key) => {
delete process.env[key]
})
}
// ============================================================================
// Response Helpers
// ============================================================================
/**
* Extract rate limit info from response headers
*/
export function extractRateLimitInfo(response: NextResponse): {
limit: number | null
remaining: number | null
reset: number | null
retryAfter: number | null
} {
return {
limit: response.headers.get('X-RateLimit-Limit')
? parseInt(response.headers.get('X-RateLimit-Limit')!, 10)
: null,
remaining: response.headers.get('X-RateLimit-Remaining')
? parseInt(response.headers.get('X-RateLimit-Remaining')!, 10)
: null,
reset: response.headers.get('X-RateLimit-Reset')
? parseInt(response.headers.get('X-RateLimit-Reset')!, 10)
: null,
retryAfter: response.headers.get('Retry-After')
? parseInt(response.headers.get('Retry-After')!, 10)
: null,
}
}
/**
* Parse JSON response body
*/
export async function parseJsonResponse<T>(response: NextResponse): Promise<T> {
const text = await response.text()
return JSON.parse(text) as T
}
// ============================================================================
// Rate Limit Testing
// ============================================================================
/**
* Make multiple requests to test rate limiting
*/
export async function exhaustRateLimit(
requestFn: () => Promise<NextResponse>,
maxRequests: number,
): Promise<NextResponse[]> {
const responses: NextResponse[] = []
for (let i = 0; i <= maxRequests; i++) {
responses.push(await requestFn())
}
return responses
}
// ============================================================================
// Assertion Helpers
// ============================================================================
/**
* Assert response has security headers
*/
export function assertSecurityHeaders(response: NextResponse): void {
const headers = response.headers
// Rate limit headers should be present
const hasRateLimitHeaders =
headers.has('X-RateLimit-Limit') || headers.has('X-RateLimit-Remaining')
if (!hasRateLimitHeaders) {
throw new Error('Response missing rate limit headers')
}
}
/**
* Assert response is rate limited (429)
*/
export function assertRateLimited(response: NextResponse): void {
if (response.status !== 429) {
throw new Error(`Expected 429 status, got ${response.status}`)
}
if (!response.headers.has('Retry-After')) {
throw new Error('Rate limited response missing Retry-After header')
}
}
/**
* Assert response is CSRF blocked (403)
*/
export function assertCsrfBlocked(response: NextResponse): void {
if (response.status !== 403) {
throw new Error(`Expected 403 status for CSRF block, got ${response.status}`)
}
}
/**
* Assert response is IP blocked (403)
*/
export function assertIpBlocked(response: NextResponse): void {
if (response.status !== 403) {
throw new Error(`Expected 403 status for IP block, got ${response.status}`)
}
}
// ============================================================================
// Test Data Generators
// ============================================================================
/**
* Generate a random IP address
*/
export function randomIp(): string {
return `${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`
}
/**
* Generate a unique test identifier
*/
export function uniqueTestId(prefix: string = 'test'): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
/**
* Generate test email
*/
export function testEmail(domain: string = 'test.example.com'): string {
return `${uniqueTestId('user')}@${domain}`
}
// ============================================================================
// Mock Payload Instance
// ============================================================================
export interface MockPayloadInstance {
auth: ReturnType<typeof import('vitest').vi.fn>
find: ReturnType<typeof import('vitest').vi.fn>
create: ReturnType<typeof import('vitest').vi.fn>
update: ReturnType<typeof import('vitest').vi.fn>
delete: ReturnType<typeof import('vitest').vi.fn>
login: ReturnType<typeof import('vitest').vi.fn>
}
/**
* Create a mock Payload instance for testing
*/
export function createMockPayload(
vi: typeof import('vitest').vi,
): MockPayloadInstance {
return {
auth: vi.fn().mockResolvedValue({ user: null }),
find: vi.fn().mockResolvedValue({ docs: [], totalDocs: 0 }),
create: vi.fn().mockResolvedValue({ id: 1 }),
update: vi.fn().mockResolvedValue({ id: 1 }),
delete: vi.fn().mockResolvedValue({ id: 1 }),
login: vi.fn().mockResolvedValue({
user: { id: 1, email: 'test@example.com' },
token: 'mock-token',
exp: Math.floor(Date.now() / 1000) + 7200,
}),
}
}