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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
47
tests/unit/api-routes/openapi.test.ts
Normal file
47
tests/unit/api-routes/openapi.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user