mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +00:00
- CSRF: Require CSRF_SECRET in production, throw error on missing secret - IP Allowlist: TRUST_PROXY must be explicitly set to 'true' for proxy headers - Rate Limiter: Add proper proxy trust handling for client IP detection - Login: Add browser form redirect support with safe URL validation - Add custom admin login page with styled form - Update CLAUDE.md with TRUST_PROXY documentation - Update tests for new security behavior BREAKING: Server will not start in production without CSRF_SECRET or PAYLOAD_SECRET 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1200 lines
35 KiB
TypeScript
1200 lines
35 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'
|
|
|
|
// Enable CSRF validation in CI by setting BYPASS_CSRF=false
|
|
// This must be set before any module imports that read this variable
|
|
process.env.BYPASS_CSRF = 'false'
|
|
|
|
// Enable TRUST_PROXY to allow IP-based tests to work with x-forwarded-for headers
|
|
// In real deployment behind Caddy, this would be set in the environment
|
|
process.env.TRUST_PROXY = 'true'
|
|
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)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Test Email Endpoint Security Tests
|
|
// ============================================================================
|
|
|
|
describe('Test Email Endpoint (/api/test-email)', () => {
|
|
describe('CSRF Protection', () => {
|
|
it('requires CSRF token for browser requests', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('CSRF')
|
|
})
|
|
|
|
it('accepts valid CSRF token', async () => {
|
|
vi.resetModules()
|
|
|
|
// Mock authenticated user
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: {
|
|
id: 1,
|
|
email: 'admin@test.com',
|
|
isSuperAdmin: true,
|
|
tenants: [],
|
|
},
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
|
|
create: vi.fn().mockResolvedValue({ id: 1 }),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should pass CSRF check (may fail on email send, but not 403)
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
|
|
it('rejects expired CSRF tokens', async () => {
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
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({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('IP Blocking', () => {
|
|
it('blocks requests from blocked IPs', async () => {
|
|
vi.stubEnv('BLOCKED_IPS', '192.168.200.100')
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: '192.168.200.100',
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('Access denied')
|
|
})
|
|
|
|
it('respects SEND_EMAIL_ALLOWED_IPS allowlist', async () => {
|
|
vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.1')
|
|
vi.resetModules()
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
// Request from IP not in allowlist
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: '192.168.1.100', // Not in allowlist
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('Access denied')
|
|
})
|
|
})
|
|
|
|
describe('Authentication', () => {
|
|
it('requires authentication', async () => {
|
|
vi.resetModules()
|
|
|
|
// Mock unauthenticated user
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({ user: null }),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(401)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('authentifiziert')
|
|
})
|
|
})
|
|
|
|
describe('Tenant Access Control', () => {
|
|
it('allows super admin access to any tenant', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: {
|
|
id: 1,
|
|
email: 'superadmin@test.com',
|
|
isSuperAdmin: true,
|
|
tenants: [],
|
|
},
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Any Tenant' }),
|
|
}),
|
|
}))
|
|
|
|
// Mock sendTestEmail
|
|
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
|
sendTestEmail: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
messageId: 'test-123',
|
|
logId: 1,
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 99,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Super admin should have access
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
|
|
it('denies access to tenants user is not assigned to', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: {
|
|
id: 2,
|
|
email: 'user@test.com',
|
|
isSuperAdmin: false,
|
|
tenants: [{ tenant: { id: 1 } }], // Only has access to tenant 1
|
|
},
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Other Tenant' }),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 99, // Different tenant
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(403)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('Zugriff')
|
|
})
|
|
|
|
it('normalizes tenant object format { tenant: { id } }', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: {
|
|
id: 2,
|
|
email: 'user@test.com',
|
|
isSuperAdmin: false,
|
|
// Multi-tenant plugin format: { tenant: { id: number } }
|
|
tenants: [{ tenant: { id: 5 } }],
|
|
},
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 5, name: 'User Tenant' }),
|
|
}),
|
|
}))
|
|
|
|
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
|
sendTestEmail: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
messageId: 'test-456',
|
|
logId: 2,
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 5,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should have access (tenant ID 5 matches)
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
|
|
it('normalizes tenant primitive format { tenant: number }', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: {
|
|
id: 2,
|
|
email: 'user@test.com',
|
|
isSuperAdmin: false,
|
|
// Alternative format: { tenant: number }
|
|
tenants: [{ tenant: 7 }],
|
|
},
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 7, name: 'User Tenant' }),
|
|
}),
|
|
}))
|
|
|
|
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
|
sendTestEmail: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
messageId: 'test-789',
|
|
logId: 3,
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 7,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should have access (tenant ID 7 matches)
|
|
expect(response.status).not.toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('Input Validation', () => {
|
|
it('requires tenantId', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: { id: 1, isSuperAdmin: true },
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
recipientEmail: 'test@example.com',
|
|
// Missing tenantId
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(400)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('tenantId')
|
|
})
|
|
|
|
it('requires recipientEmail', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: { id: 1, isSuperAdmin: true },
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
// Missing recipientEmail
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(400)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('recipientEmail')
|
|
})
|
|
|
|
it('validates email format', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: { id: 1, isSuperAdmin: true },
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'invalid-email', // Invalid format
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(400)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('E-Mail')
|
|
})
|
|
|
|
it('validates tenantId is numeric', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: { id: 1, isSuperAdmin: true },
|
|
}),
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 'not-a-number',
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
expect(response.status).toBe(400)
|
|
const body = await response.json()
|
|
expect(body.error).toContain('Zahl')
|
|
})
|
|
})
|
|
|
|
describe('Rate Limiting', () => {
|
|
it('includes rate limit headers', async () => {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('payload', () => ({
|
|
getPayload: vi.fn().mockResolvedValue({
|
|
auth: vi.fn().mockResolvedValue({
|
|
user: { id: 1, isSuperAdmin: true },
|
|
}),
|
|
findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }),
|
|
}),
|
|
}))
|
|
|
|
vi.doMock('@/lib/email/tenant-email-service', () => ({
|
|
sendTestEmail: vi.fn().mockResolvedValue({
|
|
success: true,
|
|
messageId: 'test-rate',
|
|
logId: 1,
|
|
}),
|
|
}))
|
|
|
|
const { POST } = await import('@/app/(payload)/api/test-email/route')
|
|
|
|
const token = generateTestCsrfToken('test-csrf-secret-for-testing')
|
|
const req = createMockRequest({
|
|
method: 'POST',
|
|
url: 'https://test.example.com/api/test-email',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: 'https://test.example.com',
|
|
'X-CSRF-Token': token,
|
|
},
|
|
cookies: {
|
|
'csrf-token': token,
|
|
},
|
|
ip: randomIp(),
|
|
})
|
|
|
|
Object.defineProperty(req, 'json', {
|
|
value: vi.fn().mockResolvedValue({
|
|
tenantId: 1,
|
|
recipientEmail: 'test@example.com',
|
|
}),
|
|
})
|
|
|
|
const response = await POST(req)
|
|
|
|
// Should include rate limit headers on success
|
|
if (response.status === 200) {
|
|
expect(response.headers.has('X-RateLimit-Limit')).toBe(true)
|
|
expect(response.headers.has('X-RateLimit-Remaining')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|