/** * IP Allowlist/Blocklist Unit Tests * * Tests for the IP access control module. * Covers CIDR matching, wildcards, allowlist/blocklist logic, and IP extraction. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { NextRequest } from 'next/server' describe('IP Allowlist', () => { beforeEach(() => { // Reset environment variables vi.unstubAllEnvs() }) afterEach(() => { vi.unstubAllEnvs() }) describe('getClientIpFromRequest', () => { // Import dynamically to reset mocks async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } describe('with TRUST_PROXY=true', () => { beforeEach(() => { vi.stubEnv('TRUST_PROXY', 'true') }) it('extracts IP from x-forwarded-for header', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-forwarded-for': '203.0.113.50, 70.41.3.18', }, }) const ip = getClientIpFromRequest(req) expect(ip).toBe('203.0.113.50') }) it('extracts IP from x-real-ip header', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-real-ip': '192.168.1.100', }, }) const ip = getClientIpFromRequest(req) expect(ip).toBe('192.168.1.100') }) it('prefers x-forwarded-for over x-real-ip', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-forwarded-for': '10.0.0.1', 'x-real-ip': '10.0.0.2', }, }) const ip = getClientIpFromRequest(req) expect(ip).toBe('10.0.0.1') }) }) describe('without TRUST_PROXY (default - prevents IP spoofing)', () => { beforeEach(() => { vi.stubEnv('TRUST_PROXY', '') }) it('ignores x-forwarded-for header to prevent IP spoofing', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-forwarded-for': '203.0.113.50, 70.41.3.18', }, }) const ip = getClientIpFromRequest(req) expect(ip).toBe('direct-connection') }) it('ignores x-real-ip header to prevent IP spoofing', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-real-ip': '192.168.1.100', }, }) const ip = getClientIpFromRequest(req) expect(ip).toBe('direct-connection') }) it('returns direct-connection for missing headers', async () => { const { getClientIpFromRequest } = await getModule() const req = new NextRequest('https://example.com/api/test') const ip = getClientIpFromRequest(req) expect(ip).toBe('direct-connection') }) }) }) describe('isIpBlocked', () => { async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } it('returns false when BLOCKED_IPS is not set', async () => { const { isIpBlocked } = await getModule() const result = isIpBlocked('192.168.1.1') expect(result).toBe(false) }) it('blocks exact IP match', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.100,10.0.0.50') const { isIpBlocked } = await getModule() expect(isIpBlocked('192.168.1.100')).toBe(true) expect(isIpBlocked('10.0.0.50')).toBe(true) expect(isIpBlocked('192.168.1.101')).toBe(false) }) it('blocks CIDR range', async () => { vi.stubEnv('BLOCKED_IPS', '10.0.0.0/24') const { isIpBlocked } = await getModule() expect(isIpBlocked('10.0.0.1')).toBe(true) expect(isIpBlocked('10.0.0.255')).toBe(true) expect(isIpBlocked('10.0.1.1')).toBe(false) }) it('blocks wildcard pattern', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.*') const { isIpBlocked } = await getModule() expect(isIpBlocked('192.168.0.1')).toBe(true) expect(isIpBlocked('192.168.255.255')).toBe(true) expect(isIpBlocked('192.169.0.1')).toBe(false) }) it('handles multiple patterns', async () => { vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8,192.168.1.50,172.16.*') const { isIpBlocked } = await getModule() expect(isIpBlocked('10.5.5.5')).toBe(true) // CIDR expect(isIpBlocked('192.168.1.50')).toBe(true) // Exact expect(isIpBlocked('172.16.0.1')).toBe(true) // Wildcard expect(isIpBlocked('8.8.8.8')).toBe(false) }) }) describe('isIpAllowed', () => { async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } describe('sendEmail endpoint', () => { it('allows all IPs when SEND_EMAIL_ALLOWED_IPS is not set', async () => { const { isIpAllowed } = await getModule() expect(isIpAllowed('192.168.1.1', 'sendEmail').allowed).toBe(true) expect(isIpAllowed('10.0.0.1', 'sendEmail').allowed).toBe(true) }) it('restricts to allowlist when configured', async () => { vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24,10.0.0.50') const { isIpAllowed } = await getModule() expect(isIpAllowed('192.168.1.100', 'sendEmail').allowed).toBe(true) expect(isIpAllowed('10.0.0.50', 'sendEmail').allowed).toBe(true) expect(isIpAllowed('172.16.0.1', 'sendEmail').allowed).toBe(false) }) }) describe('admin endpoint', () => { it('allows all IPs when ADMIN_ALLOWED_IPS is not set', async () => { const { isIpAllowed } = await getModule() expect(isIpAllowed('192.168.1.1', 'admin').allowed).toBe(true) }) it('restricts to allowlist when configured', async () => { vi.stubEnv('ADMIN_ALLOWED_IPS', '10.0.0.0/8') const { isIpAllowed } = await getModule() expect(isIpAllowed('10.5.5.5', 'admin').allowed).toBe(true) expect(isIpAllowed('192.168.1.1', 'admin').allowed).toBe(false) }) }) describe('webhooks endpoint', () => { it('blocks all IPs when WEBHOOK_ALLOWED_IPS is not set', async () => { const { isIpAllowed } = await getModule() expect(isIpAllowed('192.168.1.1', 'webhooks').allowed).toBe(false) }) it('allows only configured IPs', async () => { vi.stubEnv('WEBHOOK_ALLOWED_IPS', '203.0.113.0/24') const { isIpAllowed } = await getModule() expect(isIpAllowed('203.0.113.50', 'webhooks').allowed).toBe(true) expect(isIpAllowed('198.51.100.1', 'webhooks').allowed).toBe(false) }) }) }) describe('validateIpAccess', () => { async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } // These tests require TRUST_PROXY to extract IP from headers beforeEach(() => { vi.stubEnv('TRUST_PROXY', 'true') }) it('blocks if IP is on blocklist', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.100') vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24') const { validateIpAccess } = await getModule() const req = new NextRequest('https://example.com/api/send-email', { headers: { 'x-forwarded-for': '192.168.1.100', }, }) const result = validateIpAccess(req, 'sendEmail') expect(result.allowed).toBe(false) expect(result.reason).toContain('blocked') }) it('allows if not blocked and on allowlist', async () => { vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '192.168.1.0/24') const { validateIpAccess } = await getModule() const req = new NextRequest('https://example.com/api/send-email', { headers: { 'x-forwarded-for': '192.168.1.50', }, }) const result = validateIpAccess(req, 'sendEmail') expect(result.allowed).toBe(true) }) it('blocks if not on allowlist (when allowlist configured)', async () => { vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.0/8') const { validateIpAccess } = await getModule() const req = new NextRequest('https://example.com/api/send-email', { headers: { 'x-forwarded-for': '192.168.1.50', }, }) const result = validateIpAccess(req, 'sendEmail') expect(result.allowed).toBe(false) expect(result.reason).toContain('not in allowlist') }) it('returns extracted IP in result', async () => { const { validateIpAccess } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-forwarded-for': '203.0.113.50', }, }) const result = validateIpAccess(req, 'sendEmail') expect(result.ip).toBe('203.0.113.50') }) it('returns direct-connection when TRUST_PROXY is not set', async () => { vi.stubEnv('TRUST_PROXY', '') const { validateIpAccess } = await getModule() const req = new NextRequest('https://example.com/api/test', { headers: { 'x-forwarded-for': '203.0.113.50', }, }) const result = validateIpAccess(req, 'sendEmail') // Without TRUST_PROXY, IP spoofing is prevented by ignoring headers expect(result.ip).toBe('direct-connection') }) }) describe('CIDR Matching', () => { async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } it('handles /32 (single IP)', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.1/32') const { isIpBlocked } = await getModule() expect(isIpBlocked('192.168.1.1')).toBe(true) expect(isIpBlocked('192.168.1.2')).toBe(false) }) it('handles /24 (256 IPs)', async () => { vi.stubEnv('BLOCKED_IPS', '10.20.30.0/24') const { isIpBlocked } = await getModule() expect(isIpBlocked('10.20.30.0')).toBe(true) expect(isIpBlocked('10.20.30.127')).toBe(true) expect(isIpBlocked('10.20.30.255')).toBe(true) expect(isIpBlocked('10.20.31.0')).toBe(false) }) it('handles /16 (65536 IPs)', async () => { vi.stubEnv('BLOCKED_IPS', '172.16.0.0/16') const { isIpBlocked } = await getModule() expect(isIpBlocked('172.16.0.1')).toBe(true) expect(isIpBlocked('172.16.255.255')).toBe(true) expect(isIpBlocked('172.17.0.1')).toBe(false) }) it('handles /8 (16M IPs)', async () => { vi.stubEnv('BLOCKED_IPS', '10.0.0.0/8') const { isIpBlocked } = await getModule() expect(isIpBlocked('10.0.0.1')).toBe(true) expect(isIpBlocked('10.255.255.255')).toBe(true) expect(isIpBlocked('11.0.0.1')).toBe(false) }) it('handles /0 (all IPs)', async () => { vi.stubEnv('BLOCKED_IPS', '0.0.0.0/0') const { isIpBlocked } = await getModule() expect(isIpBlocked('192.168.1.1')).toBe(true) expect(isIpBlocked('8.8.8.8')).toBe(true) expect(isIpBlocked('10.0.0.1')).toBe(true) }) }) describe('Edge Cases', () => { async function getModule() { vi.resetModules() return import('@/lib/security/ip-allowlist') } it('handles empty allowlist string', async () => { vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '') const { isIpAllowed } = await getModule() // Empty string should be treated as not configured expect(isIpAllowed('192.168.1.1', 'sendEmail').allowed).toBe(true) }) it('handles whitespace in IP list', async () => { vi.stubEnv('BLOCKED_IPS', ' 192.168.1.1 , 10.0.0.1 ') const { isIpBlocked } = await getModule() expect(isIpBlocked('192.168.1.1')).toBe(true) expect(isIpBlocked('10.0.0.1')).toBe(true) }) it('handles invalid CIDR notation gracefully', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.0/invalid') const { isIpBlocked } = await getModule() // Should not throw, but may not match expect(() => isIpBlocked('192.168.1.1')).not.toThrow() }) it('handles IPv4-mapped IPv6 addresses', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.1') const { isIpBlocked } = await getModule() // Standard IPv4 should work expect(isIpBlocked('192.168.1.1')).toBe(true) }) it('handles unknown IP string', async () => { vi.stubEnv('BLOCKED_IPS', '192.168.1.1') const { isIpBlocked } = await getModule() expect(isIpBlocked('unknown')).toBe(false) }) }) })