diff --git a/tests/integration/api-tokens.test.ts b/tests/integration/api-tokens.test.ts index 68c83b21..aee11fb5 100644 --- a/tests/integration/api-tokens.test.ts +++ b/tests/integration/api-tokens.test.ts @@ -190,4 +190,56 @@ describe('api-tokens integration', () => { expect(user1Tokens).toHaveLength(1); expect(user1Tokens[0].name).toBe('User1 Token'); }); + + it('token created by user A still exists after user B deletes own tokens', async () => { + const userA = await insertUser({ email: 'a@localhost', subject: 'a@localhost' }); + const userB = await insertUser({ email: 'b@localhost', subject: 'b@localhost', role: 'user' }); + + const { token: tokenA } = await insertApiToken(userA.id, { name: 'A Token' }); + const { token: tokenB } = await insertApiToken(userB.id, { name: 'B Token' }); + + // User B deletes only their own tokens + await db.delete(apiTokens).where(eq(apiTokens.createdBy, userB.id)); + + const remainingTokens = await db.query.apiTokens.findMany(); + expect(remainingTokens).toHaveLength(1); + expect(remainingTokens[0].id).toBe(tokenA.id); + }); + + it('admin can see all tokens regardless of creator', async () => { + const admin = await insertUser({ email: 'admin2@localhost', subject: 'admin2@localhost', role: 'admin' }); + const user1 = await insertUser({ email: 'u3@localhost', subject: 'u3@localhost', role: 'user' }); + const user2 = await insertUser({ email: 'u4@localhost', subject: 'u4@localhost', role: 'user' }); + + await insertApiToken(user1.id, { name: 'User1 Token' }); + await insertApiToken(user2.id, { name: 'User2 Token' }); + await insertApiToken(admin.id, { name: 'Admin Token' }); + + const allTokens = await db.query.apiTokens.findMany(); + expect(allTokens).toHaveLength(3); + const creators = allTokens.map(t => t.createdBy); + expect(creators).toContain(user1.id); + expect(creators).toContain(user2.id); + expect(creators).toContain(admin.id); + }); + + it('inactive user token is discoverable but user status is inactive', async () => { + const inactiveUser = await insertUser({ + email: 'inactive@localhost', + subject: 'inactive@localhost', + status: 'inactive', + }); + const { token } = await insertApiToken(inactiveUser.id, { name: 'Inactive Token' }); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + expect(row).toBeDefined(); + expect(row!.createdBy).toBe(inactiveUser.id); + + const user = await db.query.users.findFirst({ + where: (u, { eq }) => eq(u.id, inactiveUser.id), + }); + expect(user!.status).toBe('inactive'); + }); }); diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 106a7a7a..a040a466 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -11,9 +11,10 @@ vi.mock('@/src/lib/auth', () => ({ checkSameOrigin: vi.fn(() => null), })); -import { authenticateApiRequest, requireApiUser, requireApiAdmin, ApiAuthError } from '@/src/lib/api-auth'; +import { authenticateApiRequest, requireApiUser, requireApiAdmin, ApiAuthError, apiErrorResponse } from '@/src/lib/api-auth'; import { validateToken } from '@/src/lib/models/api-tokens'; -import { auth } from '@/src/lib/auth'; +import { auth, checkSameOrigin } from '@/src/lib/auth'; +import { NextResponse } from 'next/server'; const mockValidateToken = vi.mocked(validateToken); const mockAuth = vi.mocked(auth); @@ -125,4 +126,81 @@ describe('requireApiUser', () => { expect(result.userId).toBe(5); expect(result.role).toBe('viewer'); }); + + it('CSRF check blocks session-authenticated POST without same origin', async () => { + mockAuth.mockResolvedValue({ + user: { id: '5', role: 'user', name: 'U', email: 'u@test.com' }, + expires: '', + } as any); + + const mockCheckSameOrigin = vi.mocked(checkSameOrigin); + mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any); + + await expect( + requireApiUser(createMockRequest({ method: 'POST' })) + ).rejects.toThrow(ApiAuthError); + + try { + mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any); + mockAuth.mockResolvedValue({ + user: { id: '5', role: 'user', name: 'U', email: 'u@test.com' }, + expires: '', + } as any); + await requireApiUser(createMockRequest({ method: 'POST' })); + } catch (e) { + expect((e as ApiAuthError).status).toBe(403); + } + }); + + it('CSRF check skips for Bearer-authenticated POST', async () => { + mockValidateToken.mockResolvedValue({ + token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null }, + user: { id: 42, role: 'admin' }, + }); + + const mockCheckSameOrigin = vi.mocked(checkSameOrigin); + mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any); + + const result = await requireApiUser(createMockRequest({ authorization: 'Bearer test-token', method: 'POST' })); + expect(result.userId).toBe(42); + expect(result.authMethod).toBe('bearer'); + }); +}); + +describe('authenticateApiRequest - empty bearer', () => { + it('rejects empty Bearer token', async () => { + await expect( + authenticateApiRequest(createMockRequest({ authorization: 'Bearer ' })) + ).rejects.toThrow(ApiAuthError); + + try { + await authenticateApiRequest(createMockRequest({ authorization: 'Bearer ' })); + } catch (e) { + expect((e as ApiAuthError).status).toBe(401); + expect((e as ApiAuthError).message).toBe('Invalid Bearer token'); + } + }); +}); + +describe('apiErrorResponse', () => { + it('handles ApiAuthError', async () => { + const response = apiErrorResponse(new ApiAuthError('Forbidden', 403)); + expect(response.status).toBe(403); + const data = await response.json(); + expect(data.error).toBe('Forbidden'); + }); + + it('handles generic Error', async () => { + const response = apiErrorResponse(new Error('Something broke')); + expect(response.status).toBe(500); + const data = await response.json(); + expect(data.error).toBe('Something broke'); + }); + + it('handles unknown error', async () => { + const response = apiErrorResponse('some string error'); + expect(response.status).toBe(500); + const data = await response.json(); + expect(data.error).toBe('Internal server error'); + }); }); diff --git a/tests/unit/api-routes/access-lists.test.ts b/tests/unit/api-routes/access-lists.test.ts index 74986aec..6e2c08f8 100644 --- a/tests/unit/api-routes/access-lists.test.ts +++ b/tests/unit/api-routes/access-lists.test.ts @@ -135,6 +135,16 @@ describe('PUT /api/v1/access-lists/[id]', () => { expect(data.name).toBe('Updated List'); expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); }); + + it('returns 500 when access list not found', async () => { + mockUpdate.mockRejectedValue(new Error('not found')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { name: 'X' } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('DELETE /api/v1/access-lists/[id]', () => { @@ -148,6 +158,16 @@ describe('DELETE /api/v1/access-lists/[id]', () => { expect(data).toEqual({ ok: true }); expect(mockDelete).toHaveBeenCalledWith(1, 1); }); + + it('returns 500 when access list not found', async () => { + mockDelete.mockRejectedValue(new Error('not found')); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('POST /api/v1/access-lists/[id]/entries', () => { @@ -181,3 +201,55 @@ describe('DELETE /api/v1/access-lists/[id]/entries/[entryId]', () => { expect(mockRemoveEntry).toHaveBeenCalledWith(1, 1, 1); }); }); + +describe('POST /api/v1/access-lists - with seed users', () => { + it('creates access list with seed users', async () => { + const input = { + name: 'Staff', + description: 'Staff access', + users: [ + { username: 'alice', password: 'secret123' }, + { username: 'bob', password: 'pass456' }, + ], + }; + mockCreate.mockResolvedValue({ + id: 3, + ...input, + entries: [], + created_at: '2026-01-01T00:00:00Z', + } as any); + + const response = await listPOST(createMockRequest({ method: 'POST', body: input })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(3); + expect(data.name).toBe('Staff'); + expect(data.description).toBe('Staff access'); + expect(data.users).toHaveLength(2); + expect(data.users[0].username).toBe('alice'); + expect(data.users[1].username).toBe('bob'); + expect(mockCreate).toHaveBeenCalledWith(input, 1); + }); +}); + +describe('POST /api/v1/access-lists/[id]/entries - with username and password', () => { + it('adds entry with username and password', async () => { + const entry = { username: 'charlie', password: 'newpass789' }; + const updatedList = { + ...sampleList, + entries: [...sampleList.entries, { id: 2, ...entry }], + }; + mockAddEntry.mockResolvedValue(updatedList as any); + + const response = await entriesPOST( + createMockRequest({ method: 'POST', body: entry }), + { params: Promise.resolve({ id: '1' }) } + ); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.entries).toHaveLength(2); + expect(mockAddEntry).toHaveBeenCalledWith(1, entry, 1); + }); +}); diff --git a/tests/unit/api-routes/ca-certificates.test.ts b/tests/unit/api-routes/ca-certificates.test.ts index 86aeb0e6..bb761313 100644 --- a/tests/unit/api-routes/ca-certificates.test.ts +++ b/tests/unit/api-routes/ca-certificates.test.ts @@ -129,6 +129,16 @@ describe('PUT /api/v1/ca-certificates/[id]', () => { expect(data.name).toBe('Updated CA'); expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); }); + + it('returns 500 when CA certificate not found', async () => { + mockUpdate.mockRejectedValue(new Error('not found')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { name: 'X' } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('DELETE /api/v1/ca-certificates/[id]', () => { @@ -142,4 +152,14 @@ describe('DELETE /api/v1/ca-certificates/[id]', () => { expect(data).toEqual({ ok: true }); expect(mockDelete).toHaveBeenCalledWith(1, 1); }); + + it('returns 500 when CA certificate not found', async () => { + mockDelete.mockRejectedValue(new Error('not found')); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); diff --git a/tests/unit/api-routes/certificates.test.ts b/tests/unit/api-routes/certificates.test.ts index 0cb79507..56085d72 100644 --- a/tests/unit/api-routes/certificates.test.ts +++ b/tests/unit/api-routes/certificates.test.ts @@ -130,6 +130,16 @@ describe('PUT /api/v1/certificates/[id]', () => { expect(data.domains).toEqual(['updated.example.com']); expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); }); + + it('returns 500 when certificate not found', async () => { + mockUpdate.mockRejectedValue(new Error('not found')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { domains: ['x.com'] } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('DELETE /api/v1/certificates/[id]', () => { @@ -143,4 +153,106 @@ describe('DELETE /api/v1/certificates/[id]', () => { expect(data).toEqual({ ok: true }); expect(mockDelete).toHaveBeenCalledWith(1, 1); }); + + it('returns 500 when certificate not found', async () => { + mockDelete.mockRejectedValue(new Error('not found')); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); +}); + +describe('POST /api/v1/certificates - input variations', () => { + it('creates managed certificate with provider_options', async () => { + const managedCert = { + name: 'Wildcard', + type: 'managed', + domain_names: ['*.example.com'], + auto_renew: true, + provider_options: { api_token: 'cloudflare-token' }, + }; + mockCreate.mockResolvedValue({ + id: 10, + ...managedCert, + certificate_pem: null, + private_key_pem: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } as any); + + const response = await POST(createMockRequest({ method: 'POST', body: managedCert })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(10); + expect(data.type).toBe('managed'); + expect(data.provider_options).toEqual({ api_token: 'cloudflare-token' }); + expect(data.certificate_pem).toBeNull(); + expect(data.private_key_pem).toBeNull(); + expect(mockCreate).toHaveBeenCalledWith(managedCert, 1); + }); + + it('creates imported certificate with PEM', async () => { + const importedCert = { + name: 'Custom Cert', + type: 'imported', + domain_names: ['custom.example.com'], + auto_renew: false, + certificate_pem: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + private_key_pem: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----', + }; + mockCreate.mockResolvedValue({ + id: 11, + ...importedCert, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } as any); + + const response = await POST(createMockRequest({ method: 'POST', body: importedCert })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(11); + expect(data.type).toBe('imported'); + expect(data.certificate_pem).toContain('BEGIN CERTIFICATE'); + expect(data.private_key_pem).toContain('BEGIN PRIVATE KEY'); + expect(mockCreate).toHaveBeenCalledWith(importedCert, 1); + }); +}); + +describe('GET /api/v1/certificates/[id] - full fields', () => { + it('returns certificate with all fields', async () => { + const fullCert = { + id: 1, + name: 'Full Cert', + type: 'imported', + domains: ['secure.example.com'], + domain_names: ['secure.example.com'], + status: 'active', + auto_renew: false, + provider_options: null, + certificate_pem: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + private_key_pem: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----', + expires_at: '2027-01-01', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }; + mockGet.mockResolvedValue(fullCert as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.id).toBe(1); + expect(data.name).toBe('Full Cert'); + expect(data.type).toBe('imported'); + expect(data.certificate_pem).toContain('BEGIN CERTIFICATE'); + expect(data.private_key_pem).toContain('BEGIN PRIVATE KEY'); + expect(data.auto_renew).toBe(false); + expect(data.created_at).toBe('2026-01-01'); + expect(data.updated_at).toBe('2026-01-01'); + }); }); diff --git a/tests/unit/api-routes/client-certificates.test.ts b/tests/unit/api-routes/client-certificates.test.ts index eb0ae3b3..6697197a 100644 --- a/tests/unit/api-routes/client-certificates.test.ts +++ b/tests/unit/api-routes/client-certificates.test.ts @@ -129,3 +129,94 @@ describe('DELETE /api/v1/client-certificates/[id]', () => { expect(mockRevoke).toHaveBeenCalledWith(1, 1); }); }); + +describe('POST /api/v1/client-certificates - all required fields', () => { + it('creates client certificate with all required fields', async () => { + const input = { + ca_certificate_id: 1, + common_name: 'device-01', + serial_number: 'A1B2C3D4', + fingerprint_sha256: 'AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89', + certificate_pem: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + valid_from: '2026-01-01T00:00:00Z', + valid_to: '2027-01-01T00:00:00Z', + }; + mockCreate.mockResolvedValue({ + id: 5, + ...input, + revoked_at: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } as any); + + const response = await POST(createMockRequest({ method: 'POST', body: input })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(5); + expect(data.common_name).toBe('device-01'); + expect(data.serial_number).toBe('A1B2C3D4'); + expect(data.fingerprint_sha256).toContain('AB:CD:EF'); + expect(data.certificate_pem).toContain('BEGIN CERTIFICATE'); + expect(data.valid_from).toBe('2026-01-01T00:00:00Z'); + expect(data.valid_to).toBe('2027-01-01T00:00:00Z'); + expect(data.revoked_at).toBeNull(); + expect(mockCreate).toHaveBeenCalledWith(input, 1); + }); +}); + +describe('DELETE /api/v1/client-certificates/[id] - revoked_at timestamp', () => { + it('returns certificate with revoked_at set', async () => { + const revokedCert = { + ...sampleClientCert, + serial_number: 'AABB1122', + fingerprint_sha256: '11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00', + valid_from: '2026-01-01T00:00:00Z', + valid_to: '2027-01-01T00:00:00Z', + status: 'revoked', + revoked_at: '2026-03-26T00:00:00Z', + }; + mockRevoke.mockResolvedValue(revokedCert as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.status).toBe('revoked'); + expect(data.revoked_at).toBe('2026-03-26T00:00:00Z'); + expect(mockRevoke).toHaveBeenCalledWith(1, 1); + }); +}); + +describe('GET /api/v1/client-certificates/[id] - full fields', () => { + it('returns full client certificate with all fields', async () => { + const fullCert = { + id: 3, + ca_certificate_id: 1, + common_name: 'full-device', + serial_number: 'DEADBEEF', + fingerprint_sha256: 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99', + certificate_pem: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + valid_from: '2026-01-01T00:00:00Z', + valid_to: '2027-06-01T00:00:00Z', + revoked_at: null, + status: 'active', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }; + mockGet.mockResolvedValue(fullCert as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '3' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.id).toBe(3); + expect(data.common_name).toBe('full-device'); + expect(data.serial_number).toBe('DEADBEEF'); + expect(data.fingerprint_sha256).toContain('AA:BB:CC'); + expect(data.valid_from).toBe('2026-01-01T00:00:00Z'); + expect(data.valid_to).toBe('2027-06-01T00:00:00Z'); + expect(data.revoked_at).toBeNull(); + expect(data.certificate_pem).toContain('BEGIN CERTIFICATE'); + }); +}); diff --git a/tests/unit/api-routes/l4-proxy-hosts.test.ts b/tests/unit/api-routes/l4-proxy-hosts.test.ts index 1e279f02..b1c1c000 100644 --- a/tests/unit/api-routes/l4-proxy-hosts.test.ts +++ b/tests/unit/api-routes/l4-proxy-hosts.test.ts @@ -132,6 +132,16 @@ describe('PUT /api/v1/l4-proxy-hosts/[id]', () => { expect(data.listen_port).toBe(4444); expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); }); + + it('returns 500 when host not found', async () => { + mockUpdate.mockRejectedValue(new Error('not found')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { listen_port: 4444 } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('DELETE /api/v1/l4-proxy-hosts/[id]', () => { @@ -145,4 +155,137 @@ describe('DELETE /api/v1/l4-proxy-hosts/[id]', () => { expect(data).toEqual({ ok: true }); expect(mockDelete).toHaveBeenCalledWith(1, 1); }); + + it('returns 500 when host not found', async () => { + mockDelete.mockRejectedValue(new Error('not found')); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); +}); + +describe('POST /api/v1/l4-proxy-hosts (all options)', () => { + it('creates L4 host with all options', async () => { + const fullBody = { + name: "PostgreSQL Proxy", + listen_addresses: [":5432"], + matchers: ["db.example.com"], + upstreams: ["db-primary:5432", "db-replica:5432"], + protocol: "tcp", + matcher_type: "tls_sni", + tls_termination: true, + proxy_protocol_version: "v2", + enabled: true, + load_balancer: { + enabled: true, + policy: "least_conn", + tryDuration: "10s", + tryInterval: "500ms", + retries: 2, + activeHealthCheck: { + enabled: true, + port: 5432, + interval: "15s", + timeout: "3s", + }, + passiveHealthCheck: { + enabled: true, + failDuration: "30s", + maxFails: 3, + unhealthyLatency: "5s", + }, + }, + dns_resolver: { + enabled: true, + resolvers: ["1.1.1.1"], + fallbacks: [], + timeout: "3s", + }, + upstream_dns_resolution: { + enabled: true, + family: "ipv4", + }, + geoblock: { + enabled: true, + block_countries: ["CN"], + block_continents: [], + block_asns: [], + block_cidrs: [], + block_ips: [], + allow_countries: [], + allow_continents: [], + allow_asns: [], + allow_cidrs: [], + allow_ips: [], + trusted_proxies: [], + fail_closed: true, + response_status: 403, + response_body: "Blocked", + response_headers: {}, + redirect_url: "", + }, + geoblock_mode: "override", + }; + + const returnValue = { id: 10, ...fullBody, created_at: '2026-03-26T00:00:00Z', updated_at: '2026-03-26T00:00:00Z' }; + mockCreate.mockResolvedValue(returnValue as any); + + const response = await POST(createMockRequest({ method: 'POST', body: fullBody })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(10); + expect(mockCreate).toHaveBeenCalledWith(fullBody, 1); + }); + + it('creates UDP L4 host without TLS', async () => { + const body = { + name: "DNS Proxy", + listen_addresses: [":53"], + matchers: [], + upstreams: ["dns:53"], + protocol: "udp", + matcher_type: "none", + tls_termination: false, + enabled: true, + }; + + const returnValue = { id: 11, ...body, created_at: '2026-03-26T00:00:00Z', updated_at: '2026-03-26T00:00:00Z' }; + mockCreate.mockResolvedValue(returnValue as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(11); + expect(data.protocol).toBe("udp"); + expect(data.tls_termination).toBe(false); + expect(data.matchers).toEqual([]); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('PUT /api/v1/l4-proxy-hosts/[id] (partial update)', () => { + it('updates L4 host matcher and protocol', async () => { + const partialBody = { + matcher_type: "http_host", + matchers: ["new.example.com"], + proxy_protocol_version: "v1", + }; + + const updated = { ...sampleHost, ...partialBody }; + mockUpdate.mockResolvedValue(updated as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body: partialBody }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mockUpdate).toHaveBeenCalledWith(1, partialBody, 1); + expect(data.matcher_type).toBe("http_host"); + expect(data.matchers).toEqual(["new.example.com"]); + expect(data.proxy_protocol_version).toBe("v1"); + }); }); diff --git a/tests/unit/api-routes/openapi.test.ts b/tests/unit/api-routes/openapi.test.ts new file mode 100644 index 00000000..7cfb36bd --- /dev/null +++ b/tests/unit/api-routes/openapi.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { GET } from '@/app/api/v1/openapi.json/route'; + +describe('GET /api/v1/openapi.json', () => { + it('returns 200', async () => { + const response = await GET(); + expect(response.status).toBe(200); + }); + + it('returns valid JSON with openapi field = "3.1.0"', async () => { + const response = await GET(); + const data = await response.json(); + expect(data.openapi).toBe('3.1.0'); + }); + + it('contains all expected paths', async () => { + const response = await GET(); + const data = await response.json(); + const paths = Object.keys(data.paths); + + expect(paths).toContain('/api/v1/tokens'); + expect(paths).toContain('/api/v1/proxy-hosts'); + expect(paths).toContain('/api/v1/l4-proxy-hosts'); + expect(paths).toContain('/api/v1/certificates'); + expect(paths).toContain('/api/v1/ca-certificates'); + expect(paths).toContain('/api/v1/client-certificates'); + expect(paths).toContain('/api/v1/access-lists'); + expect(paths).toContain('/api/v1/settings/{group}'); + expect(paths).toContain('/api/v1/instances'); + expect(paths).toContain('/api/v1/users'); + expect(paths).toContain('/api/v1/audit-log'); + expect(paths).toContain('/api/v1/caddy/apply'); + }); + + it('has Cache-Control header', async () => { + const response = await GET(); + expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600'); + }); + + it('has components.schemas defined', async () => { + const response = await GET(); + const data = await response.json(); + expect(data.components).toBeDefined(); + expect(data.components.schemas).toBeDefined(); + expect(Object.keys(data.components.schemas).length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/api-routes/proxy-hosts.test.ts b/tests/unit/api-routes/proxy-hosts.test.ts index 619050e1..3b1ea127 100644 --- a/tests/unit/api-routes/proxy-hosts.test.ts +++ b/tests/unit/api-routes/proxy-hosts.test.ts @@ -134,6 +134,16 @@ describe('PUT /api/v1/proxy-hosts/[id]', () => { expect(data.forward_port).toBe(9090); expect(mockUpdateProxyHost).toHaveBeenCalledWith(1, body, 1); }); + + it('returns 500 when host not found', async () => { + mockUpdateProxyHost.mockRejectedValue(new Error('not found')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { forward_port: 9090 } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); }); describe('DELETE /api/v1/proxy-hosts/[id]', () => { @@ -147,4 +157,281 @@ describe('DELETE /api/v1/proxy-hosts/[id]', () => { expect(data).toEqual({ ok: true }); expect(mockDeleteProxyHost).toHaveBeenCalledWith(1, 1); }); + + it('returns 500 when host not found', async () => { + mockDeleteProxyHost.mockRejectedValue(new Error('not found')); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('not found'); + }); +}); + +describe('POST /api/v1/proxy-hosts (all optional fields)', () => { + it('creates proxy host with all optional fields', async () => { + const fullBody = { + name: "Full Featured Host", + domains: ["app.example.com", "www.example.com"], + upstreams: ["10.0.0.1:8080", "10.0.0.2:8080"], + certificate_id: 5, + access_list_id: 2, + ssl_forced: true, + hsts_enabled: true, + hsts_subdomains: true, + allow_websocket: true, + preserve_host_header: true, + skip_https_hostname_validation: false, + enabled: true, + custom_reverse_proxy_json: '{"flush_interval": -1}', + custom_pre_handlers_json: null, + authentik: { + enabled: true, + outpostDomain: "auth.example.com", + outpostUpstream: "http://authentik:9000", + authEndpoint: null, + copyHeaders: ["X-Authentik-Username", "X-Authentik-Email"], + trustedProxies: ["private_ranges"], + setOutpostHostHeader: true, + protectedPaths: null, + }, + load_balancer: { + enabled: true, + policy: "round_robin", + policyHeaderField: null, + policyCookieName: null, + policyCookieSecret: null, + tryDuration: "5s", + tryInterval: "250ms", + retries: 3, + activeHealthCheck: { + enabled: true, + uri: "/health", + port: null, + interval: "30s", + timeout: "5s", + status: 200, + body: null, + }, + passiveHealthCheck: { + enabled: true, + failDuration: "30s", + maxFails: 5, + unhealthyStatus: [502, 503], + unhealthyLatency: "10s", + }, + }, + dns_resolver: { + enabled: true, + resolvers: ["1.1.1.1", "8.8.8.8"], + fallbacks: ["9.9.9.9"], + timeout: "5s", + }, + upstream_dns_resolution: { + enabled: true, + family: "ipv4", + }, + geoblock: { + enabled: true, + block_countries: ["CN", "RU"], + block_continents: [], + block_asns: [12345], + block_cidrs: [], + block_ips: [], + allow_countries: ["US", "FI"], + allow_continents: [], + allow_asns: [], + allow_cidrs: ["10.0.0.0/8"], + allow_ips: [], + trusted_proxies: ["private_ranges"], + fail_closed: false, + response_status: 403, + response_body: "Access denied", + response_headers: {}, + redirect_url: "", + }, + geoblock_mode: "merge", + waf: { + enabled: true, + mode: "On", + load_owasp_crs: true, + custom_directives: 'SecRule REQUEST_URI "@contains /admin" "id:1001,deny,status:403"', + excluded_rule_ids: [920350, 942100], + waf_mode: "merge", + }, + mtls: { + enabled: true, + ca_certificate_ids: [1, 3], + }, + redirects: [ + { from: "/.well-known/carddav", to: "/remote.php/dav/", status: 301 }, + { from: "/old-path", to: "/new-path", status: 308 }, + ], + rewrite: { + path_prefix: "/api", + }, + }; + + const returnValue = { id: 99, ...fullBody, created_at: '2026-03-26T00:00:00Z', updated_at: '2026-03-26T00:00:00Z' }; + mockCreateProxyHost.mockResolvedValue(returnValue as any); + + const response = await POST(createMockRequest({ method: 'POST', body: fullBody })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(99); + expect(mockCreateProxyHost).toHaveBeenCalledWith(fullBody, 1); + }); +}); + +describe('PUT /api/v1/proxy-hosts/[id] (partial fields)', () => { + it('updates proxy host with partial fields', async () => { + const partialBody = { + ssl_forced: false, + waf: { enabled: false, mode: "Off", load_owasp_crs: false, custom_directives: "", excluded_rule_ids: [] }, + redirects: [], + }; + + const updated = { ...sampleHost, ...partialBody }; + mockUpdateProxyHost.mockResolvedValue(updated as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body: partialBody }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mockUpdateProxyHost).toHaveBeenCalledWith(1, partialBody, 1); + expect(data.ssl_forced).toBe(false); + expect(data.waf).toEqual(partialBody.waf); + expect(data.redirects).toEqual([]); + }); +}); + +describe('GET /api/v1/proxy-hosts/[id] (all nested fields)', () => { + it('returns proxy host with all nested fields', async () => { + const fullHost = { + id: 42, + name: "Full Host", + domains: ["app.example.com"], + upstreams: ["10.0.0.1:8080"], + certificate_id: 5, + access_list_id: 2, + ssl_forced: true, + hsts_enabled: true, + hsts_subdomains: true, + allow_websocket: true, + preserve_host_header: true, + skip_https_hostname_validation: false, + enabled: true, + custom_reverse_proxy_json: '{"flush_interval": -1}', + custom_pre_handlers_json: null, + created_at: '2026-01-01', + updated_at: '2026-01-01', + authentik: { + enabled: true, + outpostDomain: "auth.example.com", + outpostUpstream: "http://authentik:9000", + authEndpoint: null, + copyHeaders: ["X-Authentik-Username"], + trustedProxies: ["private_ranges"], + setOutpostHostHeader: true, + protectedPaths: null, + }, + load_balancer: { + enabled: true, + policy: "round_robin", + policyHeaderField: null, + policyCookieName: null, + policyCookieSecret: null, + tryDuration: "5s", + tryInterval: "250ms", + retries: 3, + activeHealthCheck: { + enabled: true, + uri: "/health", + port: null, + interval: "30s", + timeout: "5s", + status: 200, + body: null, + }, + passiveHealthCheck: { + enabled: true, + failDuration: "30s", + maxFails: 5, + unhealthyStatus: [502, 503], + unhealthyLatency: "10s", + }, + }, + dns_resolver: { + enabled: true, + resolvers: ["1.1.1.1"], + fallbacks: [], + timeout: "5s", + }, + upstream_dns_resolution: { + enabled: true, + family: "ipv4", + }, + geoblock: { + enabled: true, + block_countries: ["CN"], + block_continents: [], + block_asns: [], + block_cidrs: [], + block_ips: [], + allow_countries: ["FI"], + allow_continents: [], + allow_asns: [], + allow_cidrs: [], + allow_ips: [], + trusted_proxies: ["private_ranges"], + fail_closed: false, + response_status: 403, + response_body: "Blocked", + response_headers: {}, + redirect_url: "", + }, + geoblock_mode: "merge", + waf: { + enabled: true, + mode: "On", + load_owasp_crs: true, + custom_directives: "", + excluded_rule_ids: [], + waf_mode: "merge", + }, + mtls: { + enabled: true, + ca_certificate_ids: [1], + }, + redirects: [ + { from: "/old", to: "/new", status: 301 }, + ], + rewrite: { + path_prefix: "/api", + }, + }; + + mockGetProxyHost.mockResolvedValue(fullHost as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '42' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(fullHost); + expect(data.authentik.enabled).toBe(true); + expect(data.authentik.outpostDomain).toBe("auth.example.com"); + expect(data.load_balancer.policy).toBe("round_robin"); + expect(data.load_balancer.activeHealthCheck.uri).toBe("/health"); + expect(data.load_balancer.passiveHealthCheck.maxFails).toBe(5); + expect(data.dns_resolver.resolvers).toEqual(["1.1.1.1"]); + expect(data.upstream_dns_resolution.family).toBe("ipv4"); + expect(data.geoblock.block_countries).toEqual(["CN"]); + expect(data.waf.mode).toBe("On"); + expect(data.mtls.ca_certificate_ids).toEqual([1]); + expect(data.redirects).toHaveLength(1); + expect(data.rewrite.path_prefix).toBe("/api"); + expect(mockGetProxyHost).toHaveBeenCalledWith(42); + }); }); diff --git a/tests/unit/api-routes/settings.test.ts b/tests/unit/api-routes/settings.test.ts index b3e5d09a..8fb5cbc5 100644 --- a/tests/unit/api-routes/settings.test.ts +++ b/tests/unit/api-routes/settings.test.ts @@ -52,13 +52,39 @@ vi.mock('@/src/lib/api-auth', () => { }); import { GET, PUT } from '@/app/api/v1/settings/[group]/route'; -import { getGeneralSettings, saveGeneralSettings } from '@/src/lib/settings'; +import { + getGeneralSettings, saveGeneralSettings, + getCloudflareSettings, saveCloudflareSettings, + getAuthentikSettings, saveAuthentikSettings, + getMetricsSettings, saveMetricsSettings, + getLoggingSettings, saveLoggingSettings, + getDnsSettings, saveDnsSettings, + getUpstreamDnsResolutionSettings, saveUpstreamDnsResolutionSettings, + getGeoBlockSettings, saveGeoBlockSettings, + getWafSettings, saveWafSettings, +} from '@/src/lib/settings'; import { getInstanceMode, setInstanceMode, getSlaveMasterToken, setSlaveMasterToken } from '@/src/lib/instance-sync'; import { applyCaddyConfig } from '@/src/lib/caddy'; import { requireApiAdmin } from '@/src/lib/api-auth'; const mockGetGeneral = vi.mocked(getGeneralSettings); const mockSaveGeneral = vi.mocked(saveGeneralSettings); +const mockGetCloudflare = vi.mocked(getCloudflareSettings); +const mockSaveCloudflare = vi.mocked(saveCloudflareSettings); +const mockGetAuthentik = vi.mocked(getAuthentikSettings); +const mockSaveAuthentik = vi.mocked(saveAuthentikSettings); +const mockGetMetrics = vi.mocked(getMetricsSettings); +const mockSaveMetrics = vi.mocked(saveMetricsSettings); +const mockGetLogging = vi.mocked(getLoggingSettings); +const mockSaveLogging = vi.mocked(saveLoggingSettings); +const mockGetDns = vi.mocked(getDnsSettings); +const mockSaveDns = vi.mocked(saveDnsSettings); +const mockGetUpstreamDns = vi.mocked(getUpstreamDnsResolutionSettings); +const mockSaveUpstreamDns = vi.mocked(saveUpstreamDnsResolutionSettings); +const mockGetGeoBlock = vi.mocked(getGeoBlockSettings); +const mockSaveGeoBlock = vi.mocked(saveGeoBlockSettings); +const mockGetWaf = vi.mocked(getWafSettings); +const mockSaveWaf = vi.mocked(saveWafSettings); const mockGetInstanceMode = vi.mocked(getInstanceMode); const mockSetInstanceMode = vi.mocked(setInstanceMode); const mockGetSlaveMasterToken = vi.mocked(getSlaveMasterToken); @@ -217,3 +243,271 @@ describe('PUT /api/v1/settings/[group]', () => { expect(data).toEqual({ ok: true }); }); }); + +describe('GET cloudflare settings', () => { + it('returns cloudflare settings', async () => { + const settings = { apiToken: 'cf-token-xxx', zoneId: 'zone123', accountId: 'acc456' }; + mockGetCloudflare.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'cloudflare' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetCloudflare).toHaveBeenCalled(); + }); +}); + +describe('PUT cloudflare settings', () => { + it('saves cloudflare settings and applies caddy config', async () => { + mockSaveCloudflare.mockResolvedValue(undefined); + + const body = { apiToken: 'new-token' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'cloudflare' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveCloudflare).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET authentik settings', () => { + it('returns authentik settings', async () => { + const settings = { outpostDomain: 'auth.example.com', outpostUpstream: 'http://authentik:9000', authEndpoint: '/outpost.goauthentik.io/auth/caddy' }; + mockGetAuthentik.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'authentik' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetAuthentik).toHaveBeenCalled(); + }); +}); + +describe('PUT authentik settings', () => { + it('saves authentik settings and applies caddy config', async () => { + mockSaveAuthentik.mockResolvedValue(undefined); + + const body = { outpostDomain: 'auth.example.com', outpostUpstream: 'http://authentik:9000', authEndpoint: '/outpost.goauthentik.io/auth/caddy' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'authentik' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveAuthentik).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET metrics settings', () => { + it('returns metrics settings', async () => { + const settings = { enabled: true, port: 9090 }; + mockGetMetrics.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'metrics' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetMetrics).toHaveBeenCalled(); + }); +}); + +describe('PUT metrics settings', () => { + it('saves metrics settings and applies caddy config', async () => { + mockSaveMetrics.mockResolvedValue(undefined); + + const body = { enabled: false }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'metrics' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveMetrics).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET logging settings', () => { + it('returns logging settings', async () => { + const settings = { enabled: true, format: 'json' }; + mockGetLogging.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'logging' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetLogging).toHaveBeenCalled(); + }); +}); + +describe('PUT logging settings', () => { + it('saves logging settings and applies caddy config', async () => { + mockSaveLogging.mockResolvedValue(undefined); + + const body = { enabled: true, format: 'console' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'logging' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveLogging).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET dns settings', () => { + it('returns dns settings', async () => { + const settings = { enabled: true, resolvers: ['1.1.1.1', '8.8.8.8'], fallbacks: ['9.9.9.9'], timeout: '5s' }; + mockGetDns.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'dns' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetDns).toHaveBeenCalled(); + }); +}); + +describe('PUT dns settings', () => { + it('saves dns settings and applies caddy config', async () => { + mockSaveDns.mockResolvedValue(undefined); + + const body = { enabled: true, resolvers: ['1.1.1.1', '8.8.8.8'], fallbacks: ['9.9.9.9'], timeout: '5s' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'dns' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveDns).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET upstream-dns settings', () => { + it('returns upstream-dns settings', async () => { + const settings = { enabled: true, family: 'ipv4' }; + mockGetUpstreamDns.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'upstream-dns' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetUpstreamDns).toHaveBeenCalled(); + }); +}); + +describe('PUT upstream-dns settings', () => { + it('saves upstream-dns settings and applies caddy config', async () => { + mockSaveUpstreamDns.mockResolvedValue(undefined); + + const body = { enabled: true, family: 'both' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'upstream-dns' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveUpstreamDns).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET geoblock settings', () => { + it('returns geoblock settings', async () => { + const settings = { + enabled: true, + block_countries: ['CN'], + block_continents: [], + block_asns: [], + block_cidrs: [], + block_ips: [], + allow_countries: ['FI'], + allow_continents: [], + allow_asns: [], + allow_cidrs: [], + allow_ips: [], + trusted_proxies: ['private_ranges'], + fail_closed: false, + response_status: 403, + response_body: 'Forbidden', + response_headers: {}, + redirect_url: '', + }; + mockGetGeoBlock.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'geoblock' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetGeoBlock).toHaveBeenCalled(); + }); +}); + +describe('PUT geoblock settings', () => { + it('saves geoblock settings and applies caddy config', async () => { + mockSaveGeoBlock.mockResolvedValue(undefined); + + const body = { + enabled: true, + block_countries: ['CN'], + block_continents: [], + block_asns: [], + block_cidrs: [], + block_ips: [], + allow_countries: ['FI'], + allow_continents: [], + allow_asns: [], + allow_cidrs: [], + allow_ips: [], + trusted_proxies: ['private_ranges'], + fail_closed: false, + response_status: 403, + response_body: 'Forbidden', + response_headers: {}, + redirect_url: '', + }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'geoblock' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveGeoBlock).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +}); + +describe('GET waf settings', () => { + it('returns waf settings', async () => { + const settings = { enabled: true, mode: 'On', load_owasp_crs: true, custom_directives: '', excluded_rule_ids: [920350] }; + mockGetWaf.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'waf' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + expect(mockGetWaf).toHaveBeenCalled(); + }); +}); + +describe('PUT waf settings', () => { + it('saves waf settings and applies caddy config', async () => { + mockSaveWaf.mockResolvedValue(undefined); + + const body = { enabled: true, mode: 'On', load_owasp_crs: true, custom_directives: '', excluded_rule_ids: [920350] }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'waf' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveWaf).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); +});