test: comprehensive API test coverage with full input variations

- OpenAPI endpoint: 5 tests (spec structure, paths, headers, schemas)
- Proxy hosts: POST/PUT/GET with all nested fields (authentik, load_balancer,
  dns_resolver, geoblock, waf, mtls, redirects, rewrite) + error cases
- L4 proxy hosts: TCP+TLS, UDP, matcher/protocol variations + error cases
- Certificates: managed with provider_options, imported with PEM fields
- Client certificates: all required fields, revoke with revoked_at
- Access lists: seed users, entry add with username/password + error cases
- Settings: GET+PUT for all 11 groups (was 3), full data shapes
- API auth: empty Bearer, CSRF session vs Bearer, apiErrorResponse variants
- API tokens integration: cross-user isolation, admin visibility, inactive user
- CA certificates: PUT/DELETE error cases

646 tests total (54 new)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-26 10:15:22 +01:00
parent de28478a42
commit 28f61082ce
10 changed files with 1199 additions and 3 deletions

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});