chore: git cache cleanup

This commit is contained in:
GitHub Actions
2026-03-04 18:34:49 +00:00
parent c32cce2a88
commit 27c252600a
2001 changed files with 683185 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
playwright/.auth/
+16
View File
@@ -0,0 +1,16 @@
# Frontend (Vite + React)
## Development
```bash
cd frontend
npm install
npm run dev
```
## Production build
```bash
cd frontend
npm run build
```
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
baseURL: process.env.CHARON_BASE_URL || 'http://localhost:8080',
},
reporter: [['list']],
})
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactRefresh from 'eslint-plugin-react-refresh';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: { 'react-refresh': reactRefresh, 'react-hooks': reactHooks },
rules: {
'react-refresh/only-export-components': 'warn',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn'
}
}
);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Charon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8955
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
{
"name": "charon-frontend",
"private": true,
"version": "0.3.0",
"type": "module",
"tools": [],
"constraints": [
"NPM SCRIPTS ONLY: Do not try to construct complex `vitest` or `playwright` commands. Always look at `package.json` first and use `npm run <script-name>`."
],
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json && vite build",
"pretype-check": "npm ci --silent",
"type-check": "tsc --noEmit",
"lint": "eslint . --report-unused-disable-directives",
"preview": "vite preview",
"test": "NODE_OPTIONS=--max-old-space-size=4096 vitest run",
"test:ci": "NODE_OPTIONS=--max-old-space-size=4096 vitest run",
"test:ui": "vitest --ui",
"check-coverage": "bash ../scripts/frontend-test-coverage.sh",
"pretest:coverage": "npm ci --silent && node -e \"require('fs').mkdirSync('coverage/.tmp', { recursive: true })\"",
"test:coverage": "NODE_OPTIONS=--max-old-space-size=4096 vitest run --coverage",
"e2e:install": "npx playwright install --with-deps",
"e2e:test": "playwright test",
"e2e:up:block": "docker compose -f ../.docker/compose/docker-compose.local.yml down && CHARON_SECURITY_WAF_MODE=block docker compose -f ../.docker/compose/docker-compose.local.yml up -d",
"e2e:up:monitor": "docker compose -f ../.docker/compose/docker-compose.local.yml down && CHARON_SECURITY_WAF_MODE=monitor docker compose -f ../.docker/compose/docker-compose.local.yml up -d",
"e2e:down": "docker compose -f ../.docker/compose/docker-compose.local.yml down"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0",
"tldts": "^7.0.24"
},
"devDependencies": {
"@eslint/css": "^0.14.1",
"@eslint/js": "^9.39.3 <10.0.0",
"@eslint/json": "^1.0.1",
"@eslint/markdown": "^7.5.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3 <10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"jsdom": "28.1.0",
"knip": "^5.85.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Site Not Configured | Charon</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f3f4f6;
color: #1f2937;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}
.container {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 500px;
width: 90%;
}
h1 {
color: #4f46e5;
margin-bottom: 1rem;
}
p {
margin-bottom: 1.5rem;
line-height: 1.5;
color: #4b5563;
}
.logo {
font-size: 3rem;
margin-bottom: 1rem;
}
.btn {
display: inline-block;
background-color: #4f46e5;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 500;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #4338ca;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🛡️</div>
<h1>Site Not Configured</h1>
<p>
The domain you are trying to access is pointing to this server, but no proxy host has been configured for it yet.
</p>
<p>
If you are the administrator, please log in to the Charon dashboard to configure this host.
</p>
<a href="http://localhost:8080" id="admin-link" class="btn">Go to Dashboard</a>
</div>
<script>
// Dynamically update the admin link to point to port 8080 on the current hostname
const link = document.getElementById('admin-link');
const currentHost = window.location.hostname;
link.href = `http://${currentHost}:8080`;
</script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
import { Suspense, lazy } from 'react'
import { Navigate } from 'react-router-dom'
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Layout from './components/Layout'
import { ToastContainer } from './components/Toast'
import { SetupGuard } from './components/SetupGuard'
import { LoadingOverlay } from './components/LoadingStates'
import RequireAuth from './components/RequireAuth'
import RequireRole from './components/RequireRole'
import { AuthProvider } from './context/AuthContext'
// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'))
const ProxyHosts = lazy(() => import('./pages/ProxyHosts'))
const RemoteServers = lazy(() => import('./pages/RemoteServers'))
const DNS = lazy(() => import('./pages/DNS'))
const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
const ImportNPM = lazy(() => import('./pages/ImportNPM'))
const ImportJSON = lazy(() => import('./pages/ImportJSON'))
const Certificates = lazy(() => import('./pages/Certificates'))
const DNSProviders = lazy(() => import('./pages/DNSProviders'))
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
const Settings = lazy(() => import('./pages/Settings'))
const Backups = lazy(() => import('./pages/Backups'))
const Tasks = lazy(() => import('./pages/Tasks'))
const Logs = lazy(() => import('./pages/Logs'))
const Domains = lazy(() => import('./pages/Domains'))
const Security = lazy(() => import('./pages/Security'))
const AccessLists = lazy(() => import('./pages/AccessLists'))
const WafConfig = lazy(() => import('./pages/WafConfig'))
const RateLimiting = lazy(() => import('./pages/RateLimiting'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
const UsersPage = lazy(() => import('./pages/UsersPage'))
const SecurityHeaders = lazy(() => import('./pages/SecurityHeaders'))
const AuditLogs = lazy(() => import('./pages/AuditLogs'))
const EncryptionManagement = lazy(() => import('./pages/EncryptionManagement'))
const Plugins = lazy(() => import('./pages/Plugins'))
const Login = lazy(() => import('./pages/Login'))
const Setup = lazy(() => import('./pages/Setup'))
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
const PassthroughLanding = lazy(() => import('./pages/PassthroughLanding'))
export default function App() {
return (
<AuthProvider>
<Router>
<Suspense fallback={<LoadingOverlay message="Loading application..." />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/passthrough" element={
<RequireAuth>
<PassthroughLanding />
</RequireAuth>
} />
<Route path="/" element={
<SetupGuard>
<RequireAuth>
<Layout>
<Outlet />
</Layout>
</RequireAuth>
</SetupGuard>
}>
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="domains" element={<Domains />} />
<Route path="certificates" element={<Certificates />} />
{/* DNS Routes */}
<Route path="dns" element={<DNS />}>
<Route index element={<Navigate to="/dns/providers" replace />} />
<Route path="providers" element={<DNSProviders />} />
<Route path="plugins" element={<Plugins />} />
</Route>
{/* Legacy redirect for old bookmarks */}
<Route path="dns-providers" element={<Navigate to="/dns/providers" replace />} />
<Route path="security" element={<Security />} />
<Route path="security/audit-logs" element={<AuditLogs />} />
<Route path="security/access-lists" element={<AccessLists />} />
<Route path="security/crowdsec" element={<CrowdSecConfig />} />
<Route path="security/rate-limiting" element={<RateLimiting />} />
<Route path="security/waf" element={<WafConfig />} />
<Route path="security/headers" element={<SecurityHeaders />} />
<Route path="security/encryption" element={<EncryptionManagement />} />
<Route path="access-lists" element={<AccessLists />} />
<Route path="uptime" element={<Uptime />} />
{/* Legacy redirects for old user management paths */}
<Route path="users" element={<Navigate to="/settings/users" replace />} />
<Route path="admin/plugins" element={<Navigate to="/dns/plugins" replace />} />
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
{/* Settings Routes */}
<Route path="settings" element={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
<Route index element={<SystemSettings />} />
<Route path="system" element={<SystemSettings />} />
<Route path="notifications" element={<Notifications />} />
<Route path="smtp" element={<SMTPSettings />} />
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
<Route path="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
{/* Legacy redirects */}
<Route path="account" element={<Navigate to="/settings/users" replace />} />
<Route path="account-management" element={<Navigate to="/settings/users" replace />} />
</Route>
{/* Tasks Routes */}
<Route path="tasks" element={<Tasks />}>
<Route index element={<Backups />} />
<Route path="backups" element={<Backups />} />
<Route path="logs" element={<Logs />} />
<Route path="import">
<Route path="caddyfile" element={<ImportCaddy />} />
<Route path="crowdsec" element={<ImportCrowdSec />} />
<Route path="npm" element={<ImportNPM />} />
<Route path="json" element={<ImportJSON />} />
</Route>
</Route>
</Route>
</Routes>
</Suspense>
<ToastContainer />
<Toaster
position="bottom-right"
toastOptions={{
duration: 5000,
success: {
style: { background: '#16a34a', color: 'white' },
ariaProps: { role: 'status', 'aria-live': 'polite' },
},
error: {
style: { background: '#dc2626', color: 'white' },
ariaProps: { role: 'alert', 'aria-live': 'assertive' },
},
}}
/>
</Router>
</AuthProvider>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest'
import i18n from '../i18n'
describe('i18n configuration', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
})
it('initializes with default language', () => {
expect(i18n.language).toBeDefined()
expect(i18n.isInitialized).toBe(true)
})
it('has all required language resources', () => {
const languages = ['en', 'es', 'fr', 'de', 'zh']
languages.forEach((lang) => {
expect(i18n.hasResourceBundle(lang, 'translation')).toBe(true)
})
})
it('translates common keys', () => {
expect(i18n.t('common.save')).toBe('Save')
expect(i18n.t('common.cancel')).toBe('Cancel')
expect(i18n.t('common.delete')).toBe('Delete')
})
it('translates navigation keys', () => {
expect(i18n.t('navigation.dashboard')).toBe('Dashboard')
expect(i18n.t('navigation.settings')).toBe('Settings')
})
it('changes language and translates correctly', async () => {
await i18n.changeLanguage('es')
expect(i18n.t('common.save')).toBe('Guardar')
expect(i18n.t('common.cancel')).toBe('Cancelar')
await i18n.changeLanguage('fr')
expect(i18n.t('common.save')).toBe('Enregistrer')
expect(i18n.t('common.cancel')).toBe('Annuler')
await i18n.changeLanguage('de')
expect(i18n.t('common.save')).toBe('Speichern')
expect(i18n.t('common.cancel')).toBe('Abbrechen')
await i18n.changeLanguage('zh')
expect(i18n.t('common.save')).toBe('保存')
expect(i18n.t('common.cancel')).toBe('取消')
})
it('falls back to English for missing translations', async () => {
await i18n.changeLanguage('en')
const key = 'nonexistent.key'
expect(i18n.t(key)).toBe(key) // Should return the key itself
})
it('supports interpolation', () => {
expect(i18n.t('dashboard.activeHosts', { count: 5 })).toBe('5 active')
})
})
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { accessListsApi } from '../accessLists';
import client from '../client';
import type { AccessList } from '../accessLists';
// Mock the client module
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('accessListsApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('should fetch all access lists', async () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockLists });
const result = await accessListsApi.list();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists');
expect(result).toEqual(mockLists);
});
});
describe('get', () => {
it('should fetch access list by ID', async () => {
const mockList: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.get).mockResolvedValueOnce({ data: mockList });
const result = await accessListsApi.get(1);
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/1');
expect(result).toEqual(mockList);
});
});
describe('create', () => {
it('should create a new access list', async () => {
const newList = {
name: 'New ACL',
description: 'New description',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"}]',
enabled: true,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'new-uuid',
...newList,
country_codes: '',
local_network_only: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.create(newList);
expect(client.post).toHaveBeenCalledWith<[string, typeof newList]>('/access-lists', newList);
expect(result).toEqual(mockResponse);
});
});
describe('update', () => {
it('should update an access list', async () => {
const updates = {
name: 'Updated ACL',
enabled: false,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Updated ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.put).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.update(1, updates);
expect(client.put).toHaveBeenCalledWith<[string, typeof updates]>('/access-lists/1', updates);
expect(result).toEqual(mockResponse);
});
});
describe('delete', () => {
it('should delete an access list', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined });
await accessListsApi.delete(1);
expect(client.delete).toHaveBeenCalledWith<[string]>('/access-lists/1');
});
});
describe('testIP', () => {
it('should test an IP against an access list', async () => {
const mockResponse = {
allowed: true,
reason: 'IP matches whitelist rule',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.testIP(1, '192.168.1.100');
expect(client.post).toHaveBeenCalledWith<[string, { ip_address: string }]>('/access-lists/1/test', {
ip_address: '192.168.1.100',
});
expect(result).toEqual(mockResponse);
});
});
describe('getTemplates', () => {
it('should fetch access list templates', async () => {
const mockTemplates = [
{
name: 'Private Networks',
description: 'RFC1918 private networks',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"},{"cidr":"172.16.0.0/12"},{"cidr":"192.168.0.0/16"}]',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockTemplates });
const result = await accessListsApi.getTemplates();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/templates');
expect(result).toEqual(mockTemplates);
});
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../../api/client'
import { getBackups, createBackup, restoreBackup, deleteBackup } from '../backups'
describe('backups api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('getBackups returns list', async () => {
const mockData = [{ filename: 'b1.zip', size: 123, time: '2025-01-01T00:00:00Z' }]
vi.spyOn(client, 'get').mockResolvedValueOnce({ data: mockData })
const res = await getBackups()
expect(res).toEqual(mockData)
})
it('createBackup returns filename', async () => {
vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { filename: 'b2.zip' } })
const res = await createBackup()
expect(res).toEqual({ filename: 'b2.zip' })
})
it('restoreBackup posts to restore endpoint', async () => {
const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({})
await restoreBackup('b3.zip')
expect(spy).toHaveBeenCalledWith('/backups/b3.zip/restore')
})
it('deleteBackup deletes backup', async () => {
const spy = vi.spyOn(client, 'delete').mockResolvedValueOnce({})
await deleteBackup('b3.zip')
expect(spy).toHaveBeenCalledWith('/backups/b3.zip')
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import { getCertificates, uploadCertificate, deleteCertificate, Certificate } from '../certificates';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('certificates API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockCert: Certificate = {
id: 1,
domain: 'example.com',
issuer: 'Let\'s Encrypt',
expires_at: '2023-01-01',
status: 'valid',
provider: 'letsencrypt',
};
it('getCertificates calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockCert] });
const result = await getCertificates();
expect(client.get).toHaveBeenCalledWith('/certificates');
expect(result).toEqual([mockCert]);
});
it('uploadCertificate calls client.post with FormData', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockCert });
const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' });
const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' });
const result = await uploadCertificate('My Cert', certFile, keyFile);
expect(client.post).toHaveBeenCalledWith('/certificates', expect.any(FormData), {
headers: { 'Content-Type': 'multipart/form-data' },
});
expect(result).toEqual(mockCert);
});
it('deleteCertificate calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteCertificate(1);
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
});
});
+205
View File
@@ -0,0 +1,205 @@
import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest'
type ResponseHandler = (value: unknown) => unknown
type ErrorHandler = (error: ResponseError) => Promise<never>
type ResponseError = {
response?: {
status?: number
data?: Record<string, unknown>
}
config?: {
url?: string
}
message?: string
}
// Use vi.hoisted() to declare variables accessible in hoisted mocks
const capturedHandlers = vi.hoisted(() => ({
onFulfilled: undefined as ResponseHandler | undefined,
onRejected: undefined as ErrorHandler | undefined,
}))
vi.mock('axios', () => {
const mockClient = {
defaults: {
headers: {
common: {} as Record<string, string>,
},
},
interceptors: {
response: {
use: vi.fn((onFulfilled?: ResponseHandler, onRejected?: ErrorHandler) => {
capturedHandlers.onFulfilled = onFulfilled
capturedHandlers.onRejected = onRejected
return vi.fn()
}),
},
},
}
return {
default: {
create: vi.fn(() => mockClient),
},
}
})
// Must import AFTER mock definition
import { setAuthErrorHandler, setAuthToken } from '../client'
import axios from 'axios'
// Get mock client instance for header assertions
const getMockClient = () => {
const mockAxios = vi.mocked(axios)
return mockAxios.create()
}
describe('api client', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('sets and clears the Authorization header', () => {
const mockClientInstance = getMockClient()
setAuthToken('test-token')
expect(mockClientInstance.defaults.headers.common.Authorization).toBe('Bearer test-token')
setAuthToken(null)
expect(mockClientInstance.defaults.headers.common.Authorization).toBeUndefined()
})
it('extracts error message from response payload', async () => {
const error: ResponseError = {
response: { data: { error: 'Bad request' } },
config: { url: '/test' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(error.message).toBe('Bad request')
})
it('keeps original message when response payload is not an object', async () => {
const error: ResponseError = {
response: { data: 'plain text error' as unknown as Record<string, unknown> },
config: { url: '/test' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(error.message).toBe('Original')
})
it('uses error field over message field when both exist', async () => {
const error: ResponseError = {
response: { data: { error: 'Preferred error', message: 'Secondary message' } },
config: { url: '/test' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(error.message).toBe('Preferred error')
})
it('invokes auth error handler on 401 outside auth endpoints', async () => {
const onAuthError = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setAuthErrorHandler(onAuthError)
const error: ResponseError = {
response: { status: 401, data: { message: 'Unauthorized' } },
config: { url: '/proxy-hosts' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(onAuthError).toHaveBeenCalledTimes(1)
expect(error.message).toBe('Unauthorized')
warnSpy.mockRestore()
})
it('skips auth error handler for auth endpoints', async () => {
const onAuthError = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setAuthErrorHandler(onAuthError)
const error: ResponseError = {
response: { status: 401, data: { message: 'Unauthorized' } },
config: { url: '/auth/login' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
// Call handler with auth endpoint error to verify it skips the auth error handler
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(onAuthError).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('does not invoke auth error handler when status is not 401', async () => {
const onAuthError = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setAuthErrorHandler(onAuthError)
const error: ResponseError = {
response: { status: 403, data: { message: 'Forbidden' } },
config: { url: '/proxy-hosts' },
message: 'Original',
}
const handler = capturedHandlers.onRejected
expect(handler).toBeDefined()
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(onAuthError).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('passes through successful responses via fulfilled interceptor', () => {
const responsePayload = { data: { ok: true } }
const fulfilled = capturedHandlers.onFulfilled
expect(fulfilled).toBeDefined()
const result = fulfilled ? fulfilled(responsePayload) : undefined
expect(result).toBe(responsePayload)
})
})
@@ -0,0 +1,507 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as consoleEnrollment from '../consoleEnrollment'
import client from '../client'
vi.mock('../client')
describe('consoleEnrollment API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getConsoleStatus', () => {
it('should fetch enrollment status with pending state', async () => {
const mockStatus = {
status: 'pending',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
last_attempt_at: '2025-12-15T09:00:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/console/status')
expect(result).toEqual(mockStatus)
expect(result.status).toBe('pending')
expect(result.key_present).toBe(true)
})
it('should fetch enrolled status with heartbeat', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
last_heartbeat_at: '2025-12-15T09:55:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('enrolled')
expect(result.enrolled_at).toBeDefined()
expect(result.last_heartbeat_at).toBeDefined()
})
it('should fetch failed status with error message', async () => {
const mockStatus = {
status: 'failed',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: false,
last_error: 'Invalid enrollment key',
last_attempt_at: '2025-12-15T09:00:00Z',
correlation_id: 'req-abc123',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('failed')
expect(result.last_error).toBe('Invalid enrollment key')
expect(result.correlation_id).toBe('req-abc123')
expect(result.key_present).toBe(false)
})
it('should fetch status with none state (not enrolled)', async () => {
const mockStatus = {
status: 'none',
key_present: false,
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.status).toBe('none')
expect(result.key_present).toBe(false)
expect(result.tenant).toBeUndefined()
})
it('should NOT return enrollment key in status response', async () => {
const mockStatus = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'test-agent',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
// Security test: Ensure key is never exposed
expect(result).not.toHaveProperty('enrollment_key')
expect(result).not.toHaveProperty('encrypted_enroll_key')
expect(result).toHaveProperty('key_present')
})
it('should handle API errors', async () => {
const error = new Error('Network error')
vi.mocked(client.get).mockRejectedValue(error)
await expect(consoleEnrollment.getConsoleStatus()).rejects.toThrow('Network error')
})
it('should handle server unavailability', async () => {
const error = {
response: {
status: 503,
data: { error: 'Service temporarily unavailable' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(consoleEnrollment.getConsoleStatus()).rejects.toEqual(error)
})
})
describe('enrollConsole', () => {
it('should enroll with valid payload', async () => {
const payload = {
enrollment_key: 'cs-enroll-abc123xyz',
tenant: 'my-org',
agent_name: 'charon-prod',
force: false,
}
const mockResponse = {
status: 'enrolled',
tenant: 'my-org',
agent_name: 'charon-prod',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/console/enroll', payload)
expect(result).toEqual(mockResponse)
expect(result.status).toBe('enrolled')
expect(result.enrolled_at).toBeDefined()
})
it('should enroll with minimal payload (no tenant)', async () => {
const payload = {
enrollment_key: 'cs-enroll-key123',
agent_name: 'charon-test',
}
const mockResponse = {
status: 'enrolled',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(result.agent_name).toBe('charon-test')
})
it('should force re-enrollment when force=true', async () => {
const payload = {
enrollment_key: 'cs-enroll-new-key',
agent_name: 'charon-updated',
force: true,
}
const mockResponse = {
status: 'enrolled',
agent_name: 'charon-updated',
key_present: true,
enrolled_at: '2025-12-15T10:05:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/console/enroll', payload)
})
it('should handle invalid enrollment key format', async () => {
const payload = {
enrollment_key: 'not-a-valid-key',
agent_name: 'test',
}
const error = {
response: {
status: 400,
data: { error: 'Invalid enrollment key format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle transient network errors during enrollment', async () => {
const payload = {
enrollment_key: 'cs-enroll-key123',
agent_name: 'test-agent',
}
const error = {
response: {
status: 503,
data: { error: 'CrowdSec Console API temporarily unavailable' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle enrollment key expiration', async () => {
const payload = {
enrollment_key: 'cs-enroll-expired-key',
agent_name: 'test',
}
const mockResponse = {
status: 'failed',
key_present: false,
last_error: 'Enrollment key expired',
correlation_id: 'err-expired-123',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('failed')
expect(result.last_error).toBe('Enrollment key expired')
})
it('should sanitize tenant name with special characters', async () => {
const payload = {
enrollment_key: 'valid-key',
tenant: 'My Org (Production)',
agent_name: 'agent1',
}
const mockResponse = {
status: 'enrolled',
tenant: 'My Org (Production)',
agent_name: 'agent1',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('enrolled')
expect(result.tenant).toBe('My Org (Production)')
})
it('should handle SQL injection attempts in agent_name', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: "'; DROP TABLE users; --",
}
const error = {
response: {
status: 400,
data: { error: 'Invalid agent name format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should handle CrowdSec not running during enrollment', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: 'test',
}
const error = {
response: {
status: 500,
data: { error: 'CrowdSec is not running. Start CrowdSec before enrolling.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toEqual(error)
})
it('should return pending status when enrollment is queued', async () => {
const payload = {
enrollment_key: 'valid-key',
agent_name: 'test',
}
const mockResponse = {
status: 'pending',
agent_name: 'test',
key_present: true,
last_attempt_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
expect(result.status).toBe('pending')
expect(result.last_attempt_at).toBeDefined()
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(consoleEnrollment.default).toHaveProperty('getConsoleStatus')
expect(consoleEnrollment.default).toHaveProperty('enrollConsole')
})
})
describe('integration scenarios', () => {
it('should handle full enrollment workflow: status → enroll → verify', async () => {
// 1. Check initial status (not enrolled)
const mockStatusNone = {
status: 'none',
key_present: false,
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockStatusNone })
const statusBefore = await consoleEnrollment.getConsoleStatus()
expect(statusBefore.status).toBe('none')
// 2. Enroll
const enrollPayload = {
enrollment_key: 'cs-enroll-valid-key',
tenant: 'test-org',
agent_name: 'charon-test',
}
const mockEnrollResponse = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockEnrollResponse })
const enrollResult = await consoleEnrollment.enrollConsole(enrollPayload)
expect(enrollResult.status).toBe('enrolled')
// 3. Verify status updated
const mockStatusEnrolled = {
status: 'enrolled',
tenant: 'test-org',
agent_name: 'charon-test',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
last_heartbeat_at: '2025-12-15T10:01:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockStatusEnrolled })
const statusAfter = await consoleEnrollment.getConsoleStatus()
expect(statusAfter.status).toBe('enrolled')
expect(statusAfter.tenant).toBe('test-org')
})
it('should handle enrollment failure and retry', async () => {
// 1. First enrollment attempt fails
const payload = {
enrollment_key: 'cs-enroll-key',
agent_name: 'test',
}
const networkError = new Error('Network timeout')
vi.mocked(client.post).mockRejectedValueOnce(networkError)
await expect(consoleEnrollment.enrollConsole(payload)).rejects.toThrow('Network timeout')
// 2. Retry succeeds
const mockResponse = {
status: 'enrolled',
agent_name: 'test',
key_present: true,
enrolled_at: '2025-12-15T10:05:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse })
const retryResult = await consoleEnrollment.enrollConsole(payload)
expect(retryResult.status).toBe('enrolled')
})
it('should handle status transitions: none → pending → enrolled', async () => {
// 1. Initial: none
const mockNone = { status: 'none', key_present: false }
vi.mocked(client.get).mockResolvedValueOnce({ data: mockNone })
const status1 = await consoleEnrollment.getConsoleStatus()
expect(status1.status).toBe('none')
// 2. Enroll (returns pending)
const payload = { enrollment_key: 'key', agent_name: 'agent' }
const mockPending = {
status: 'pending',
agent_name: 'agent',
key_present: true,
last_attempt_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPending })
const enrollResult = await consoleEnrollment.enrollConsole(payload)
expect(enrollResult.status).toBe('pending')
// 3. Check status again (now enrolled)
const mockEnrolled = {
status: 'enrolled',
agent_name: 'agent',
key_present: true,
enrolled_at: '2025-12-15T10:00:30Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockEnrolled })
const status2 = await consoleEnrollment.getConsoleStatus()
expect(status2.status).toBe('enrolled')
})
it('should handle force re-enrollment over existing enrollment', async () => {
// 1. Check current enrollment
const mockCurrent = {
status: 'enrolled',
tenant: 'old-org',
agent_name: 'old-agent',
key_present: true,
enrolled_at: '2025-12-14T10:00:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockCurrent })
const currentStatus = await consoleEnrollment.getConsoleStatus()
expect(currentStatus.tenant).toBe('old-org')
// 2. Force re-enrollment
const forcePayload = {
enrollment_key: 'new-key',
tenant: 'new-org',
agent_name: 'new-agent',
force: true,
}
const mockForced = {
status: 'enrolled',
tenant: 'new-org',
agent_name: 'new-agent',
key_present: true,
enrolled_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockForced })
const forceResult = await consoleEnrollment.enrollConsole(forcePayload)
expect(forceResult.tenant).toBe('new-org')
})
})
describe('security tests', () => {
it('should never log or expose enrollment key', async () => {
const payload = {
enrollment_key: 'cs-enroll-secret-key-should-never-log',
agent_name: 'test',
}
const mockResponse = {
status: 'enrolled',
agent_name: 'test',
key_present: true,
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await consoleEnrollment.enrollConsole(payload)
// Ensure response never contains the key
expect(result).not.toHaveProperty('enrollment_key')
expect(JSON.stringify(result)).not.toContain('cs-enroll-secret-key')
})
it('should sanitize error messages to avoid key leakage', async () => {
const payload = {
enrollment_key: 'cs-enroll-sensitive-key',
agent_name: 'test',
}
const error = {
response: {
status: 400,
data: { error: 'Enrollment failed: invalid key format' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
try {
await consoleEnrollment.enrollConsole(payload)
} catch (e: unknown) {
// Error message should NOT contain the key
const error = e as { response?: { data?: { error?: string } } }
expect(error.response?.data?.error).not.toContain('cs-enroll-sensitive-key')
}
})
it('should handle correlation_id for debugging without exposing keys', async () => {
const mockStatus = {
status: 'failed',
key_present: false,
last_error: 'Authentication failed',
correlation_id: 'debug-correlation-abc123',
}
vi.mocked(client.get).mockResolvedValue({ data: mockStatus })
const result = await consoleEnrollment.getConsoleStatus()
expect(result.correlation_id).toBe('debug-correlation-abc123')
expect(result).not.toHaveProperty('enrollment_key')
})
})
})
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getCredentials,
getCredential,
createCredential,
updateCredential,
deleteCredential,
testCredential,
enableMultiCredentials,
type DNSProviderCredential,
type CredentialRequest,
type CredentialTestResult,
} from '../credentials'
import client from '../client'
vi.mock('../client')
const mockCredential: DNSProviderCredential = {
id: 1,
uuid: 'test-uuid-1',
dns_provider_id: 1,
label: 'Production Credentials',
zone_filter: '*.example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 2,
key_version: 1,
success_count: 5,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockCredentialRequest: CredentialRequest = {
label: 'New Credentials',
zone_filter: '*.example.com',
credentials: { api_token: 'test-token-123' },
propagation_timeout: 120,
polling_interval: 2,
enabled: true,
}
describe('credentials API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call getCredentials with correct endpoint', async () => {
const mockData = [mockCredential, { ...mockCredential, id: 2, label: 'Secondary' }]
vi.mocked(client.get).mockResolvedValue({
data: { credentials: mockData, total: 2 },
})
const result = await getCredentials(1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1/credentials')
expect(result).toEqual(mockData)
expect(result).toHaveLength(2)
})
it('should call getCredential with correct endpoint', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockCredential })
const result = await getCredential(1, 1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1/credentials/1')
expect(result).toEqual(mockCredential)
})
it('should call createCredential with correct endpoint and data', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockCredential })
const result = await createCredential(1, mockCredentialRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/credentials', mockCredentialRequest)
expect(result).toEqual(mockCredential)
})
it('should call updateCredential with correct endpoint and data', async () => {
const updatedCredential = { ...mockCredential, label: 'Updated Label' }
vi.mocked(client.put).mockResolvedValue({ data: updatedCredential })
const result = await updateCredential(1, 1, mockCredentialRequest)
expect(client.put).toHaveBeenCalledWith('/dns-providers/1/credentials/1', mockCredentialRequest)
expect(result).toEqual(updatedCredential)
})
it('should call deleteCredential with correct endpoint', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
await deleteCredential(1, 1)
expect(client.delete).toHaveBeenCalledWith('/dns-providers/1/credentials/1')
})
it('should call testCredential with correct endpoint', async () => {
const mockTestResult: CredentialTestResult = {
success: true,
message: 'Credentials validated successfully',
propagation_time_ms: 1200,
}
vi.mocked(client.post).mockResolvedValue({ data: mockTestResult })
const result = await testCredential(1, 1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/credentials/1/test')
expect(result).toEqual(mockTestResult)
expect(result.success).toBe(true)
})
it('should call enableMultiCredentials with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: undefined })
await enableMultiCredentials(1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/enable-multi-credentials')
})
})
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as crowdsec from '../crowdsec'
import client from '../client'
vi.mock('../client')
describe('crowdsec API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('startCrowdsec', () => {
it('should call POST /admin/crowdsec/start', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.startCrowdsec()
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/start')
expect(result).toEqual(mockData)
})
})
describe('stopCrowdsec', () => {
it('should call POST /admin/crowdsec/stop', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.stopCrowdsec()
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/stop')
expect(result).toEqual(mockData)
})
})
describe('statusCrowdsec', () => {
it('should call GET /admin/crowdsec/status', async () => {
const mockData = { running: true, pid: 1234 }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.statusCrowdsec()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/status')
expect(result).toEqual(mockData)
})
})
describe('importCrowdsecConfig', () => {
it('should call POST /admin/crowdsec/import with FormData', async () => {
const mockFile = new File(['content'], 'config.tar.gz', { type: 'application/gzip' })
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.importCrowdsecConfig(mockFile)
expect(client.post).toHaveBeenCalledWith(
'/admin/crowdsec/import',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
expect(result).toEqual(mockData)
})
})
describe('exportCrowdsecConfig', () => {
it('should call GET /admin/crowdsec/export with blob responseType', async () => {
const mockBlob = new Blob(['data'], { type: 'application/gzip' })
vi.mocked(client.get).mockResolvedValue({ data: mockBlob })
const result = await crowdsec.exportCrowdsecConfig()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/export', { responseType: 'blob' })
expect(result).toEqual(mockBlob)
})
})
describe('listCrowdsecFiles', () => {
it('should call GET /admin/crowdsec/files', async () => {
const mockData = { files: ['file1.yaml', 'file2.yaml'] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.listCrowdsecFiles()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/files')
expect(result).toEqual(mockData)
})
})
describe('readCrowdsecFile', () => {
it('should call GET /admin/crowdsec/file with encoded path', async () => {
const mockData = { content: 'file content' }
const path = '/etc/crowdsec/file.yaml'
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.readCrowdsecFile(path)
expect(client.get).toHaveBeenCalledWith(
`/admin/crowdsec/file?path=${encodeURIComponent(path)}`
)
expect(result).toEqual(mockData)
})
})
describe('writeCrowdsecFile', () => {
it('should call POST /admin/crowdsec/file with path and content', async () => {
const mockData = { success: true }
const path = '/etc/crowdsec/file.yaml'
const content = 'new content'
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await crowdsec.writeCrowdsecFile(path, content)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/file', { path, content })
expect(result).toEqual(mockData)
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(crowdsec.default).toHaveProperty('startCrowdsec')
expect(crowdsec.default).toHaveProperty('stopCrowdsec')
expect(crowdsec.default).toHaveProperty('statusCrowdsec')
expect(crowdsec.default).toHaveProperty('importCrowdsecConfig')
expect(crowdsec.default).toHaveProperty('exportCrowdsecConfig')
expect(crowdsec.default).toHaveProperty('listCrowdsecFiles')
expect(crowdsec.default).toHaveProperty('readCrowdsecFile')
expect(crowdsec.default).toHaveProperty('writeCrowdsecFile')
})
})
})
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { detectDNSProvider, getDetectionPatterns } from '../dnsDetection'
import client from '../client'
import type { DetectionResult, NameserverPattern } from '../dnsDetection'
vi.mock('../client')
describe('dnsDetection API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('detectDNSProvider', () => {
it('should detect DNS provider successfully', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'cloudflare',
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com'],
confidence: 'high',
suggested_provider: {
id: 1,
uuid: 'test-uuid',
name: 'Production Cloudflare',
provider_type: 'cloudflare',
enabled: true,
is_default: true,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 5,
success_count: 10,
failure_count: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(client.post).toHaveBeenCalledWith('/dns-providers/detect', { domain: 'example.com' })
expect(result).toEqual(mockResponse)
expect(result.detected).toBe(true)
expect(result.provider_type).toBe('cloudflare')
expect(result.confidence).toBe('high')
})
it('should handle detection failure (no provider found)', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: false,
nameservers: ['ns1.unknown.com', 'ns2.unknown.com'],
confidence: 'none',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(result.detected).toBe(false)
expect(result.confidence).toBe('none')
expect(result.nameservers).toHaveLength(2)
})
it('should handle detection error', async () => {
const mockResponse: DetectionResult = {
domain: 'invalid.domain',
detected: false,
nameservers: [],
confidence: 'none',
error: 'Failed to lookup nameservers: domain not found',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('invalid.domain')
expect(result.detected).toBe(false)
expect(result.error).toContain('domain not found')
})
it('should handle network error', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(detectDNSProvider('example.com')).rejects.toThrow('Network error')
})
it('should handle medium confidence detection', async () => {
const mockResponse: DetectionResult = {
domain: 'example.com',
detected: true,
provider_type: 'route53',
nameservers: ['ns-123.awsdns-12.com'],
confidence: 'medium',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await detectDNSProvider('example.com')
expect(result.confidence).toBe('medium')
expect(result.detected).toBe(true)
})
})
describe('getDetectionPatterns', () => {
it('should fetch detection patterns successfully', async () => {
const mockPatterns: NameserverPattern[] = [
{ pattern: '.ns.cloudflare.com', provider_type: 'cloudflare' },
{ pattern: '.awsdns', provider_type: 'route53' },
{ pattern: '.digitalocean.com', provider_type: 'digitalocean' },
]
vi.mocked(client.get).mockResolvedValue({ data: { patterns: mockPatterns } })
const result = await getDetectionPatterns()
expect(client.get).toHaveBeenCalledWith('/dns-providers/patterns')
expect(result).toEqual(mockPatterns)
expect(result).toHaveLength(3)
})
it('should handle empty patterns list', async () => {
vi.mocked(client.get).mockResolvedValue({ data: { patterns: [] } })
const result = await getDetectionPatterns()
expect(result).toEqual([])
})
it('should handle network error when fetching patterns', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'))
await expect(getDetectionPatterns()).rejects.toThrow('Network error')
})
})
})
@@ -0,0 +1,431 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getDNSProviders,
getDNSProvider,
getDNSProviderTypes,
createDNSProvider,
updateDNSProvider,
deleteDNSProvider,
testDNSProvider,
testDNSProviderCredentials,
type DNSProvider,
type DNSProviderRequest,
type DNSProviderTypeInfo,
} from '../dnsProviders'
import client from '../client'
vi.mock('../client')
const mockProvider: DNSProvider = {
id: 1,
uuid: 'test-uuid-1',
name: 'Cloudflare Production',
provider_type: 'cloudflare',
enabled: true,
is_default: true,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 2,
success_count: 5,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockProviderType: DNSProviderTypeInfo = {
type: 'cloudflare',
name: 'Cloudflare',
fields: [
{
name: 'api_token',
label: 'API Token',
type: 'password',
required: true,
hint: 'Cloudflare API token with DNS edit permissions',
},
],
documentation_url: 'https://developers.cloudflare.com/api/',
}
describe('getDNSProviders', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches all DNS providers successfully', async () => {
const mockProviders = [mockProvider, { ...mockProvider, id: 2, name: 'Secondary' }]
vi.mocked(client.get).mockResolvedValue({
data: { providers: mockProviders, total: 2 },
})
const result = await getDNSProviders()
expect(client.get).toHaveBeenCalledWith('/dns-providers')
expect(result).toEqual(mockProviders)
expect(result).toHaveLength(2)
})
it('returns empty array when no providers exist', async () => {
vi.mocked(client.get).mockResolvedValue({
data: { providers: [], total: 0 },
})
const result = await getDNSProviders()
expect(result).toEqual([])
})
it('handles network errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'))
await expect(getDNSProviders()).rejects.toThrow('Network error')
})
it('handles server errors', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 500 } })
await expect(getDNSProviders()).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('getDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches single provider by valid ID', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockProvider })
const result = await getDNSProvider(1)
expect(client.get).toHaveBeenCalledWith('/dns-providers/1')
expect(result).toEqual(mockProvider)
})
it('handles not found error for invalid ID', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 404 } })
await expect(getDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles server errors', async () => {
vi.mocked(client.get).mockRejectedValue({ response: { status: 500 } })
await expect(getDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('getDNSProviderTypes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches supported provider types with field definitions', async () => {
const mockTypes = [
mockProviderType,
{
type: 'route53',
name: 'AWS Route 53',
fields: [
{ name: 'access_key_id', label: 'Access Key ID', type: 'text', required: true },
{ name: 'secret_access_key', label: 'Secret Access Key', type: 'password', required: true },
],
documentation_url: 'https://aws.amazon.com/route53/',
} as DNSProviderTypeInfo,
]
vi.mocked(client.get).mockResolvedValue({
data: { types: mockTypes },
})
const result = await getDNSProviderTypes()
expect(client.get).toHaveBeenCalledWith('/dns-providers/types')
expect(result).toEqual(mockTypes)
expect(result).toHaveLength(2)
})
it('handles errors when fetching types', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Failed to fetch types'))
await expect(getDNSProviderTypes()).rejects.toThrow('Failed to fetch types')
})
})
describe('createDNSProvider', () => {
const validRequest: DNSProviderRequest = {
name: 'New Cloudflare',
provider_type: 'cloudflare',
credentials: { api_token: 'test-token-123' },
propagation_timeout: 120,
polling_interval: 2,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('creates provider successfully and returns with ID', async () => {
const createdProvider = { ...mockProvider, id: 5, name: 'New Cloudflare' }
vi.mocked(client.post).mockResolvedValue({ data: createdProvider })
const result = await createDNSProvider(validRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers', validRequest)
expect(result).toEqual(createdProvider)
expect(result.id).toBe(5)
})
it('handles validation error for missing required fields', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Missing required field: api_token' } },
})
await expect(
createDNSProvider({ ...validRequest, credentials: {} })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles validation error for invalid provider type', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Invalid provider type' } },
})
await expect(
createDNSProvider({ ...validRequest, provider_type: 'invalid' as DNSProviderRequest['provider_type'] })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles duplicate name error', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 409, data: { error: 'Provider with this name already exists' } },
})
await expect(createDNSProvider(validRequest)).rejects.toMatchObject({
response: { status: 409 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(createDNSProvider(validRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('updateDNSProvider', () => {
const updateRequest: DNSProviderRequest = {
name: 'Updated Name',
provider_type: 'cloudflare',
credentials: { api_token: 'new-token' },
}
beforeEach(() => {
vi.clearAllMocks()
})
it('updates provider successfully', async () => {
const updatedProvider = { ...mockProvider, name: 'Updated Name' }
vi.mocked(client.put).mockResolvedValue({ data: updatedProvider })
const result = await updateDNSProvider(1, updateRequest)
expect(client.put).toHaveBeenCalledWith('/dns-providers/1', updateRequest)
expect(result).toEqual(updatedProvider)
expect(result.name).toBe('Updated Name')
})
it('handles not found error', async () => {
vi.mocked(client.put).mockRejectedValue({ response: { status: 404 } })
await expect(updateDNSProvider(999, updateRequest)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles validation errors', async () => {
vi.mocked(client.put).mockRejectedValue({
response: { status: 400, data: { error: 'Invalid credentials' } },
})
await expect(updateDNSProvider(1, updateRequest)).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles server errors', async () => {
vi.mocked(client.put).mockRejectedValue({ response: { status: 500 } })
await expect(updateDNSProvider(1, updateRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('deleteDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('deletes provider successfully', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
await deleteDNSProvider(1)
expect(client.delete).toHaveBeenCalledWith('/dns-providers/1')
})
it('handles not found error', async () => {
vi.mocked(client.delete).mockRejectedValue({ response: { status: 404 } })
await expect(deleteDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles in-use error when provider used by proxy hosts', async () => {
vi.mocked(client.delete).mockRejectedValue({
response: {
status: 409,
data: { error: 'Cannot delete provider in use by proxy hosts' },
},
})
await expect(deleteDNSProvider(1)).rejects.toMatchObject({
response: { status: 409 },
})
})
it('handles server errors', async () => {
vi.mocked(client.delete).mockRejectedValue({ response: { status: 500 } })
await expect(deleteDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('testDNSProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns success result with propagation time', async () => {
const successResult = {
success: true,
message: 'DNS challenge completed successfully',
propagation_time_ms: 1500,
}
vi.mocked(client.post).mockResolvedValue({ data: successResult })
const result = await testDNSProvider(1)
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/test')
expect(result).toEqual(successResult)
expect(result.success).toBe(true)
expect(result.propagation_time_ms).toBe(1500)
})
it('returns failure result with error message', async () => {
const failureResult = {
success: false,
error: 'Invalid API token',
code: 'AUTH_FAILED',
}
vi.mocked(client.post).mockResolvedValue({ data: failureResult })
const result = await testDNSProvider(1)
expect(result).toEqual(failureResult)
expect(result.success).toBe(false)
expect(result.error).toBe('Invalid API token')
})
it('handles not found error', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 404 } })
await expect(testDNSProvider(999)).rejects.toMatchObject({
response: { status: 404 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(testDNSProvider(1)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
describe('testDNSProviderCredentials', () => {
const testRequest: DNSProviderRequest = {
name: 'Test Provider',
provider_type: 'cloudflare',
credentials: { api_token: 'test-token' },
}
beforeEach(() => {
vi.clearAllMocks()
})
it('returns success for valid credentials', async () => {
const successResult = {
success: true,
message: 'Credentials validated successfully',
propagation_time_ms: 800,
}
vi.mocked(client.post).mockResolvedValue({ data: successResult })
const result = await testDNSProviderCredentials(testRequest)
expect(client.post).toHaveBeenCalledWith('/dns-providers/test', testRequest)
expect(result).toEqual(successResult)
expect(result.success).toBe(true)
})
it('returns failure for invalid credentials', async () => {
const failureResult = {
success: false,
error: 'Authentication failed',
code: 'INVALID_CREDENTIALS',
}
vi.mocked(client.post).mockResolvedValue({ data: failureResult })
const result = await testDNSProviderCredentials(testRequest)
expect(result).toEqual(failureResult)
expect(result.success).toBe(false)
})
it('handles validation errors for missing credentials', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { status: 400, data: { error: 'Missing required field: api_token' } },
})
await expect(
testDNSProviderCredentials({ ...testRequest, credentials: {} })
).rejects.toMatchObject({
response: { status: 400 },
})
})
it('handles server errors', async () => {
vi.mocked(client.post).mockRejectedValue({ response: { status: 500 } })
await expect(testDNSProviderCredentials(testRequest)).rejects.toMatchObject({
response: { status: 500 },
})
})
})
+96
View File
@@ -0,0 +1,96 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { dockerApi } from '../docker';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('dockerApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('listContainers', () => {
const mockContainers = [
{
id: 'abc123',
names: ['/container1'],
image: 'nginx:latest',
state: 'running',
status: 'Up 2 hours',
network: 'bridge',
ip: '172.17.0.2',
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }],
},
{
id: 'def456',
names: ['/container2'],
image: 'redis:alpine',
state: 'running',
status: 'Up 1 hour',
network: 'bridge',
ip: '172.17.0.3',
ports: [],
},
];
it('fetches containers without parameters', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers();
expect(client.get).toHaveBeenCalledWith('/docker/containers', { params: {} });
expect(result).toEqual(mockContainers);
});
it('fetches containers with host parameter', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers('192.168.1.100');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { host: '192.168.1.100' },
});
expect(result).toEqual(mockContainers);
});
it('fetches containers with serverId parameter', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers(undefined, 'server-uuid-123');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { server_id: 'server-uuid-123' },
});
expect(result).toEqual(mockContainers);
});
it('fetches containers with both host and serverId parameters', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockContainers });
const result = await dockerApi.listContainers('192.168.1.100', 'server-uuid-123');
expect(client.get).toHaveBeenCalledWith('/docker/containers', {
params: { host: '192.168.1.100', server_id: 'server-uuid-123' },
});
expect(result).toEqual(mockContainers);
});
it('returns empty array when no containers', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [] });
const result = await dockerApi.listContainers();
expect(result).toEqual([]);
});
it('handles API error', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'));
await expect(dockerApi.listContainers()).rejects.toThrow('Network error');
});
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import { getDomains, createDomain, deleteDomain, Domain } from '../domains';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockDomain: Domain = {
id: 1,
uuid: '123',
name: 'example.com',
created_at: '2023-01-01',
};
it('getDomains calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockDomain] });
const result = await getDomains();
expect(client.get).toHaveBeenCalledWith('/domains');
expect(result).toEqual([mockDomain]);
});
it('createDomain calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockDomain });
const result = await createDomain('example.com');
expect(client.post).toHaveBeenCalledWith('/domains', { name: 'example.com' });
expect(result).toEqual(mockDomain);
});
it('deleteDomain calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteDomain('123');
expect(client.delete).toHaveBeenCalledWith('/domains/123');
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getEncryptionStatus,
rotateEncryptionKey,
getRotationHistory,
validateKeyConfiguration,
type RotationStatus,
type RotationResult,
type RotationHistoryEntry,
type KeyValidationResult,
} from '../encryption'
import client from '../client'
vi.mock('../client')
const mockRotationStatus: RotationStatus = {
current_version: 2,
next_key_configured: true,
legacy_key_count: 1,
providers_on_current_version: 5,
providers_on_older_versions: 0,
}
const mockRotationResult: RotationResult = {
total_providers: 5,
success_count: 5,
failure_count: 0,
duration: '2.5s',
new_key_version: 3,
}
const mockHistoryEntry: RotationHistoryEntry = {
id: 1,
uuid: 'test-uuid-1',
actor: 'admin@example.com',
action: 'encryption_key_rotated',
event_category: 'security',
details: 'Rotated from version 1 to version 2',
created_at: '2025-01-01T00:00:00Z',
}
const mockValidationResult: KeyValidationResult = {
valid: true,
message: 'Key configuration is valid',
}
describe('encryption API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call getEncryptionStatus with correct endpoint', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockRotationStatus })
const result = await getEncryptionStatus()
expect(client.get).toHaveBeenCalledWith('/admin/encryption/status')
expect(result).toEqual(mockRotationStatus)
expect(result.current_version).toBe(2)
})
it('should call rotateEncryptionKey with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockRotationResult })
const result = await rotateEncryptionKey()
expect(client.post).toHaveBeenCalledWith('/admin/encryption/rotate')
expect(result).toEqual(mockRotationResult)
expect(result.new_key_version).toBe(3)
expect(result.success_count).toBe(5)
})
it('should call getRotationHistory with correct endpoint', async () => {
const mockHistory = [mockHistoryEntry, { ...mockHistoryEntry, id: 2 }]
vi.mocked(client.get).mockResolvedValue({
data: { history: mockHistory, total: 2 },
})
const result = await getRotationHistory()
expect(client.get).toHaveBeenCalledWith('/admin/encryption/history')
expect(result).toEqual(mockHistory)
expect(result).toHaveLength(2)
})
it('should call validateKeyConfiguration with correct endpoint', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockValidationResult })
const result = await validateKeyConfiguration()
expect(client.post).toHaveBeenCalledWith('/admin/encryption/validate')
expect(result).toEqual(mockValidationResult)
expect(result.valid).toBe(true)
})
})
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadCaddyfile, uploadCaddyfilesMulti, getImportPreview, commitImport, cancelImport, getImportStatus } from '../import';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('import API', () => {
const mockedGet = vi.mocked(client.get);
const mockedPost = vi.mocked(client.post);
const mockedDelete = vi.mocked(client.delete);
beforeEach(() => {
vi.clearAllMocks();
});
it('uploadCaddyfile posts content', async () => {
const content = 'example.com';
const mockResponse = { preview: { hosts: [] } };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfile(content);
expect(client.post).toHaveBeenCalledWith('/import/upload', { content });
expect(result).toEqual(mockResponse);
});
it('uploadCaddyfilesMulti posts files', async () => {
const files = [{ filename: 'Caddyfile', content: 'foo.com' }];
const mockResponse = { preview: { hosts: [] } };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfilesMulti(files);
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files });
expect(result).toEqual(mockResponse);
});
it('uploadCaddyfilesMulti accepts empty file arrays', async () => {
mockedPost.mockResolvedValue({ data: { preview: { hosts: [], conflicts: [], errors: [] } } });
const result = await uploadCaddyfilesMulti([]);
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files: [] });
expect(result).toEqual({ preview: { hosts: [], conflicts: [], errors: [] } });
});
it('getImportPreview gets preview', async () => {
const mockResponse = { preview: { hosts: [] } };
mockedGet.mockResolvedValue({ data: mockResponse });
const result = await getImportPreview();
expect(client.get).toHaveBeenCalledWith('/import/preview');
expect(result).toEqual(mockResponse);
});
it('commitImport posts commitments', async () => {
const sessionUUID = 'uuid-123';
const resolutions = { 'foo.com': 'keep' };
const names = { 'foo.com': 'My Site' };
const mockResponse = { created: 1, updated: 0, skipped: 0, errors: [] };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/commit', {
session_uuid: sessionUUID,
resolutions,
names
});
expect(result).toEqual(mockResponse);
});
it('cancelImport deletes cancel with required session_uuid query', async () => {
const sessionUUID = 'uuid-cancel-123';
mockedDelete.mockResolvedValue({});
await cancelImport(sessionUUID);
expect(client.delete).toHaveBeenCalledTimes(1);
expect(client.delete).toHaveBeenCalledWith('/import/cancel', {
params: {
session_uuid: sessionUUID,
},
});
const [, requestConfig] = mockedDelete.mock.calls[0];
expect(requestConfig).toEqual({
params: {
session_uuid: sessionUUID,
},
});
});
it('forwards commitImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitImport('uuid-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelImport errors', async () => {
const error = new Error('cancel failed');
mockedDelete.mockRejectedValue(error);
await expect(cancelImport('uuid-cancel-123')).rejects.toBe(error);
});
it('getImportStatus gets status', async () => {
const mockResponse = { has_pending: true };
mockedGet.mockResolvedValue({ data: mockResponse });
const result = await getImportStatus();
expect(client.get).toHaveBeenCalledWith('/import/status');
expect(result).toEqual(mockResponse);
});
it('getImportStatus handles error', async () => {
mockedGet.mockRejectedValue(new Error('Failed'));
const result = await getImportStatus();
expect(result).toEqual({ has_pending: false });
});
it('getImportStatus returns false on non-Error rejections', async () => {
mockedGet.mockRejectedValue('network down');
const result = await getImportStatus();
expect(result).toEqual({ has_pending: false });
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport';
import client from '../client';
vi.mock('../client', () => ({
default: {
post: vi.fn(),
},
}));
describe('jsonImport API', () => {
const mockedPost = vi.mocked(client.post);
beforeEach(() => {
vi.clearAllMocks();
});
it('cancelJSONImport posts cancel endpoint with required session_uuid body', async () => {
const sessionUUID = 'json-session-123';
mockedPost.mockResolvedValue({});
await cancelJSONImport(sessionUUID);
expect(client.post).toHaveBeenCalledWith('/import/json/cancel', {
session_uuid: sessionUUID,
});
});
it('uploadJSONExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'json-session-456',
state: 'reviewing',
source: 'json',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadJSONExport(content);
expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'json-session-789';
const resolutions = { 'json.example.com': 'replace' };
const names = { 'json.example.com': 'JSON Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitJSONImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/json/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadJSONExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitJSONImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelJSONImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);
await expect(cancelJSONImport('json-session-123')).rejects.toBe(error);
});
});
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { connectLiveLogs } from '../logs';
// Mock WebSocket
class MockWebSocket {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((error: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
readyState: number = WebSocket.CONNECTING;
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url: string) {
this.url = url;
// Simulate connection opening
setTimeout(() => {
this.readyState = WebSocket.OPEN;
}, 0);
}
close() {
this.readyState = WebSocket.CLOSING;
setTimeout(() => {
this.readyState = WebSocket.CLOSED;
const closeEvent = { code: 1000, reason: '', wasClean: true } as CloseEvent;
if (this.onclose) {
this.onclose(closeEvent);
}
}, 0);
}
simulateMessage(data: string) {
if (this.onmessage) {
const event = new MessageEvent('message', { data });
this.onmessage(event);
}
}
simulateError() {
if (this.onerror) {
const event = new Event('error');
this.onerror(event);
}
}
}
describe('logs API - connectLiveLogs', () => {
let mockWebSocket: MockWebSocket;
beforeEach(() => {
// Mock global WebSocket
mockWebSocket = new MockWebSocket('');
(globalThis as typeof globalThis & { WebSocket: typeof WebSocket }).WebSocket = class MockedWebSocket extends MockWebSocket {
constructor(url: string) {
super(url);
// eslint-disable-next-line @typescript-eslint/no-this-alias
mockWebSocket = this;
}
} as unknown as typeof WebSocket;
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
protocol: 'http:',
host: 'localhost:8080',
},
writable: true,
});
});
it('creates WebSocket connection with correct URL', () => {
connectLiveLogs({}, vi.fn());
expect(mockWebSocket.url).toBe('ws://localhost:8080/api/v1/logs/live?');
});
it('uses wss protocol when page is https', () => {
Object.defineProperty(window, 'location', {
value: {
protocol: 'https:',
host: 'example.com',
},
writable: true,
});
connectLiveLogs({}, vi.fn());
expect(mockWebSocket.url).toBe('wss://example.com/api/v1/logs/live?');
});
it('includes filters in query parameters', () => {
connectLiveLogs({ level: 'error', source: 'waf' }, vi.fn());
expect(mockWebSocket.url).toContain('level=error');
expect(mockWebSocket.url).toContain('source=waf');
});
it('calls onMessage callback when message is received', () => {
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
const logData = {
level: 'info',
timestamp: '2025-12-09T10:30:00Z',
message: 'Test message',
};
mockWebSocket.simulateMessage(JSON.stringify(logData));
expect(mockOnMessage).toHaveBeenCalledWith(logData);
});
it('handles JSON parse errors gracefully', () => {
const mockOnMessage = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
connectLiveLogs({}, mockOnMessage);
mockWebSocket.simulateMessage('invalid json');
expect(mockOnMessage).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to parse log message:', expect.any(Error));
consoleErrorSpy.mockRestore();
});
// These tests are skipped because the WebSocket mock has timing issues with event handlers
// The functionality is covered by E2E tests
it.skip('calls onError callback when error occurs', async () => {
const mockOnError = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
connectLiveLogs({}, vi.fn(), mockOnError);
// Wait for handlers to be set up
await new Promise(resolve => setTimeout(resolve, 10));
mockWebSocket.simulateError();
expect(mockOnError).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith('WebSocket error:', expect.any(Event));
consoleErrorSpy.mockRestore();
});
it.skip('calls onClose callback when connection closes', async () => {
const mockOnClose = vi.fn();
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
connectLiveLogs({}, vi.fn(), undefined, mockOnClose);
// Wait for handlers to be set up
await new Promise(resolve => setTimeout(resolve, 10));
mockWebSocket.close();
// Wait for the close event to be processed
await new Promise(resolve => setTimeout(resolve, 20));
expect(mockOnClose).toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
it('returns a close function that closes the WebSocket', async () => {
const closeConnection = connectLiveLogs({}, vi.fn());
// Wait for connection to open
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
closeConnection();
expect(mockWebSocket.readyState).toBeGreaterThanOrEqual(WebSocket.CLOSING);
});
it('does not throw when closing already closed connection', () => {
const closeConnection = connectLiveLogs({}, vi.fn());
mockWebSocket.readyState = WebSocket.CLOSED;
expect(() => closeConnection()).not.toThrow();
});
it('handles missing optional callbacks', () => {
// Should not throw with only required onMessage callback
expect(() => connectLiveLogs({}, vi.fn())).not.toThrow();
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
// Simulate various events
mockWebSocket.simulateMessage(JSON.stringify({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'test' }));
mockWebSocket.simulateError();
expect(mockOnMessage).toHaveBeenCalled();
});
it('processes multiple messages in sequence', () => {
const mockOnMessage = vi.fn();
connectLiveLogs({}, mockOnMessage);
const log1 = { level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Message 1' };
const log2 = { level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Message 2' };
mockWebSocket.simulateMessage(JSON.stringify(log1));
mockWebSocket.simulateMessage(JSON.stringify(log2));
expect(mockOnMessage).toHaveBeenCalledTimes(2);
expect(mockOnMessage).toHaveBeenNthCalledWith(1, log1);
expect(mockOnMessage).toHaveBeenNthCalledWith(2, log2);
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import { downloadLog, getLogContent, getLogs } from '../logs'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}))
describe('logs api http helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'location', {
value: { href: 'http://localhost' },
writable: true,
})
})
it('fetches log list and content with filters', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ name: 'access.log', size: 10, mod_time: 'now' }] })
const logs = await getLogs()
expect(logs[0].name).toBe('access.log')
expect(client.get).toHaveBeenCalledWith('/logs')
vi.mocked(client.get).mockResolvedValueOnce({ data: { filename: 'access.log', logs: [], total: 0, limit: 100, offset: 0 } })
const resp = await getLogContent('access.log', {
search: 'bot',
host: 'example.com',
status: '500',
level: 'error',
limit: 50,
offset: 5,
sort: 'asc',
})
expect(resp.filename).toBe('access.log')
expect(client.get).toHaveBeenCalledWith('/logs/access.log?search=bot&host=example.com&status=500&level=error&limit=50&offset=5&sort=asc')
})
it('downloads log via window location', () => {
downloadLog('access.log')
expect(window.location.href).toBe('/api/v1/logs/access.log/download')
})
})
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getChallenge,
createChallenge,
verifyChallenge,
pollChallenge,
deleteChallenge,
} from '../manualChallenge'
import client from '../client'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
describe('manualChallenge API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getChallenge', () => {
it('fetches challenge by provider and challenge ID', async () => {
const mockChallenge = {
id: 'challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'test-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockChallenge })
const result = await getChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
expect(result).toEqual(mockChallenge)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.get).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(getChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
})
describe('createChallenge', () => {
it('creates a new challenge for the provider', async () => {
const mockChallenge = {
id: 'new-challenge-uuid',
status: 'created',
fqdn: '_acme-challenge.example.com',
value: 'generated-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockChallenge })
const result = await createChallenge(1, { domain: 'example.com' })
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/manual-challenge', {
domain: 'example.com',
})
expect(result).toEqual(mockChallenge)
})
it('throws error when provider not found', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Provider not found' } },
})
await expect(createChallenge(999, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when challenge already in progress', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 409, data: { code: 'CHALLENGE_IN_PROGRESS' } },
})
await expect(createChallenge(1, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 409 },
})
})
})
describe('verifyChallenge', () => {
it('triggers verification for a challenge', async () => {
const mockResult = {
success: true,
dns_found: true,
message: 'TXT record verified successfully',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(client.post).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/verify'
)
expect(result).toEqual(mockResult)
})
it('returns dns_found false when record not propagated', async () => {
const mockResult = {
success: false,
dns_found: false,
message: 'DNS record not found',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(result.success).toBe(false)
expect(result.dns_found).toBe(false)
})
it('throws error when challenge expired', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 410, data: { code: 'CHALLENGE_EXPIRED' } },
})
await expect(verifyChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 410 },
})
})
})
describe('pollChallenge', () => {
it('returns current challenge status', async () => {
const mockPoll = {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: '2026-01-11T00:02:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/poll'
)
expect(result).toEqual(mockPoll)
})
it('returns verified status when DNS propagated', async () => {
const mockPoll = {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('verified')
expect(result.dns_propagated).toBe(true)
})
it('includes error message when challenge failed', async () => {
const mockPoll = {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
error_message: 'ACME validation failed',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('failed')
expect(result.error_message).toBe('ACME validation failed')
})
})
describe('deleteChallenge', () => {
it('deletes/cancels a challenge', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined })
await deleteChallenge(1, 'challenge-uuid')
expect(client.delete).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(deleteChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when unauthorized', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 403, data: { error: 'Unauthorized' } },
})
await expect(deleteChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 403 },
})
})
})
})
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import {
getProviders,
createProvider,
updateProvider,
deleteProvider,
testProvider,
getTemplates,
previewProvider,
getExternalTemplates,
createExternalTemplate,
updateExternalTemplate,
deleteExternalTemplate,
previewExternalTemplate,
getSecurityNotificationSettings,
updateSecurityNotificationSettings,
} from '../notifications'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
describe('notifications api', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('crud for providers uses correct endpoints', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [{ id: '1', name: 'discord', type: 'discord', url: 'http://', enabled: true } as never] })
vi.mocked(client.post).mockResolvedValue({ data: { id: '2' } })
vi.mocked(client.put).mockResolvedValue({ data: { id: '2', name: 'updated' } })
const providers = await getProviders()
expect(providers[0].id).toBe('1')
expect(client.get).toHaveBeenCalledWith('/notifications/providers')
await createProvider({ name: 'x', type: 'discord' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers', { name: 'x', type: 'discord' })
await updateProvider('2', { name: 'updated', type: 'discord' })
expect(client.put).toHaveBeenCalledWith('/notifications/providers/2', { name: 'updated', type: 'discord' })
await deleteProvider('2')
expect(client.delete).toHaveBeenCalledWith('/notifications/providers/2')
await testProvider({ id: '2', name: 'test', type: 'discord' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'discord' })
await expect(createProvider({ name: 'x', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
await expect(updateProvider('2', { name: 'updated', type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
await expect(testProvider({ id: '2', name: 'test', type: 'telegram' })).rejects.toThrow('Unsupported notification provider type: telegram')
})
it('templates and previews use merged payloads', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 't1', name: 'default' }] })
const templates = await getTemplates()
expect(templates[0].name).toBe('default')
expect(client.get).toHaveBeenCalledWith('/notifications/templates')
vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'ok' } })
const preview = await previewProvider({ name: 'provider', type: 'discord' }, { user: 'alice' })
expect(preview).toEqual({ preview: 'ok' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'discord', data: { user: 'alice' } })
vi.mocked(client.post).mockResolvedValueOnce({ data: { preview: 'webhook-ok' } })
const webhookPreview = await previewProvider({ name: 'provider', type: 'webhook' }, { user: 'alice' })
expect(webhookPreview).toEqual({ preview: 'webhook-ok' })
expect(client.post).toHaveBeenCalledWith('/notifications/providers/preview', { name: 'provider', type: 'webhook', data: { user: 'alice' } })
})
it('external template endpoints shape payloads', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 'ext', name: 'External' }] })
const external = await getExternalTemplates()
expect(external[0].id).toBe('ext')
expect(client.get).toHaveBeenCalledWith('/notifications/external-templates')
vi.mocked(client.post).mockResolvedValueOnce({ data: { id: 'ext2' } })
await createExternalTemplate({ name: 'n' })
expect(client.post).toHaveBeenCalledWith('/notifications/external-templates', { name: 'n' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { id: 'ext', name: 'updated' } })
await updateExternalTemplate('ext', { name: 'updated' })
expect(client.put).toHaveBeenCalledWith('/notifications/external-templates/ext', { name: 'updated' })
await deleteExternalTemplate('ext')
expect(client.delete).toHaveBeenCalledWith('/notifications/external-templates/ext')
vi.mocked(client.post).mockResolvedValueOnce({ data: { id: 'ext2' } })
const result = await previewExternalTemplate('ext', 'tpl', { id: 1 })
expect(result).toEqual({ id: 'ext2' })
expect(client.post).toHaveBeenCalledWith('/notifications/external-templates/preview', { template_id: 'ext', template: 'tpl', data: { id: 1 } })
})
it('reads and updates security notification settings', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: { enabled: true, min_log_level: 'info', security_waf_enabled: true, security_acl_enabled: false, security_rate_limit_enabled: true } })
const settings = await getSecurityNotificationSettings()
expect(settings.enabled).toBe(true)
expect(client.get).toHaveBeenCalledWith('/notifications/settings/security')
vi.mocked(client.put).mockResolvedValueOnce({ data: { enabled: false } })
const updated = await updateSecurityNotificationSettings({ enabled: false })
expect(updated.enabled).toBe(false)
expect(client.put).toHaveBeenCalledWith('/notifications/settings/security', { enabled: false })
})
})
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport';
import client from '../client';
vi.mock('../client', () => ({
default: {
post: vi.fn(),
},
}));
describe('npmImport API', () => {
const mockedPost = vi.mocked(client.post);
beforeEach(() => {
vi.clearAllMocks();
});
it('cancelNPMImport posts cancel endpoint with required session_uuid body', async () => {
const sessionUUID = 'npm-session-123';
mockedPost.mockResolvedValue({});
await cancelNPMImport(sessionUUID);
expect(client.post).toHaveBeenCalledWith('/import/npm/cancel', {
session_uuid: sessionUUID,
});
});
it('uploadNPMExport posts upload endpoint with content payload', async () => {
const content = '{"proxy_hosts":[]}';
const mockResponse = {
session: {
id: 'npm-session-456',
state: 'reviewing',
source: 'npm',
},
preview: {
hosts: [],
conflicts: [],
errors: [],
},
conflict_details: {},
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadNPMExport(content);
expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content });
expect(result).toEqual(mockResponse);
});
it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
const sessionUUID = 'npm-session-789';
const resolutions = { 'npm.example.com': 'replace' };
const names = { 'npm.example.com': 'NPM Example' };
const mockResponse = {
created: 1,
updated: 1,
skipped: 0,
errors: [],
};
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitNPMImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/npm/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
expect(result).toEqual(mockResponse);
});
it('forwards uploadNPMExport errors', async () => {
const error = new Error('upload failed');
mockedPost.mockRejectedValue(error);
await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error);
});
it('forwards commitNPMImport errors', async () => {
const error = new Error('commit failed');
mockedPost.mockRejectedValue(error);
await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error);
});
it('forwards cancelNPMImport errors', async () => {
const error = new Error('cancel failed');
mockedPost.mockRejectedValue(error);
await expect(cancelNPMImport('npm-session-123')).rejects.toBe(error);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import {
getPlugins,
getPlugin,
enablePlugin,
disablePlugin,
reloadPlugins,
type PluginInfo,
} from '../plugins';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe('Plugins API', () => {
const mockPlugins: PluginInfo[] = [
{
id: 1,
uuid: 'plugin-1',
name: 'Test Plugin 1',
type: 'auth',
enabled: true,
status: 'loaded',
is_built_in: false,
created_at: '2023-01-01',
updated_at: '2023-01-01',
},
{
id: 2,
uuid: 'plugin-2',
name: 'Test Plugin 2',
type: 'notification',
enabled: false,
status: 'pending',
is_built_in: true,
created_at: '2023-01-01',
updated_at: '2023-01-01',
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('getPlugins', () => {
it('fetches all plugins successfully', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPlugins });
const result = await getPlugins();
expect(client.get).toHaveBeenCalledWith('/admin/plugins');
expect(result).toEqual(mockPlugins);
});
it('propagates error when request fails', async () => {
const error = new Error('API Error');
vi.mocked(client.get).mockRejectedValueOnce(error);
await expect(getPlugins()).rejects.toThrow(error);
});
});
describe('getPlugin', () => {
it('fetches a single plugin successfully', async () => {
const plugin = mockPlugins[0];
vi.mocked(client.get).mockResolvedValueOnce({ data: plugin });
const result = await getPlugin(1);
expect(client.get).toHaveBeenCalledWith('/admin/plugins/1');
expect(result).toEqual(plugin);
});
it('propagates error when plugin not found', async () => {
const error = new Error('Not Found');
vi.mocked(client.get).mockRejectedValueOnce(error);
await expect(getPlugin(999)).rejects.toThrow(error);
});
});
describe('enablePlugin', () => {
it('enables a plugin successfully', async () => {
const response = { message: 'Plugin enabled' };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await enablePlugin(1);
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/enable');
expect(result).toEqual(response);
});
});
describe('disablePlugin', () => {
it('disables a plugin successfully', async () => {
const response = { message: 'Plugin disabled' };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await disablePlugin(1);
expect(client.post).toHaveBeenCalledWith('/admin/plugins/1/disable');
expect(result).toEqual(response);
});
});
describe('reloadPlugins', () => {
it('reloads plugins successfully', async () => {
const response = { message: 'Plugins reloaded', count: 5 };
vi.mocked(client.post).mockResolvedValueOnce({ data: response });
const result = await reloadPlugins();
expect(client.post).toHaveBeenCalledWith('/admin/plugins/reload');
expect(result).toEqual(response);
});
});
});
+465
View File
@@ -0,0 +1,465 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as presets from '../presets'
import client from '../client'
vi.mock('../client')
describe('presets API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('listCrowdsecPresets', () => {
it('should fetch presets list with cached flags', async () => {
const mockPresets = {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation Essentials',
summary: 'Core HTTP parsers and scenarios',
source: 'hub',
tags: ['bots', 'web'],
requires_hub: true,
available: true,
cached: true,
cache_key: 'hub-bot-abc123',
etag: '"w/12345"',
retrieved_at: '2025-12-15T10:00:00Z',
},
{
slug: 'honeypot-friendly-defaults',
title: 'Honeypot Friendly Defaults',
summary: 'Lightweight defaults for honeypots',
source: 'builtin',
tags: ['low-noise'],
requires_hub: false,
available: true,
cached: false,
},
],
}
vi.mocked(client.get).mockResolvedValue({ data: mockPresets })
const result = await presets.listCrowdsecPresets()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
expect(result).toEqual(mockPresets)
expect(result.presets).toHaveLength(2)
expect(result.presets[0].cached).toBe(true)
expect(result.presets[0].cache_key).toBe('hub-bot-abc123')
expect(result.presets[1].cached).toBe(false)
})
it('should handle empty presets list', async () => {
const mockData = { presets: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.listCrowdsecPresets()
expect(result.presets).toHaveLength(0)
})
it('should handle API errors', async () => {
const error = new Error('Network error')
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.listCrowdsecPresets()).rejects.toThrow('Network error')
})
it('should handle hub API unavailability', async () => {
const error = {
response: {
status: 503,
data: { error: 'CrowdSec Hub API unavailable' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.listCrowdsecPresets()).rejects.toEqual(error)
})
})
describe('getCrowdsecPresets', () => {
it('should be an alias for listCrowdsecPresets', async () => {
const mockData = { presets: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await presets.getCrowdsecPresets()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
expect(result).toEqual(mockData)
})
})
describe('pullCrowdsecPreset', () => {
it('should pull preset and return preview with cache_key', async () => {
const mockResponse = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Bot Mitigation Config\nconfigs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'hub-bot-xyz789',
etag: '"abc123"',
retrieved_at: '2025-12-15T10:00:00Z',
source: 'hub',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', {
slug: 'bot-mitigation-essentials',
})
expect(result).toEqual(mockResponse)
expect(result.status).toBe('success')
expect(result.cache_key).toBeDefined()
expect(result.preview).toContain('configs:')
})
it('should handle invalid preset slug', async () => {
const mockResponse = {
status: 'error',
slug: 'non-existent-preset',
preview: '',
cache_key: '',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('non-existent-preset')
expect(result.status).toBe('error')
})
it('should handle hub API timeout during pull', async () => {
const error = {
response: {
status: 504,
data: { error: 'Gateway timeout while fetching from CrowdSec Hub' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.pullCrowdsecPreset('bot-mitigation-essentials')).rejects.toEqual(error)
})
it('should handle ETAG validation scenarios', async () => {
const mockResponse = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Cached content',
cache_key: 'hub-bot-cached123',
etag: '"not-modified"',
retrieved_at: '2025-12-14T09:00:00Z',
source: 'cache',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(result.source).toBe('cache')
expect(result.etag).toBe('"not-modified"')
})
it('should handle CrowdSec not running during pull', async () => {
const error = {
response: {
status: 500,
data: { error: 'CrowdSec LAPI not available' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.pullCrowdsecPreset('bot-mitigation-essentials')).rejects.toEqual(error)
})
it('should encode special characters in preset slug', async () => {
const mockResponse = {
status: 'success',
slug: 'custom/preset-with-slash',
preview: '# Custom',
cache_key: 'custom-key',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
await presets.pullCrowdsecPreset('custom/preset-with-slash')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', {
slug: 'custom/preset-with-slash',
})
})
})
describe('applyCrowdsecPreset', () => {
it('should apply preset with cache_key when available', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'hub-bot-xyz789' }
const mockResponse = {
status: 'success',
backup: '/data/charon/data/backups/preset-backup-20251215-100000.tar.gz',
reload_hint: true,
used_cscli: true,
cache_key: 'hub-bot-xyz789',
slug: 'bot-mitigation-essentials',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
expect(result).toEqual(mockResponse)
expect(result.status).toBe('success')
expect(result.backup).toBeDefined()
expect(result.reload_hint).toBe(true)
})
it('should apply preset without cache_key (fallback mode)', async () => {
const payload = { slug: 'honeypot-friendly-defaults' }
const mockResponse = {
status: 'success',
backup: '/data/charon/data/backups/preset-backup-20251215-100100.tar.gz',
reload_hint: true,
used_cscli: true,
slug: 'honeypot-friendly-defaults',
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
expect(result.status).toBe('success')
expect(result.used_cscli).toBe(true)
})
it('should handle stale cache_key gracefully', async () => {
const stalePayload = { slug: 'bot-mitigation-essentials', cache_key: 'old_key_123' }
const error = {
response: {
status: 400,
data: { error: 'Cache key mismatch or expired. Please pull the preset again.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(stalePayload)).rejects.toEqual(error)
})
it('should error when applying preset with CrowdSec stopped', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'valid-key' }
const error = {
response: {
status: 500,
data: { error: 'CrowdSec is not running. Start CrowdSec before applying presets.' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle backup creation failure', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'valid-key' }
const error = {
response: {
status: 500,
data: { error: 'Failed to create backup before applying preset' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle cscli errors during application', async () => {
const payload = { slug: 'invalid-preset' }
const error = {
response: {
status: 500,
data: { error: 'cscli hub update failed: exit status 1' },
},
}
vi.mocked(client.post).mockRejectedValue(error)
await expect(presets.applyCrowdsecPreset(payload)).rejects.toEqual(error)
})
it('should handle payload with force flag', async () => {
const payload = { slug: 'bot-mitigation-essentials', cache_key: 'key123' }
const mockResponse = {
status: 'success',
backup: '/data/backups/preset-forced.tar.gz',
reload_hint: true,
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await presets.applyCrowdsecPreset(payload)
expect(result.status).toBe('success')
})
})
describe('getCrowdsecPresetCache', () => {
it('should fetch cached preset preview', async () => {
const mockCache = {
preview: '# Cached Bot Mitigation Config\nconfigs:\n collections:\n - crowdsecurity/base-http-scenarios',
cache_key: 'hub-bot-xyz789',
etag: '"abc123"',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
const result = await presets.getCrowdsecPresetCache('bot-mitigation-essentials')
expect(client.get).toHaveBeenCalledWith(
'/admin/crowdsec/presets/cache/bot-mitigation-essentials'
)
expect(result).toEqual(mockCache)
expect(result.preview).toContain('configs:')
expect(result.cache_key).toBe('hub-bot-xyz789')
})
it('should encode special characters in slug', async () => {
const mockCache = {
preview: '# Custom',
cache_key: 'custom-key',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
await presets.getCrowdsecPresetCache('custom/preset with spaces')
expect(client.get).toHaveBeenCalledWith(
'/admin/crowdsec/presets/cache/custom%2Fpreset%20with%20spaces'
)
})
it('should handle cache miss (404)', async () => {
const error = {
response: {
status: 404,
data: { error: 'Preset not found in cache' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.getCrowdsecPresetCache('non-cached-preset')).rejects.toEqual(error)
})
it('should handle expired cache entries', async () => {
const error = {
response: {
status: 410,
data: { error: 'Cache entry expired' },
},
}
vi.mocked(client.get).mockRejectedValue(error)
await expect(presets.getCrowdsecPresetCache('expired-preset')).rejects.toEqual(error)
})
it('should handle empty preview content', async () => {
const mockCache = {
preview: '',
cache_key: 'empty-key',
}
vi.mocked(client.get).mockResolvedValue({ data: mockCache })
const result = await presets.getCrowdsecPresetCache('empty-preset')
expect(result.preview).toBe('')
expect(result.cache_key).toBe('empty-key')
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(presets.default).toHaveProperty('listCrowdsecPresets')
expect(presets.default).toHaveProperty('getCrowdsecPresets')
expect(presets.default).toHaveProperty('pullCrowdsecPreset')
expect(presets.default).toHaveProperty('applyCrowdsecPreset')
expect(presets.default).toHaveProperty('getCrowdsecPresetCache')
})
})
describe('integration scenarios', () => {
it('should handle full workflow: list → pull → cache → apply', async () => {
// 1. List presets
const mockList = {
presets: [
{
slug: 'bot-mitigation-essentials',
title: 'Bot Mitigation',
summary: 'Core',
source: 'hub',
requires_hub: true,
available: true,
cached: false,
},
],
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockList })
const listResult = await presets.listCrowdsecPresets()
expect(listResult.presets[0].cached).toBe(false)
// 2. Pull preset
const mockPull = {
status: 'success',
slug: 'bot-mitigation-essentials',
preview: '# Config',
cache_key: 'hub-bot-new123',
etag: '"etag1"',
retrieved_at: '2025-12-15T10:00:00Z',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPull })
const pullResult = await presets.pullCrowdsecPreset('bot-mitigation-essentials')
expect(pullResult.cache_key).toBe('hub-bot-new123')
// 3. Verify cache
const mockCache = {
preview: '# Config',
cache_key: 'hub-bot-new123',
etag: '"etag1"',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockCache })
const cacheResult = await presets.getCrowdsecPresetCache('bot-mitigation-essentials')
expect(cacheResult.cache_key).toBe(pullResult.cache_key)
// 4. Apply preset
const mockApply = {
status: 'success',
backup: '/data/backups/preset-backup.tar.gz',
reload_hint: true,
cache_key: 'hub-bot-new123',
slug: 'bot-mitigation-essentials',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockApply })
const applyResult = await presets.applyCrowdsecPreset({
slug: 'bot-mitigation-essentials',
cache_key: pullResult.cache_key,
})
expect(applyResult.status).toBe('success')
expect(applyResult.backup).toBeDefined()
})
it('should handle network failure mid-workflow', async () => {
// Pull succeeds
const mockPull = {
status: 'success',
slug: 'test-preset',
preview: '# Test',
cache_key: 'test-key',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockPull })
const pullResult = await presets.pullCrowdsecPreset('test-preset')
expect(pullResult.cache_key).toBe('test-key')
// Apply fails due to network
const networkError = new Error('Network error')
vi.mocked(client.post).mockRejectedValueOnce(networkError)
await expect(
presets.applyCrowdsecPreset({ slug: 'test-preset', cache_key: 'test-key' })
).rejects.toThrow('Network error')
})
})
})
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { bulkUpdateACL } from '../proxyHosts';
import type { BulkUpdateACLResponse } from '../proxyHosts';
// Mock the client module
const mockPut = vi.fn();
vi.mock('../client', () => ({
default: {
put: (...args: unknown[]) => mockPut(...args),
},
}));
describe('proxyHosts bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('bulkUpdateACL', () => {
it('should apply ACL to multiple hosts', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 3,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2', 'uuid-3'];
const accessListID = 42;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
expect(result).toEqual(mockResponse);
});
it('should remove ACL from hosts when accessListID is null', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 2,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['uuid-1', 'uuid-2'];
const result = await bulkUpdateACL(hostUUIDs, null);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: null,
});
expect(result).toEqual(mockResponse);
});
it('should handle partial failures', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 1,
errors: [
{ uuid: 'invalid-uuid', error: 'proxy host not found' },
],
};
mockPut.mockResolvedValue({ data: mockResponse });
const hostUUIDs = ['valid-uuid', 'invalid-uuid'];
const accessListID = 10;
const result = await bulkUpdateACL(hostUUIDs, accessListID);
expect(result.updated).toBe(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].uuid).toBe('invalid-uuid');
});
it('should handle empty host list', async () => {
const mockResponse: BulkUpdateACLResponse = {
updated: 0,
errors: [],
};
mockPut.mockResolvedValue({ data: mockResponse });
const result = await bulkUpdateACL([], 5);
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
host_uuids: [],
access_list_id: 5,
});
expect(result.updated).toBe(0);
});
it('should propagate API errors', async () => {
const error = new Error('Network error');
mockPut.mockRejectedValue(error);
await expect(bulkUpdateACL(['uuid-1'], 1)).rejects.toThrow('Network error');
});
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import client from '../client';
import {
getProxyHosts,
getProxyHost,
createProxyHost,
updateProxyHost,
deleteProxyHost,
testProxyHostConnection,
ProxyHost
} from '../proxyHosts';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('proxyHosts API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockHost: ProxyHost = {
uuid: '123',
name: 'Example Host',
domain_names: 'example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
created_at: '2023-01-01',
updated_at: '2023-01-01',
};
it('getProxyHosts calls client.get', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockHost] });
const result = await getProxyHosts();
expect(client.get).toHaveBeenCalledWith('/proxy-hosts');
expect(result).toEqual([mockHost]);
});
it('getProxyHost calls client.get with uuid', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockHost });
const result = await getProxyHost('123');
expect(client.get).toHaveBeenCalledWith('/proxy-hosts/123');
expect(result).toEqual(mockHost);
});
it('createProxyHost calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: mockHost });
const newHost = { domain_names: 'example.com' };
const result = await createProxyHost(newHost);
expect(client.post).toHaveBeenCalledWith('/proxy-hosts', newHost);
expect(result).toEqual(mockHost);
});
it('updateProxyHost calls client.put', async () => {
vi.mocked(client.put).mockResolvedValue({ data: mockHost });
const updates = { enabled: false };
const result = await updateProxyHost('123', updates);
expect(client.put).toHaveBeenCalledWith('/proxy-hosts/123', updates);
expect(result).toEqual(mockHost);
});
it('deleteProxyHost calls client.delete', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: {} });
await deleteProxyHost('123');
expect(client.delete).toHaveBeenCalledWith('/proxy-hosts/123');
});
it('testProxyHostConnection calls client.post', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} });
await testProxyHostConnection('localhost', 8080);
expect(client.post).toHaveBeenCalledWith('/proxy-hosts/test', {
forward_host: 'localhost',
forward_port: 8080,
});
});
});
@@ -0,0 +1,146 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
getRemoteServers,
getRemoteServer,
createRemoteServer,
updateRemoteServer,
deleteRemoteServer,
testRemoteServerConnection,
testCustomRemoteServerConnection,
} from '../remoteServers';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('remoteServers API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockServer = {
uuid: 'server-123',
name: 'Test Server',
provider: 'docker',
host: '192.168.1.100',
port: 2375,
username: 'admin',
enabled: true,
reachable: true,
last_check: '2024-01-01T12:00:00Z',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T12:00:00Z',
};
describe('getRemoteServers', () => {
it('fetches all servers', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockServer] });
const result = await getRemoteServers();
expect(client.get).toHaveBeenCalledWith('/remote-servers', { params: {} });
expect(result).toEqual([mockServer]);
});
it('fetches enabled servers only', async () => {
vi.mocked(client.get).mockResolvedValue({ data: [mockServer] });
const result = await getRemoteServers(true);
expect(client.get).toHaveBeenCalledWith('/remote-servers', { params: { enabled: true } });
expect(result).toEqual([mockServer]);
});
});
describe('getRemoteServer', () => {
it('fetches a single server by UUID', async () => {
vi.mocked(client.get).mockResolvedValue({ data: mockServer });
const result = await getRemoteServer('server-123');
expect(client.get).toHaveBeenCalledWith('/remote-servers/server-123');
expect(result).toEqual(mockServer);
});
});
describe('createRemoteServer', () => {
it('creates a new server', async () => {
const newServer = {
name: 'New Server',
provider: 'docker',
host: '10.0.0.1',
port: 2375,
};
vi.mocked(client.post).mockResolvedValue({ data: { ...mockServer, ...newServer } });
const result = await createRemoteServer(newServer);
expect(client.post).toHaveBeenCalledWith('/remote-servers', newServer);
expect(result.name).toBe('New Server');
});
});
describe('updateRemoteServer', () => {
it('updates an existing server', async () => {
const updates = { name: 'Updated Server', enabled: false };
vi.mocked(client.put).mockResolvedValue({ data: { ...mockServer, ...updates } });
const result = await updateRemoteServer('server-123', updates);
expect(client.put).toHaveBeenCalledWith('/remote-servers/server-123', updates);
expect(result.name).toBe('Updated Server');
expect(result.enabled).toBe(false);
});
});
describe('deleteRemoteServer', () => {
it('deletes a server', async () => {
vi.mocked(client.delete).mockResolvedValue({});
await deleteRemoteServer('server-123');
expect(client.delete).toHaveBeenCalledWith('/remote-servers/server-123');
});
});
describe('testRemoteServerConnection', () => {
it('tests connection to an existing server', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { address: '192.168.1.100:2375' } });
const result = await testRemoteServerConnection('server-123');
expect(client.post).toHaveBeenCalledWith('/remote-servers/server-123/test');
expect(result.address).toBe('192.168.1.100:2375');
});
});
describe('testCustomRemoteServerConnection', () => {
it('tests connection to a custom host and port', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { address: '10.0.0.1:2375', reachable: true },
});
const result = await testCustomRemoteServerConnection('10.0.0.1', 2375);
expect(client.post).toHaveBeenCalledWith('/remote-servers/test', { host: '10.0.0.1', port: 2375 });
expect(result.reachable).toBe(true);
});
it('handles unreachable server', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { address: '10.0.0.1:2375', reachable: false, error: 'Connection refused' },
});
const result = await testCustomRemoteServerConnection('10.0.0.1', 2375);
expect(result.reachable).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
});
+244
View File
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as security from '../security'
import client from '../client'
vi.mock('../client')
describe('security API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSecurityStatus', () => {
it('should call GET /security/status', async () => {
const mockData: security.SecurityStatus = {
cerberus: { enabled: true },
crowdsec: { mode: 'local', api_url: 'http://localhost:8080', enabled: true },
waf: { mode: 'enabled', enabled: true },
rate_limit: { mode: 'enabled', enabled: true },
acl: { enabled: true }
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getSecurityStatus()
expect(client.get).toHaveBeenCalledWith('/security/status')
expect(result).toEqual(mockData)
})
})
describe('getSecurityConfig', () => {
it('should call GET /security/config', async () => {
const mockData = { config: { admin_whitelist: '10.0.0.0/8' } }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getSecurityConfig()
expect(client.get).toHaveBeenCalledWith('/security/config')
expect(result).toEqual(mockData)
})
})
describe('updateSecurityConfig', () => {
it('should call POST /security/config with payload', async () => {
const payload: security.SecurityConfigPayload = {
name: 'test',
enabled: true,
admin_whitelist: '10.0.0.0/8'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.updateSecurityConfig(payload)
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
expect(result).toEqual(mockData)
})
it('should handle all payload fields', async () => {
const payload: security.SecurityConfigPayload = {
name: 'test',
enabled: true,
admin_whitelist: '10.0.0.0/8',
crowdsec_mode: 'local',
crowdsec_api_url: 'http://localhost:8080',
waf_mode: 'enabled',
waf_rules_source: 'coreruleset',
waf_learning: true,
rate_limit_enable: true,
rate_limit_burst: 10,
rate_limit_requests: 100,
rate_limit_window_sec: 60
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.updateSecurityConfig(payload)
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
expect(result).toEqual(mockData)
})
})
describe('generateBreakGlassToken', () => {
it('should call POST /security/breakglass/generate', async () => {
const mockData = { token: 'abc123' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.generateBreakGlassToken()
expect(client.post).toHaveBeenCalledWith('/security/breakglass/generate')
expect(result).toEqual(mockData)
})
})
describe('enableCerberus', () => {
it('should call POST /security/enable with payload', async () => {
const payload = { mode: 'full' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.enableCerberus(payload)
expect(client.post).toHaveBeenCalledWith('/security/enable', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/enable with empty object when no payload', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.enableCerberus()
expect(client.post).toHaveBeenCalledWith('/security/enable', {})
expect(result).toEqual(mockData)
})
})
describe('disableCerberus', () => {
it('should call POST /security/disable with payload', async () => {
const payload = { reason: 'maintenance' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.disableCerberus(payload)
expect(client.post).toHaveBeenCalledWith('/security/disable', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/disable with empty object when no payload', async () => {
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.disableCerberus()
expect(client.post).toHaveBeenCalledWith('/security/disable', {})
expect(result).toEqual(mockData)
})
})
describe('getDecisions', () => {
it('should call GET /security/decisions with default limit', async () => {
const mockData = { decisions: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getDecisions()
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=50')
expect(result).toEqual(mockData)
})
it('should call GET /security/decisions with custom limit', async () => {
const mockData = { decisions: [] }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getDecisions(100)
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=100')
expect(result).toEqual(mockData)
})
})
describe('createDecision', () => {
it('should call POST /security/decisions with payload', async () => {
const payload = { value: '1.2.3.4', duration: '4h', type: 'ban' }
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.createDecision(payload)
expect(client.post).toHaveBeenCalledWith('/security/decisions', payload)
expect(result).toEqual(mockData)
})
})
describe('getRuleSets', () => {
it('should call GET /security/rulesets', async () => {
const mockData: security.RuleSetsResponse = {
rulesets: [
{
id: 1,
uuid: 'abc-123',
name: 'OWASP CRS',
source_url: 'https://example.com/rules',
mode: 'blocking',
last_updated: '2025-12-04T00:00:00Z',
content: 'rule content'
}
]
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await security.getRuleSets()
expect(client.get).toHaveBeenCalledWith('/security/rulesets')
expect(result).toEqual(mockData)
})
})
describe('upsertRuleSet', () => {
it('should call POST /security/rulesets with create payload', async () => {
const payload: security.UpsertRuleSetPayload = {
name: 'Custom Rules',
content: 'rule content',
mode: 'blocking'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.upsertRuleSet(payload)
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
expect(result).toEqual(mockData)
})
it('should call POST /security/rulesets with update payload', async () => {
const payload: security.UpsertRuleSetPayload = {
id: 1,
name: 'Updated Rules',
source_url: 'https://example.com/rules',
mode: 'detection'
}
const mockData = { success: true }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await security.upsertRuleSet(payload)
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
expect(result).toEqual(mockData)
})
})
describe('deleteRuleSet', () => {
it('should call DELETE /security/rulesets/:id', async () => {
const mockData = { success: true }
vi.mocked(client.delete).mockResolvedValue({ data: mockData })
const result = await security.deleteRuleSet(1)
expect(client.delete).toHaveBeenCalledWith('/security/rulesets/1')
expect(result).toEqual(mockData)
})
})
})
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { securityHeadersApi } from '../securityHeaders';
import client from '../client';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('securityHeadersApi', () => {
const mockedGet = vi.mocked(client.get);
const mockedPost = vi.mocked(client.post);
const mockedPut = vi.mocked(client.put);
const mockedDelete = vi.mocked(client.delete);
beforeEach(() => {
vi.clearAllMocks();
});
it('listProfiles returns profiles', async () => {
const mockProfiles = [{ id: 1, name: 'Profile 1' }];
mockedGet.mockResolvedValue({ data: { profiles: mockProfiles } });
const result = await securityHeadersApi.listProfiles();
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles');
expect(result).toEqual(mockProfiles);
});
it('getProfile returns a profile', async () => {
const mockProfile = { id: 1, name: 'Profile 1' };
mockedGet.mockResolvedValue({ data: { profile: mockProfile } });
const result = await securityHeadersApi.getProfile(1);
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/1');
expect(result).toEqual(mockProfile);
});
it('getProfile accepts UUID string identifiers', async () => {
const mockProfile = { id: 2, uuid: 'profile-uuid', name: 'Profile UUID' };
mockedGet.mockResolvedValue({ data: { profile: mockProfile } });
const result = await securityHeadersApi.getProfile('profile-uuid');
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/profile-uuid');
expect(result).toEqual(mockProfile);
});
it('createProfile creates a profile', async () => {
const newProfile = { name: 'New Profile' };
const mockResponse = { id: 1, ...newProfile };
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.createProfile(newProfile);
expect(client.post).toHaveBeenCalledWith('/security/headers/profiles', newProfile);
expect(result).toEqual(mockResponse);
});
it('updateProfile updates a profile', async () => {
const updates = { name: 'Updated Profile' };
const mockResponse = { id: 1, ...updates };
mockedPut.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.updateProfile(1, updates);
expect(client.put).toHaveBeenCalledWith('/security/headers/profiles/1', updates);
expect(result).toEqual(mockResponse);
});
it('deleteProfile deletes a profile', async () => {
mockedDelete.mockResolvedValue({});
await securityHeadersApi.deleteProfile(1);
expect(client.delete).toHaveBeenCalledWith('/security/headers/profiles/1');
});
it('forwards API errors from listProfiles', async () => {
const error = new Error('backend unavailable');
mockedGet.mockRejectedValue(error);
await expect(securityHeadersApi.listProfiles()).rejects.toBe(error);
});
it('getPresets returns presets', async () => {
const mockPresets = [{ name: 'Basic' }];
mockedGet.mockResolvedValue({ data: { presets: mockPresets } });
const result = await securityHeadersApi.getPresets();
expect(client.get).toHaveBeenCalledWith('/security/headers/presets');
expect(result).toEqual(mockPresets);
});
it('applyPreset applies a preset', async () => {
const request = { preset_type: 'basic', name: 'My Preset' };
const mockResponse = { id: 1, ...request };
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.applyPreset(request);
expect(client.post).toHaveBeenCalledWith('/security/headers/presets/apply', request);
expect(result).toEqual(mockResponse);
});
it('calculateScore calculates score', async () => {
const config = { hsts_enabled: true };
const mockResponse = { score: 90 };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.calculateScore(config);
expect(client.post).toHaveBeenCalledWith('/security/headers/score', config);
expect(result).toEqual(mockResponse);
});
it('validateCSP validates CSP', async () => {
const csp = "default-src 'self'";
const mockResponse = { valid: true, errors: [] };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.validateCSP(csp);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/validate', { csp });
expect(result).toEqual(mockResponse);
});
it('buildCSP builds CSP', async () => {
const directives = [{ directive: 'default-src', values: ["'self'"] }];
const mockResponse = { csp: "default-src 'self'" };
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.buildCSP(directives);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/build', { directives });
expect(result).toEqual(mockResponse);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as settings from '../settings'
import client from '../client'
vi.mock('../client')
describe('settings API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSettings', () => {
it('should call GET /settings', async () => {
const mockData: settings.SettingsMap = {
'ui.theme': 'dark',
'security.cerberus.enabled': 'true'
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await settings.getSettings()
expect(client.get).toHaveBeenCalledWith('/settings')
expect(result).toEqual(mockData)
})
})
describe('updateSetting', () => {
it('should call POST /settings with key and value only', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('ui.theme', 'light')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'ui.theme',
value: 'light',
category: undefined,
type: undefined
})
})
it('should call POST /settings with all parameters', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('security.cerberus.enabled', 'true', 'security', 'bool')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'security.cerberus.enabled',
value: 'true',
category: 'security',
type: 'bool'
})
})
it('should call POST /settings with category but no type', async () => {
vi.mocked(client.post).mockResolvedValue({ data: {} })
await settings.updateSetting('ui.theme', 'dark', 'ui')
expect(client.post).toHaveBeenCalledWith('/settings', {
key: 'ui.theme',
value: 'dark',
category: 'ui',
type: undefined
})
})
})
describe('validatePublicURL', () => {
it('should call POST /settings/validate-url with URL', async () => {
const mockResponse = { valid: true, normalized: 'https://example.com' }
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await settings.validatePublicURL('https://example.com')
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', { url: 'https://example.com' })
expect(result).toEqual(mockResponse)
})
it('should return valid: true for valid URL', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: true } })
const result = await settings.validatePublicURL('https://valid.com')
expect(result.valid).toBe(true)
})
it('should return valid: false for invalid URL', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: false, error: 'Invalid URL format' } })
const result = await settings.validatePublicURL('not-a-url')
expect(result.valid).toBe(false)
expect(result.error).toBe('Invalid URL format')
})
it('should return normalized URL when provided', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { valid: true, normalized: 'https://example.com/' }
})
const result = await settings.validatePublicURL('https://example.com')
expect(result.normalized).toBe('https://example.com/')
})
it('should handle validation errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(settings.validatePublicURL('https://example.com')).rejects.toThrow('Network error')
})
it('should handle empty URL parameter', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { valid: false } })
const result = await settings.validatePublicURL('')
expect(client.post).toHaveBeenCalledWith('/settings/validate-url', { url: '' })
expect(result.valid).toBe(false)
})
})
describe('testPublicURL', () => {
it('should call POST /settings/test-url with URL', async () => {
const mockResponse = { reachable: true, latency: 42 }
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await settings.testPublicURL('https://example.com')
expect(client.post).toHaveBeenCalledWith('/settings/test-url', { url: 'https://example.com' })
expect(result).toEqual(mockResponse)
})
it('should return reachable: true with latency for successful test', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: true, latency: 123, message: 'URL is reachable' }
})
const result = await settings.testPublicURL('https://example.com')
expect(result.reachable).toBe(true)
expect(result.latency).toBe(123)
expect(result.message).toBe('URL is reachable')
})
it('should return reachable: false with error for failed test', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: false, error: 'Connection timeout' }
})
const result = await settings.testPublicURL('https://unreachable.com')
expect(result.reachable).toBe(false)
expect(result.error).toBe('Connection timeout')
})
it('should return message field when provided', async () => {
vi.mocked(client.post).mockResolvedValue({
data: { reachable: true, latency: 50, message: 'Custom success message' }
})
const result = await settings.testPublicURL('https://example.com')
expect(result.message).toBe('Custom success message')
})
it('should handle request errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Request failed'))
await expect(settings.testPublicURL('https://example.com')).rejects.toThrow('Request failed')
})
it('should handle empty URL parameter', async () => {
vi.mocked(client.post).mockResolvedValue({ data: { reachable: false } })
const result = await settings.testPublicURL('')
expect(client.post).toHaveBeenCalledWith('/settings/test-url', { url: '' })
expect(result.reachable).toBe(false)
})
})
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../../api/client'
import { getSetupStatus, performSetup } from '../setup'
describe('setup api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('getSetupStatus returns status', async () => {
const data = { setupRequired: true }
vi.spyOn(client, 'get').mockResolvedValueOnce({ data })
const res = await getSetupStatus()
expect(res).toEqual(data)
})
it('performSetup posts data to setup endpoint', async () => {
const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: {} })
const payload = { name: 'Admin', email: 'admin@example.com', password: 'secret' }
await performSetup(payload)
expect(spy).toHaveBeenCalledWith('/setup', payload)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import client from '../client'
import { checkUpdates, getNotifications, markNotificationRead, markAllNotificationsRead } from '../system'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
describe('System API', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('checkUpdates calls /system/updates', async () => {
const mockData = { available: true, latest_version: '1.0.0', changelog_url: 'url' }
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await checkUpdates()
expect(client.get).toHaveBeenCalledWith('/system/updates')
expect(result).toEqual(mockData)
})
it('getNotifications calls /notifications', async () => {
const mockData = [{ id: '1', title: 'Test' }]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await getNotifications()
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: false } })
expect(result).toEqual(mockData)
})
it('getNotifications calls /notifications with unreadOnly=true', async () => {
const mockData = [{ id: '1', title: 'Test' }]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await getNotifications(true)
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: true } })
expect(result).toEqual(mockData)
})
it('markNotificationRead calls /notifications/:id/read', async () => {
vi.mocked(client.post).mockResolvedValue({})
await markNotificationRead('123')
expect(client.post).toHaveBeenCalledWith('/notifications/123/read')
})
it('markAllNotificationsRead calls /notifications/read-all', async () => {
vi.mocked(client.post).mockResolvedValue({})
await markAllNotificationsRead()
expect(client.post).toHaveBeenCalledWith('/notifications/read-all')
})
})
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as uptime from '../uptime'
import client from '../client'
import type { UptimeMonitor, UptimeHeartbeat } from '../uptime'
vi.mock('../client')
describe('uptime API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getMonitors', () => {
it('should call GET /uptime/monitors', async () => {
const mockData: UptimeMonitor[] = [
{
id: 'mon-1',
name: 'Test Monitor',
type: 'http',
url: 'https://example.com',
interval: 60,
enabled: true,
status: 'up',
latency: 100,
max_retries: 3
}
]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitors()
expect(client.get).toHaveBeenCalledWith('/uptime/monitors')
expect(result).toEqual(mockData)
})
})
describe('getMonitorHistory', () => {
it('should call GET /uptime/monitors/:id/history with default limit', async () => {
const mockData: UptimeHeartbeat[] = [
{
id: 1,
monitor_id: 'mon-1',
status: 'up',
latency: 100,
message: 'OK',
created_at: '2025-12-04T00:00:00Z'
}
]
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitorHistory('mon-1')
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=50')
expect(result).toEqual(mockData)
})
it('should call GET /uptime/monitors/:id/history with custom limit', async () => {
const mockData: UptimeHeartbeat[] = []
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await uptime.getMonitorHistory('mon-1', 100)
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=100')
expect(result).toEqual(mockData)
})
})
describe('updateMonitor', () => {
it('should call PUT /uptime/monitors/:id', async () => {
const mockMonitor: UptimeMonitor = {
id: 'mon-1',
name: 'Updated Monitor',
type: 'http',
url: 'https://example.com',
interval: 120,
enabled: false,
status: 'down',
latency: 0,
max_retries: 5
}
vi.mocked(client.put).mockResolvedValue({ data: mockMonitor })
const result = await uptime.updateMonitor('mon-1', { enabled: false, interval: 120 })
expect(client.put).toHaveBeenCalledWith('/uptime/monitors/mon-1', { enabled: false, interval: 120 })
expect(result).toEqual(mockMonitor)
})
})
describe('deleteMonitor', () => {
it('should call DELETE /uptime/monitors/:id', async () => {
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
const result = await uptime.deleteMonitor('mon-1')
expect(client.delete).toHaveBeenCalledWith('/uptime/monitors/mon-1')
expect(result).toBeUndefined()
})
})
describe('syncMonitors', () => {
it('should call POST /uptime/sync with empty body when no params', async () => {
const mockData = { synced: 5 }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.syncMonitors()
expect(client.post).toHaveBeenCalledWith('/uptime/sync', {})
expect(result).toEqual(mockData)
})
it('should call POST /uptime/sync with provided parameters', async () => {
const mockData = { synced: 5 }
const body = { interval: 120, max_retries: 5 }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.syncMonitors(body)
expect(client.post).toHaveBeenCalledWith('/uptime/sync', body)
expect(result).toEqual(mockData)
})
})
describe('checkMonitor', () => {
it('should call POST /uptime/monitors/:id/check', async () => {
const mockData = { message: 'Check initiated' }
vi.mocked(client.post).mockResolvedValue({ data: mockData })
const result = await uptime.checkMonitor('mon-1')
expect(client.post).toHaveBeenCalledWith('/uptime/monitors/mon-1/check')
expect(result).toEqual(mockData)
})
})
})
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import client from '../client'
import { getProfile, regenerateApiKey, updateProfile } from '../users'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}))
describe('user api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches profile using masked API key fields', async () => {
vi.mocked(client.get).mockResolvedValueOnce({
data: {
id: 1,
email: 'admin@example.com',
name: 'Admin',
role: 'admin',
has_api_key: true,
api_key_masked: '********',
},
})
const profile = await getProfile()
expect(client.get).toHaveBeenCalledWith('/user/profile')
expect(profile.has_api_key).toBe(true)
expect(profile.api_key_masked).toBe('********')
})
it('regenerates API key and returns metadata-only response', async () => {
vi.mocked(client.post).mockResolvedValueOnce({
data: {
message: 'API key regenerated successfully',
has_api_key: true,
api_key_masked: '********',
api_key_updated: '2026-02-25T00:00:00Z',
},
})
const result = await regenerateApiKey()
expect(client.post).toHaveBeenCalledWith('/user/api-key')
expect(result.has_api_key).toBe(true)
expect(result.api_key_masked).toBe('********')
expect(result.api_key_updated).toBe('2026-02-25T00:00:00Z')
})
it('updates profile with optional current password', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ data: { message: 'ok' } })
await updateProfile({
name: 'Updated Name',
email: 'updated@example.com',
current_password: 'current-password',
})
expect(client.post).toHaveBeenCalledWith('/user/profile', {
name: 'Updated Name',
email: 'updated@example.com',
current_password: 'current-password',
})
})
})
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from '../client'
import {
listUsers,
getUser,
createUser,
inviteUser,
updateUser,
deleteUser,
updateUserPermissions,
validateInvite,
acceptInvite,
} from '../users'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
describe('users api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lists, reads, creates, updates, and deletes users', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: [{ id: 1, email: 'a' }] })
const users = await listUsers()
expect(users[0].id).toBe(1)
expect(client.get).toHaveBeenCalledWith('/users')
vi.mocked(client.get).mockResolvedValueOnce({ data: { id: 2 } })
await getUser(2)
expect(client.get).toHaveBeenCalledWith('/users/2')
vi.mocked(client.post).mockResolvedValueOnce({ data: { id: 3 } })
await createUser({ email: 'e', name: 'n', password: 'p' })
expect(client.post).toHaveBeenCalledWith('/users', { email: 'e', name: 'n', password: 'p' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { message: 'ok' } })
await updateUser(2, { enabled: false })
expect(client.put).toHaveBeenCalledWith('/users/2', { enabled: false })
vi.mocked(client.delete).mockResolvedValueOnce({ data: { message: 'deleted' } })
await deleteUser(2)
expect(client.delete).toHaveBeenCalledWith('/users/2')
})
it('invites users and updates permissions', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token_masked: '********', invite_url: '[REDACTED]' } })
await inviteUser({ email: 'i', permission_mode: 'allow_all' })
expect(client.post).toHaveBeenCalledWith('/users/invite', { email: 'i', permission_mode: 'allow_all' })
vi.mocked(client.put).mockResolvedValueOnce({ data: { message: 'saved' } })
await updateUserPermissions(1, { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
expect(client.put).toHaveBeenCalledWith('/users/1/permissions', { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
})
it('validates and accepts invites with params', async () => {
vi.mocked(client.get).mockResolvedValueOnce({ data: { valid: true, email: 'a' } })
await validateInvite('token-1')
expect(client.get).toHaveBeenCalledWith('/invite/validate', { params: { token: 'token-1' } })
vi.mocked(client.post).mockResolvedValueOnce({ data: { message: 'accepted', email: 'a' } })
await acceptInvite({ token: 't', name: 'n', password: 'p' })
expect(client.post).toHaveBeenCalledWith('/invite/accept', { token: 't', name: 'n', password: 'p' })
})
describe('previewInviteURL', () => {
it('should call POST /users/preview-invite-url with email', async () => {
const mockResponse = {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: 'test@example.com',
warning: false,
warning_message: ''
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
expect(result).toEqual(mockResponse)
})
it('should return complete PreviewInviteURLResponse structure', async () => {
const mockResponse = {
preview_url: 'https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://charon.example.com',
is_configured: true,
email: 'user@test.com',
warning: false,
warning_message: ''
}
vi.mocked(client.post).mockResolvedValue({ data: mockResponse })
const result = await import('../users').then(m => m.previewInviteURL('user@test.com'))
expect(result.preview_url).toBeDefined()
expect(result.base_url).toBeDefined()
expect(result.is_configured).toBeDefined()
expect(result.email).toBeDefined()
expect(result.warning).toBeDefined()
expect(result.warning_message).toBeDefined()
})
it('should return preview_url with sample token', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'http://localhost:8080',
is_configured: false,
email: 'test@example.com',
warning: true,
warning_message: 'Public URL not configured'
}
})
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(result.preview_url).toContain('SAMPLE_TOKEN_PREVIEW')
})
it('should return is_configured flag', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: 'test@example.com',
warning: false,
warning_message: ''
}
})
const result = await import('../users').then(m => m.previewInviteURL('test@example.com'))
expect(result.is_configured).toBe(true)
})
it('should return warning flag when public URL not configured', async () => {
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'http://localhost:8080',
is_configured: false,
email: 'admin@test.com',
warning: true,
warning_message: 'Using default localhost URL'
}
})
const result = await import('../users').then(m => m.previewInviteURL('admin@test.com'))
expect(result.warning).toBe(true)
expect(result.warning_message).toBe('Using default localhost URL')
})
it('should return the provided email in response', async () => {
const testEmail = 'specific@email.com'
vi.mocked(client.post).mockResolvedValue({
data: {
preview_url: 'https://example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW',
base_url: 'https://example.com',
is_configured: true,
email: testEmail,
warning: false,
warning_message: ''
}
})
const result = await import('../users').then(m => m.previewInviteURL(testEmail))
expect(result.email).toBe(testEmail)
})
it('should handle request errors', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('Network error'))
await expect(
import('../users').then(m => m.previewInviteURL('test@example.com'))
).rejects.toThrow('Network error')
})
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getWebSocketConnections, getWebSocketStats } from '../websocket';
import client from '../client';
vi.mock('../client');
describe('WebSocket API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getWebSocketConnections', () => {
it('should fetch WebSocket connections', async () => {
const mockResponse = {
connections: [
{
id: 'test-conn-1',
type: 'logs',
connected_at: '2024-01-15T10:00:00Z',
last_activity_at: '2024-01-15T10:05:00Z',
remote_addr: '192.168.1.1:12345',
user_agent: 'Mozilla/5.0',
filters: 'level=error',
},
{
id: 'test-conn-2',
type: 'cerberus',
connected_at: '2024-01-15T10:02:00Z',
last_activity_at: '2024-01-15T10:06:00Z',
remote_addr: '192.168.1.2:54321',
user_agent: 'Chrome/90.0',
filters: 'source=waf',
},
],
count: 2,
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketConnections();
expect(client.get).toHaveBeenCalledWith('/websocket/connections');
expect(result).toEqual(mockResponse);
expect(result.count).toBe(2);
expect(result.connections).toHaveLength(2);
});
it('should handle empty connections', async () => {
const mockResponse = {
connections: [],
count: 0,
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketConnections();
expect(result.connections).toHaveLength(0);
expect(result.count).toBe(0);
});
it('should handle API errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Network error'));
await expect(getWebSocketConnections()).rejects.toThrow('Network error');
});
});
describe('getWebSocketStats', () => {
it('should fetch WebSocket statistics', async () => {
const mockResponse = {
total_active: 3,
logs_connections: 2,
cerberus_connections: 1,
oldest_connection: '2024-01-15T09:55:00Z',
last_updated: '2024-01-15T10:10:00Z',
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketStats();
expect(client.get).toHaveBeenCalledWith('/websocket/stats');
expect(result).toEqual(mockResponse);
expect(result.total_active).toBe(3);
expect(result.logs_connections).toBe(2);
expect(result.cerberus_connections).toBe(1);
});
it('should handle stats with no connections', async () => {
const mockResponse = {
total_active: 0,
logs_connections: 0,
cerberus_connections: 0,
last_updated: '2024-01-15T10:10:00Z',
};
vi.mocked(client.get).mockResolvedValue({ data: mockResponse });
const result = await getWebSocketStats();
expect(result.total_active).toBe(0);
expect(result.oldest_connection).toBeUndefined();
});
it('should handle API errors', async () => {
vi.mocked(client.get).mockRejectedValue(new Error('Server error'));
await expect(getWebSocketStats()).rejects.toThrow('Server error');
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import client from './client';
export interface AccessListRule {
cidr: string;
description: string;
}
export interface AccessList {
id: number;
uuid: string;
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string; // JSON string of AccessListRule[]
country_codes: string; // Comma-separated
local_network_only: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateAccessListRequest {
name: string;
description?: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules?: string;
country_codes?: string;
local_network_only?: boolean;
enabled?: boolean;
}
export interface TestIPRequest {
ip_address: string;
}
export interface TestIPResponse {
allowed: boolean;
reason: string;
}
export interface AccessListTemplate {
name: string;
description: string;
type: string;
local_network_only?: boolean;
country_codes?: string;
}
export const accessListsApi = {
/**
* Fetches all access lists.
* @returns Promise resolving to array of AccessList objects
* @throws {AxiosError} If the request fails
*/
async list(): Promise<AccessList[]> {
const response = await client.get<AccessList[]>('/access-lists');
return response.data;
},
/**
* Gets a single access list by ID.
* @param id - The access list ID
* @returns Promise resolving to the AccessList object
* @throws {AxiosError} If the request fails or access list not found
*/
async get(id: number): Promise<AccessList> {
const response = await client.get<AccessList>(`/access-lists/${id}`);
return response.data;
},
/**
* Creates a new access list.
* @param data - CreateAccessListRequest with access list configuration
* @returns Promise resolving to the created AccessList
* @throws {AxiosError} If creation fails or validation errors occur
*/
async create(data: CreateAccessListRequest): Promise<AccessList> {
const response = await client.post<AccessList>('/access-lists', data);
return response.data;
},
/**
* Updates an existing access list.
* @param id - The access list ID to update
* @param data - Partial CreateAccessListRequest with fields to update
* @returns Promise resolving to the updated AccessList
* @throws {AxiosError} If update fails or access list not found
*/
async update(id: number, data: Partial<CreateAccessListRequest>): Promise<AccessList> {
const response = await client.put<AccessList>(`/access-lists/${id}`, data);
return response.data;
},
/**
* Deletes an access list.
* @param id - The access list ID to delete
* @throws {AxiosError} If deletion fails or access list not found
*/
async delete(id: number): Promise<void> {
await client.delete(`/access-lists/${id}`);
},
/**
* Tests if an IP address would be allowed or blocked by an access list.
* @param id - The access list ID to test against
* @param ipAddress - The IP address to test
* @returns Promise resolving to TestIPResponse with allowed status and reason
* @throws {AxiosError} If test fails or access list not found
*/
async testIP(id: number, ipAddress: string): Promise<TestIPResponse> {
const response = await client.post<TestIPResponse>(`/access-lists/${id}/test`, {
ip_address: ipAddress,
});
return response.data;
},
/**
* Gets predefined access list templates.
* @returns Promise resolving to array of AccessListTemplate objects
* @throws {AxiosError} If the request fails
*/
async getTemplates(): Promise<AccessListTemplate[]> {
const response = await client.get<AccessListTemplate[]>('/access-lists/templates');
return response.data;
},
};
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
getAuditLogs,
getAuditLog,
getAuditLogsByProvider,
exportAuditLogsCSV,
type AuditLog,
type AuditLogFilters,
} from './auditLogs'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
}
describe('auditLogs api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getAuditLogs', () => {
it('fetches audit logs with default pagination', async () => {
const mockResponse = {
logs: [
{
id: 1,
uuid: 'log-1',
actor: 'admin',
action: 'user_login',
event_category: 'user',
details: 'User logged in',
ip_address: '192.168.1.1',
created_at: '2024-01-01T00:00:00Z',
},
],
total: 1,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogs()
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs?page=1&limit=50')
expect(result).toEqual(mockResponse)
expect(result.logs).toHaveLength(1)
expect(result.logs[0].uuid).toBe('log-1')
})
it('fetches audit logs with custom pagination', async () => {
const mockResponse = {
logs: [],
total: 100,
page: 3,
limit: 25,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogs(undefined, 3, 25)
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs?page=3&limit=25')
expect(result.page).toBe(3)
expect(result.limit).toBe(25)
})
it('fetches audit logs with all filters', async () => {
const filters: AuditLogFilters = {
event_category: 'dns_provider',
actor: 'admin',
action: 'dns_provider_create',
start_date: '2024-01-01',
end_date: '2024-12-31',
resource_uuid: 'resource-123',
}
const mockResponse = {
logs: [],
total: 0,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
await getAuditLogs(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs?page=1&limit=50&event_category=dns_provider&actor=admin&action=dns_provider_create&start_date=2024-01-01&end_date=2024-12-31&resource_uuid=resource-123'
)
})
it('fetches audit logs with partial filters', async () => {
const filters: AuditLogFilters = {
event_category: 'certificate',
start_date: '2024-01-01',
}
const mockResponse = {
logs: [],
total: 5,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
await getAuditLogs(filters, 1, 50)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs?page=1&limit=50&event_category=certificate&start_date=2024-01-01'
)
})
it('handles errors when fetching audit logs', async () => {
const error = new Error('Network error')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLogs()).rejects.toThrow('Network error')
})
})
describe('getAuditLog', () => {
it('fetches a single audit log by UUID', async () => {
const mockLog: AuditLog = {
id: 42,
uuid: 'log-uuid-123',
actor: 'admin',
action: 'certificate_issue',
event_category: 'certificate',
resource_id: 10,
resource_uuid: 'cert-uuid',
details: 'Certificate issued successfully',
ip_address: '10.0.0.1',
user_agent: 'Mozilla/5.0',
created_at: '2024-06-15T12:30:00Z',
}
mockedClient.get.mockResolvedValueOnce({ data: mockLog })
const result = await getAuditLog('log-uuid-123')
expect(mockedClient.get).toHaveBeenCalledWith('/audit-logs/log-uuid-123')
expect(result).toEqual(mockLog)
expect(result.uuid).toBe('log-uuid-123')
expect(result.action).toBe('certificate_issue')
})
it('handles 404 when audit log not found', async () => {
const error = new Error('Not found')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLog('nonexistent')).rejects.toThrow('Not found')
})
})
describe('getAuditLogsByProvider', () => {
it('fetches audit logs for a specific DNS provider with default pagination', async () => {
const mockResponse = {
logs: [
{
id: 5,
uuid: 'log-5',
actor: 'system',
action: 'dns_provider_update',
event_category: 'dns_provider',
resource_id: 123,
details: 'DNS provider updated',
created_at: '2024-03-15T10:00:00Z',
},
],
total: 10,
page: 1,
limit: 50,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogsByProvider(123)
expect(mockedClient.get).toHaveBeenCalledWith('/dns-providers/123/audit-logs?page=1&limit=50')
expect(result.logs).toHaveLength(1)
expect(result.logs[0].action).toBe('dns_provider_update')
})
it('fetches audit logs for a provider with custom pagination', async () => {
const mockResponse = {
logs: [],
total: 25,
page: 2,
limit: 10,
}
mockedClient.get.mockResolvedValueOnce({ data: mockResponse })
const result = await getAuditLogsByProvider(456, 2, 10)
expect(mockedClient.get).toHaveBeenCalledWith('/dns-providers/456/audit-logs?page=2&limit=10')
expect(result.page).toBe(2)
expect(result.limit).toBe(10)
})
it('handles errors when fetching provider audit logs', async () => {
const error = new Error('Provider not found')
mockedClient.get.mockRejectedValueOnce(error)
await expect(getAuditLogsByProvider(999)).rejects.toThrow('Provider not found')
})
})
describe('exportAuditLogsCSV', () => {
it('exports audit logs to CSV without filters', async () => {
const mockCSV = 'id,actor,action,created_at\n1,admin,user_login,2024-01-01'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
const result = await exportAuditLogsCSV()
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?',
{ headers: { Accept: 'text/csv' } }
)
expect(result).toBe(mockCSV)
})
it('exports audit logs to CSV with all filters', async () => {
const filters: AuditLogFilters = {
event_category: 'proxy_host',
actor: 'operator',
action: 'proxy_host_delete',
start_date: '2024-01-01',
end_date: '2024-06-30',
resource_uuid: 'host-uuid-456',
}
const mockCSV = 'id,actor,action,created_at\n'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
const result = await exportAuditLogsCSV(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?event_category=proxy_host&actor=operator&action=proxy_host_delete&start_date=2024-01-01&end_date=2024-06-30&resource_uuid=host-uuid-456',
{ headers: { Accept: 'text/csv' } }
)
expect(result).toBe(mockCSV)
})
it('exports audit logs with partial filters', async () => {
const filters: AuditLogFilters = {
action: 'settings_update',
end_date: '2024-12-31',
}
const mockCSV = 'header,data\n'
mockedClient.get.mockResolvedValueOnce({ data: mockCSV })
await exportAuditLogsCSV(filters)
expect(mockedClient.get).toHaveBeenCalledWith(
'/audit-logs/export?action=settings_update&end_date=2024-12-31',
{ headers: { Accept: 'text/csv' } }
)
})
it('handles errors when exporting audit logs', async () => {
const error = new Error('Export failed')
mockedClient.get.mockRejectedValueOnce(error)
await expect(exportAuditLogsCSV()).rejects.toThrow('Export failed')
})
})
})
+144
View File
@@ -0,0 +1,144 @@
import client from './client'
/** Audit log event category */
export type EventCategory = 'dns_provider' | 'certificate' | 'proxy_host' | 'user' | 'system'
/** Audit log action type */
export type AuditAction =
| 'dns_provider_create'
| 'dns_provider_update'
| 'dns_provider_delete'
| 'credential_test'
| 'credential_decrypt'
| 'certificate_issue'
| 'certificate_renew'
| 'proxy_host_create'
| 'proxy_host_update'
| 'proxy_host_delete'
| 'user_login'
| 'user_logout'
| 'settings_update'
/** Represents a single audit log entry */
export interface AuditLog {
id: number
uuid: string
actor: string
action: AuditAction
event_category: EventCategory
resource_id?: number
resource_uuid?: string
details: string
ip_address?: string
user_agent?: string
created_at: string
}
/** Filters for querying audit logs */
export interface AuditLogFilters {
event_category?: EventCategory
actor?: string
action?: AuditAction
start_date?: string
end_date?: string
resource_uuid?: string
}
/** Response for list endpoint */
interface ListAuditLogsResponse {
logs: AuditLog[]
total: number
page: number
limit: number
}
/**
* Fetches audit logs with pagination and filtering.
* @param filters - Optional filters to apply
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Promise resolving to paginated audit logs
* @throws {AxiosError} If the request fails
*/
export async function getAuditLogs(
filters?: AuditLogFilters,
page: number = 1,
limit: number = 50
): Promise<ListAuditLogsResponse> {
const params = new URLSearchParams()
params.append('page', page.toString())
params.append('limit', limit.toString())
if (filters) {
if (filters.event_category) params.append('event_category', filters.event_category)
if (filters.actor) params.append('actor', filters.actor)
if (filters.action) params.append('action', filters.action)
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.resource_uuid) params.append('resource_uuid', filters.resource_uuid)
}
const response = await client.get<ListAuditLogsResponse>(`/audit-logs?${params.toString()}`)
return response.data
}
/**
* Fetches a single audit log by UUID.
* @param uuid - The audit log UUID
* @returns Promise resolving to the audit log
* @throws {AxiosError} If not found or request fails
*/
export async function getAuditLog(uuid: string): Promise<AuditLog> {
const response = await client.get<AuditLog>(`/audit-logs/${uuid}`)
return response.data
}
/**
* Fetches audit logs for a specific DNS provider.
* @param providerId - The DNS provider ID
* @param page - Page number (1-indexed)
* @param limit - Number of records per page
* @returns Promise resolving to paginated audit logs
* @throws {AxiosError} If not found or request fails
*/
export async function getAuditLogsByProvider(
providerId: number,
page: number = 1,
limit: number = 50
): Promise<ListAuditLogsResponse> {
const params = new URLSearchParams()
params.append('page', page.toString())
params.append('limit', limit.toString())
const response = await client.get<ListAuditLogsResponse>(
`/dns-providers/${providerId}/audit-logs?${params.toString()}`
)
return response.data
}
/**
* Exports audit logs to CSV format.
* @param filters - Optional filters to apply
* @returns Promise resolving to CSV string
* @throws {AxiosError} If the request fails
*/
export async function exportAuditLogsCSV(filters?: AuditLogFilters): Promise<string> {
const params = new URLSearchParams()
if (filters) {
if (filters.event_category) params.append('event_category', filters.event_category)
if (filters.actor) params.append('actor', filters.actor)
if (filters.action) params.append('action', filters.action)
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.resource_uuid) params.append('resource_uuid', filters.resource_uuid)
}
const response = await client.get<string>(
`/audit-logs/export?${params.toString()}`,
{
headers: { Accept: 'text/csv' },
}
)
return response.data
}
+46
View File
@@ -0,0 +1,46 @@
import client from './client';
/** Represents a backup file stored on the server. */
export interface BackupFile {
filename: string;
size: number;
time: string;
}
/**
* Fetches all available backup files.
* @returns Promise resolving to array of BackupFile objects
* @throws {AxiosError} If the request fails
*/
export const getBackups = async (): Promise<BackupFile[]> => {
const response = await client.get<BackupFile[]>('/backups');
return response.data;
};
/**
* Creates a new backup of the current configuration.
* @returns Promise resolving to object containing the new backup filename
* @throws {AxiosError} If backup creation fails
*/
export const createBackup = async (): Promise<{ filename: string }> => {
const response = await client.post<{ filename: string }>('/backups');
return response.data;
};
/**
* Restores configuration from a backup file.
* @param filename - The name of the backup file to restore
* @throws {AxiosError} If restoration fails or file not found
*/
export const restoreBackup = async (filename: string): Promise<void> => {
await client.post(`/backups/${filename}/restore`);
};
/**
* Deletes a backup file.
* @param filename - The name of the backup file to delete
* @throws {AxiosError} If deletion fails or file not found
*/
export const deleteBackup = async (filename: string): Promise<void> => {
await client.delete(`/backups/${filename}`);
};
+53
View File
@@ -0,0 +1,53 @@
import client from './client'
/** Represents an SSL/TLS certificate. */
export interface Certificate {
id?: number
name?: string
domain: string
issuer: string
expires_at: string
status: 'valid' | 'expiring' | 'expired' | 'untrusted'
provider: string
}
/**
* Fetches all SSL certificates.
* @returns Promise resolving to array of Certificate objects
* @throws {AxiosError} If the request fails
*/
export async function getCertificates(): Promise<Certificate[]> {
const response = await client.get<Certificate[]>('/certificates')
return response.data
}
/**
* Uploads a new SSL certificate with its private key.
* @param name - Display name for the certificate
* @param certFile - The certificate file (PEM format)
* @param keyFile - The private key file (PEM format)
* @returns Promise resolving to the created Certificate
* @throws {AxiosError} If upload fails or certificate is invalid
*/
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
const formData = new FormData()
formData.append('name', name)
formData.append('certificate_file', certFile)
formData.append('key_file', keyFile)
const response = await client.post<Certificate>('/certificates', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
/**
* Deletes an SSL certificate.
* @param id - The ID of the certificate to delete
* @throws {AxiosError} If deletion fails or certificate not found
*/
export async function deleteCertificate(id: number): Promise<void> {
await client.delete(`/certificates/${id}`)
}
+71
View File
@@ -0,0 +1,71 @@
import axios from 'axios';
/**
* Pre-configured Axios instance for API communication.
* Includes base URL, credentials, and timeout settings.
*/
const client = axios.create({
baseURL: '/api/v1',
withCredentials: true, // Required for HttpOnly cookie transmission
timeout: 30000, // 30 second timeout
});
/**
* Sets or clears the Authorization header for API requests.
* @param token - JWT token to set, or null to clear authentication
*/
export const setAuthToken = (token: string | null) => {
if (token) {
client.defaults.headers.common.Authorization = `Bearer ${token}`;
} else {
delete client.defaults.headers.common.Authorization;
}
};
/**
* Callback function invoked when a 401 authentication error occurs.
* Set via setAuthErrorHandler to allow AuthContext to handle session expiry.
*/
let onAuthError: (() => void) | null = null;
/**
* Registers a callback to handle authentication errors (401 responses).
* @param handler - Function to call when authentication fails
*/
export const setAuthErrorHandler = (handler: () => void) => {
onAuthError = handler;
};
// Global response error handling
client.interceptors.response.use(
(response) => response,
(error) => {
// Extract API error message and set on error object for consistent error handling
if (error.response?.data && typeof error.response.data === 'object') {
const data = error.response.data as { error?: string; message?: string };
if (data.error) {
error.message = data.error;
} else if (data.message) {
error.message = data.message;
}
}
// Handle 401 authentication errors - triggers auth error callback for session expiry
if (error.response?.status === 401) {
console.warn('Authentication failed:', error.config?.url);
// Skip auth error handling for login/auth endpoints to avoid redirect loops
const url = error.config?.url || '';
const isAuthEndpoint =
url.includes('/auth/login') ||
url.includes('/auth/me') ||
url.includes('/auth/logout') ||
url.includes('/auth/refresh');
if (onAuthError && !isAuthEndpoint) {
onAuthError();
}
}
return Promise.reject(error);
}
);
export default client;
+57
View File
@@ -0,0 +1,57 @@
import client from './client'
/** CrowdSec Console enrollment status. */
export interface ConsoleEnrollmentStatus {
status: string
tenant?: string
agent_name?: string
last_error?: string
last_attempt_at?: string
enrolled_at?: string
last_heartbeat_at?: string
key_present: boolean
correlation_id?: string
}
/** Payload for enrolling with CrowdSec Console. */
export interface ConsoleEnrollPayload {
enrollment_key: string
tenant?: string
agent_name: string
force?: boolean
}
/**
* Gets the current CrowdSec Console enrollment status.
* @returns Promise resolving to ConsoleEnrollmentStatus
* @throws {AxiosError} If status check fails
*/
export async function getConsoleStatus(): Promise<ConsoleEnrollmentStatus> {
const resp = await client.get<ConsoleEnrollmentStatus>('/admin/crowdsec/console/status')
return resp.data
}
/**
* Enrolls the instance with CrowdSec Console.
* @param payload - Enrollment configuration including key and agent name
* @returns Promise resolving to the new enrollment status
* @throws {AxiosError} If enrollment fails
*/
export async function enrollConsole(payload: ConsoleEnrollPayload): Promise<ConsoleEnrollmentStatus> {
const resp = await client.post<ConsoleEnrollmentStatus>('/admin/crowdsec/console/enroll', payload)
return resp.data
}
/**
* Clears the current CrowdSec Console enrollment.
* @throws {AxiosError} If clearing enrollment fails
*/
export async function clearConsoleEnrollment(): Promise<void> {
await client.delete('/admin/crowdsec/console/enrollment')
}
export default {
getConsoleStatus,
enrollConsole,
clearConsoleEnrollment,
}
+148
View File
@@ -0,0 +1,148 @@
import client from './client'
/** Represents a zone-specific credential set */
export interface DNSProviderCredential {
id: number
uuid: string
dns_provider_id: number
label: string
zone_filter: string
enabled: boolean
propagation_timeout: number
polling_interval: number
key_version: number
last_used_at?: string
success_count: number
failure_count: number
last_error?: string
created_at: string
updated_at: string
}
/** Request payload for creating/updating credentials */
export interface CredentialRequest {
label: string
zone_filter: string
credentials: Record<string, string>
propagation_timeout?: number
polling_interval?: number
enabled?: boolean
}
/** Credential test result */
export interface CredentialTestResult {
success: boolean
message?: string
error?: string
propagation_time_ms?: number
}
/** Response for list endpoint */
interface ListCredentialsResponse {
credentials: DNSProviderCredential[]
total: number
}
/**
* Fetches all credentials for a DNS provider.
* @param providerId - The DNS provider ID
* @returns Promise resolving to array of credentials
* @throws {AxiosError} If the request fails
*/
export async function getCredentials(providerId: number): Promise<DNSProviderCredential[]> {
const response = await client.get<ListCredentialsResponse>(
`/dns-providers/${providerId}/credentials`
)
return response.data.credentials
}
/**
* Fetches a single credential by ID.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @returns Promise resolving to the credential
* @throws {AxiosError} If not found or request fails
*/
export async function getCredential(
providerId: number,
credentialId: number
): Promise<DNSProviderCredential> {
const response = await client.get<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials/${credentialId}`
)
return response.data
}
/**
* Creates a new credential for a DNS provider.
* @param providerId - The DNS provider ID
* @param data - Credential configuration
* @returns Promise resolving to the created credential
* @throws {AxiosError} If validation fails or request fails
*/
export async function createCredential(
providerId: number,
data: CredentialRequest
): Promise<DNSProviderCredential> {
const response = await client.post<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials`,
data
)
return response.data
}
/**
* Updates an existing credential.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @param data - Updated configuration
* @returns Promise resolving to the updated credential
* @throws {AxiosError} If not found, validation fails, or request fails
*/
export async function updateCredential(
providerId: number,
credentialId: number,
data: CredentialRequest
): Promise<DNSProviderCredential> {
const response = await client.put<DNSProviderCredential>(
`/dns-providers/${providerId}/credentials/${credentialId}`,
data
)
return response.data
}
/**
* Deletes a credential.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @throws {AxiosError} If not found or in use
*/
export async function deleteCredential(providerId: number, credentialId: number): Promise<void> {
await client.delete(`/dns-providers/${providerId}/credentials/${credentialId}`)
}
/**
* Tests a credential's connectivity.
* @param providerId - The DNS provider ID
* @param credentialId - The credential ID
* @returns Promise resolving to test result
* @throws {AxiosError} If not found or request fails
*/
export async function testCredential(
providerId: number,
credentialId: number
): Promise<CredentialTestResult> {
const response = await client.post<CredentialTestResult>(
`/dns-providers/${providerId}/credentials/${credentialId}/test`
)
return response.data
}
/**
* Enables multi-credential mode for a DNS provider.
* @param providerId - The DNS provider ID
* @throws {AxiosError} If provider not found or already enabled
*/
export async function enableMultiCredentials(providerId: number): Promise<void> {
await client.post(`/dns-providers/${providerId}/enable-multi-credentials`)
}
+159
View File
@@ -0,0 +1,159 @@
import client from './client'
/** Represents a CrowdSec decision (ban/captcha). */
export interface CrowdSecDecision {
id: string
ip: string
reason: string
duration: string
created_at: string
source: string
}
/**
* Starts the CrowdSec security service.
* @returns Promise resolving to status with process ID and LAPI readiness
* @throws {AxiosError} If the service fails to start
*/
export async function startCrowdsec(): Promise<{ status: string; pid: number; lapi_ready?: boolean }> {
const resp = await client.post('/admin/crowdsec/start')
return resp.data
}
/**
* Stops the CrowdSec security service.
* @returns Promise resolving to stop status
* @throws {AxiosError} If the service fails to stop
*/
export async function stopCrowdsec() {
const resp = await client.post('/admin/crowdsec/stop')
return resp.data
}
/** CrowdSec service status information. */
export interface CrowdSecStatus {
running: boolean
pid: number
lapi_ready: boolean
}
/**
* Gets the current status of the CrowdSec service.
* @returns Promise resolving to CrowdSecStatus
* @throws {AxiosError} If status check fails
*/
export async function statusCrowdsec(): Promise<CrowdSecStatus> {
const resp = await client.get<CrowdSecStatus>('/admin/crowdsec/status')
return resp.data
}
/**
* Imports a CrowdSec configuration file.
* @param file - The configuration file to import
* @returns Promise resolving to import result
* @throws {AxiosError} If import fails or file is invalid
*/
export async function importCrowdsecConfig(file: File) {
const fd = new FormData()
fd.append('file', file)
const resp = await client.post('/admin/crowdsec/import', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return resp.data
}
/**
* Exports the current CrowdSec configuration.
* @returns Promise resolving to configuration blob for download
* @throws {AxiosError} If export fails
*/
export async function exportCrowdsecConfig() {
const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' })
return resp.data
}
/**
* Lists all CrowdSec configuration files.
* @returns Promise resolving to object containing file list
* @throws {AxiosError} If listing fails
*/
export async function listCrowdsecFiles() {
const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files')
return resp.data
}
/**
* Reads the content of a CrowdSec configuration file.
* @param path - The file path to read
* @returns Promise resolving to object containing file content
* @throws {AxiosError} If file cannot be read
*/
export async function readCrowdsecFile(path: string) {
const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`)
return resp.data
}
/**
* Writes content to a CrowdSec configuration file.
* @param path - The file path to write
* @param content - The content to write
* @returns Promise resolving to write result
* @throws {AxiosError} If file cannot be written
*/
export async function writeCrowdsecFile(path: string, content: string) {
const resp = await client.post('/admin/crowdsec/file', { path, content })
return resp.data
}
/**
* Lists all active CrowdSec decisions (bans).
* @returns Promise resolving to object containing decisions array
* @throws {AxiosError} If listing fails
*/
export async function listCrowdsecDecisions(): Promise<{ decisions: CrowdSecDecision[] }> {
const resp = await client.get<{ decisions: CrowdSecDecision[] }>('/admin/crowdsec/decisions')
return resp.data
}
/**
* Bans an IP address via CrowdSec.
* @param ip - The IP address to ban
* @param duration - Ban duration (e.g., "24h", "7d")
* @param reason - Reason for the ban
* @throws {AxiosError} If ban fails
*/
export async function banIP(ip: string, duration: string, reason: string): Promise<void> {
await client.post('/admin/crowdsec/ban', { ip, duration, reason })
}
/**
* Removes a ban for an IP address.
* @param ip - The IP address to unban
* @throws {AxiosError} If unban fails
*/
export async function unbanIP(ip: string): Promise<void> {
await client.delete(`/admin/crowdsec/ban/${encodeURIComponent(ip)}`)
}
/** CrowdSec API key status information for key rejection notifications. */
export interface CrowdSecKeyStatus {
key_source: 'env' | 'file' | 'auto-generated'
env_key_rejected: boolean
full_key?: string // Only present when env_key_rejected is true
current_key_preview: string
rejected_key_preview?: string
message: string
}
/**
* Gets the current CrowdSec API key status.
* Used to display warning banner when env key was rejected.
* @returns Promise resolving to CrowdSecKeyStatus
* @throws {AxiosError} If status check fails
*/
export async function getCrowdsecKeyStatus(): Promise<CrowdSecKeyStatus> {
const resp = await client.get<CrowdSecKeyStatus>('/admin/crowdsec/key-status')
return resp.data
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, getCrowdsecKeyStatus }
+40
View File
@@ -0,0 +1,40 @@
import client from './client'
import type { DNSProvider } from './dnsProviders'
/** DNS provider detection result */
export interface DetectionResult {
domain: string
detected: boolean
provider_type?: string
nameservers: string[]
confidence: 'high' | 'medium' | 'low' | 'none'
suggested_provider?: DNSProvider
error?: string
}
/** Nameserver pattern used for detection */
export interface NameserverPattern {
pattern: string
provider_type: string
}
/**
* Detects DNS provider for a domain by analyzing nameservers.
* @param domain - Domain name to detect provider for
* @returns Promise resolving to detection result
* @throws {AxiosError} If the request fails
*/
export async function detectDNSProvider(domain: string): Promise<DetectionResult> {
const response = await client.post<DetectionResult>('/dns-providers/detect', { domain })
return response.data
}
/**
* Fetches built-in nameserver patterns used for detection.
* @returns Promise resolving to array of patterns
* @throws {AxiosError} If the request fails
*/
export async function getDetectionPatterns(): Promise<NameserverPattern[]> {
const response = await client.get<{ patterns: NameserverPattern[] }>('/dns-providers/patterns')
return response.data.patterns
}
+178
View File
@@ -0,0 +1,178 @@
import client from './client'
/** Supported DNS provider types */
export type DNSProviderType =
| 'cloudflare'
| 'route53'
| 'digitalocean'
| 'googleclouddns'
| 'namecheap'
| 'godaddy'
| 'azure'
| 'hetzner'
| 'vultr'
| 'dnsimple'
// Custom plugin types
| 'manual'
| 'webhook'
| 'rfc2136'
| 'script'
/** Represents a configured DNS provider */
export interface DNSProvider {
id: number
uuid: string
name: string
provider_type: DNSProviderType
enabled: boolean
is_default: boolean
has_credentials: boolean
propagation_timeout: number
polling_interval: number
last_used_at?: string
success_count: number
failure_count: number
last_error?: string
created_at: string
updated_at: string
}
/** Request payload for creating/updating DNS providers */
export interface DNSProviderRequest {
name: string
provider_type: DNSProviderType
credentials: Record<string, string>
propagation_timeout?: number
polling_interval?: number
is_default?: boolean
}
/** DNS provider test result */
export interface DNSTestResult {
success: boolean
message?: string
error?: string
code?: string
propagation_time_ms?: number
}
/** Field definition for DNS provider credentials */
export interface DNSProviderField {
name: string
label: string
type: 'text' | 'password' | 'textarea' | 'select'
required: boolean
default?: string
hint?: string
placeholder?: string
options?: Array<{
value: string
label: string
}>
}
/** DNS provider type information with field definitions */
export interface DNSProviderTypeInfo {
type: DNSProviderType
name: string
description?: string
documentation_url?: string
is_built_in?: boolean
fields: DNSProviderField[]
}
/** Response for list endpoint */
interface ListDNSProvidersResponse {
providers: DNSProvider[]
total: number
}
/** Response for types endpoint */
interface DNSProviderTypesResponse {
types: DNSProviderTypeInfo[]
}
/**
* Fetches all configured DNS providers.
* @returns Promise resolving to array of DNS providers
* @throws {AxiosError} If the request fails
*/
export async function getDNSProviders(): Promise<DNSProvider[]> {
const response = await client.get<ListDNSProvidersResponse>('/dns-providers')
return response.data.providers
}
/**
* Fetches a single DNS provider by ID.
* @param id - The DNS provider ID
* @returns Promise resolving to the DNS provider
* @throws {AxiosError} If not found or request fails
*/
export async function getDNSProvider(id: number): Promise<DNSProvider> {
const response = await client.get<DNSProvider>(`/dns-providers/${id}`)
return response.data
}
/**
* Creates a new DNS provider.
* @param data - DNS provider configuration
* @returns Promise resolving to the created provider
* @throws {AxiosError} If validation fails or request fails
*/
export async function createDNSProvider(data: DNSProviderRequest): Promise<DNSProvider> {
const response = await client.post<DNSProvider>('/dns-providers', data)
return response.data
}
/**
* Updates an existing DNS provider.
* @param id - The DNS provider ID
* @param data - Updated configuration
* @returns Promise resolving to the updated provider
* @throws {AxiosError} If not found, validation fails, or request fails
*/
export async function updateDNSProvider(id: number, data: DNSProviderRequest): Promise<DNSProvider> {
const response = await client.put<DNSProvider>(`/dns-providers/${id}`, data)
return response.data
}
/**
* Deletes a DNS provider.
* @param id - The DNS provider ID
* @throws {AxiosError} If not found or in use by proxy hosts
*/
export async function deleteDNSProvider(id: number): Promise<void> {
await client.delete(`/dns-providers/${id}`)
}
/**
* Tests connectivity of a saved DNS provider.
* @param id - The DNS provider ID
* @returns Promise resolving to test result
* @throws {AxiosError} If not found or request fails
*/
export async function testDNSProvider(id: number): Promise<DNSTestResult> {
const response = await client.post<DNSTestResult>(`/dns-providers/${id}/test`)
return response.data
}
/**
* Tests DNS provider credentials before saving.
* @param data - Provider configuration to test
* @returns Promise resolving to test result
* @throws {AxiosError} If validation fails or request fails
*/
export async function testDNSProviderCredentials(data: DNSProviderRequest): Promise<DNSTestResult> {
const response = await client.post<DNSTestResult>('/dns-providers/test', data)
return response.data
}
/**
* Fetches supported DNS provider types with field definitions.
* @returns Promise resolving to array of provider type info
* @throws {AxiosError} If request fails
*/
export async function getDNSProviderTypes(): Promise<DNSProviderTypeInfo[]> {
const response = await client.get<DNSProviderTypesResponse>('/dns-providers/types')
return response.data.types
}
+39
View File
@@ -0,0 +1,39 @@
import client from './client'
/** Docker port mapping information. */
export interface DockerPort {
private_port: number
public_port: number
type: string
}
/** Docker container information. */
export interface DockerContainer {
id: string
names: string[]
image: string
state: string
status: string
network: string
ip: string
ports: DockerPort[]
}
/** Docker API client for container operations. */
export const dockerApi = {
/**
* Lists Docker containers from a local or remote host.
* @param host - Optional Docker host address
* @param serverId - Optional remote server ID
* @returns Promise resolving to array of DockerContainer objects
* @throws {AxiosError} If listing fails or host unreachable
*/
listContainers: async (host?: string, serverId?: string): Promise<DockerContainer[]> => {
const params: Record<string, string> = {}
if (host) params.host = host
if (serverId) params.server_id = serverId
const response = await client.get<DockerContainer[]>('/docker/containers', { params })
return response.data
},
}
+39
View File
@@ -0,0 +1,39 @@
import client from './client'
/** Represents a managed domain. */
export interface Domain {
id: number
uuid: string
name: string
created_at: string
}
/**
* Fetches all managed domains.
* @returns Promise resolving to array of Domain objects
* @throws {AxiosError} If the request fails
*/
export const getDomains = async (): Promise<Domain[]> => {
const { data } = await client.get<Domain[]>('/domains')
return data
}
/**
* Creates a new managed domain.
* @param name - The domain name to create
* @returns Promise resolving to the created Domain
* @throws {AxiosError} If creation fails or domain is invalid
*/
export const createDomain = async (name: string): Promise<Domain> => {
const { data } = await client.post<Domain>('/domains', { name })
return data
}
/**
* Deletes a managed domain.
* @param uuid - The unique identifier of the domain to delete
* @throws {AxiosError} If deletion fails or domain not found
*/
export const deleteDomain = async (uuid: string): Promise<void> => {
await client.delete(`/domains/${uuid}`)
}
+85
View File
@@ -0,0 +1,85 @@
import client from './client'
/** Rotation status for key management */
export interface RotationStatus {
current_version: number
next_key_configured: boolean
legacy_key_count: number
providers_on_current_version: number
providers_on_older_versions: number
}
/** Result of a key rotation operation */
export interface RotationResult {
total_providers: number
success_count: number
failure_count: number
failed_providers?: number[]
duration: string
new_key_version: number
}
/** Audit log entry for key rotation history */
export interface RotationHistoryEntry {
id: number
uuid: string
actor: string
action: string
event_category: string
details: string
created_at: string
}
/** Response for history endpoint */
interface RotationHistoryResponse {
history: RotationHistoryEntry[]
total: number
}
/** Validation result for key configuration */
export interface KeyValidationResult {
valid: boolean
message?: string
errors?: string[]
warnings?: string[]
}
/**
* Fetches current encryption key status and rotation information.
* @returns Promise resolving to rotation status
* @throws {AxiosError} If the request fails
*/
export async function getEncryptionStatus(): Promise<RotationStatus> {
const response = await client.get<RotationStatus>('/admin/encryption/status')
return response.data
}
/**
* Triggers rotation of all DNS provider credentials to a new encryption key.
* @returns Promise resolving to rotation result
* @throws {AxiosError} If rotation fails or request fails
*/
export async function rotateEncryptionKey(): Promise<RotationResult> {
const response = await client.post<RotationResult>('/admin/encryption/rotate')
return response.data
}
/**
* Fetches key rotation audit history.
* @returns Promise resolving to array of rotation history entries
* @throws {AxiosError} If the request fails
*/
export async function getRotationHistory(): Promise<RotationHistoryEntry[]> {
const response = await client.get<RotationHistoryResponse>('/admin/encryption/history')
return response.data.history
}
/**
* Validates the current key configuration.
* @returns Promise resolving to validation result
* @throws {AxiosError} If the request fails
*/
export async function validateKeyConfiguration(): Promise<KeyValidationResult> {
const response = await client.post<KeyValidationResult>('/admin/encryption/validate')
return response.data
}
+26
View File
@@ -0,0 +1,26 @@
import { vi, describe, it, expect } from 'vitest'
// Mock the client module which is an axios instance wrapper
vi.mock('./client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: { 'feature.cerberus.enabled': true } })),
put: vi.fn(() => Promise.resolve({ data: { status: 'ok' } })),
},
}))
import { getFeatureFlags, updateFeatureFlags } from './featureFlags'
import client from './client'
describe('featureFlags API', () => {
it('fetches feature flags', async () => {
const flags = await getFeatureFlags()
expect(flags['feature.cerberus.enabled']).toBe(true)
expect(vi.mocked(client.get)).toHaveBeenCalled()
})
it('updates feature flags', async () => {
const resp = await updateFeatureFlags({ 'feature.cerberus.enabled': false })
expect(resp).toEqual({ status: 'ok' })
expect(vi.mocked(client.put)).toHaveBeenCalledWith('/feature-flags', { 'feature.cerberus.enabled': false })
})
})
+27
View File
@@ -0,0 +1,27 @@
import client from './client'
/**
* Fetches all feature flags and their current states.
* @returns Promise resolving to a record of flag names to boolean values
* @throws {AxiosError} If the request fails
*/
export async function getFeatureFlags(): Promise<Record<string, boolean>> {
const resp = await client.get<Record<string, boolean>>('/feature-flags')
return resp.data
}
/**
* Updates one or more feature flags.
* @param payload - Record of flag names to new boolean values
* @returns Promise resolving to the update result
* @throws {AxiosError} If the update fails
*/
export async function updateFeatureFlags(payload: Record<string, boolean>) {
const resp = await client.put('/feature-flags', payload)
return resp.data
}
export default {
getFeatureFlags,
updateFeatureFlags,
}
+20
View File
@@ -0,0 +1,20 @@
import client from './client';
/** Health check response with version and build information. */
export interface HealthResponse {
status: string;
service: string;
version: string;
git_commit: string;
build_time: string;
}
/**
* Checks the health status of the API server.
* @returns Promise resolving to HealthResponse with version info
* @throws {AxiosError} If the health check fails
*/
export const checkHealth = async (): Promise<HealthResponse> => {
const { data } = await client.get<HealthResponse>('/health');
return data;
};
+142
View File
@@ -0,0 +1,142 @@
import client from './client';
/** Represents an active import session. */
export interface ImportSession {
id: string;
state: 'pending' | 'reviewing' | 'completed' | 'failed' | 'transient';
created_at: string;
updated_at: string;
source_file?: string;
}
/** Preview of a Caddyfile import with hosts and conflicts. */
export interface ImportPreview {
session: ImportSession;
preview: {
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
conflicts: string[];
errors: string[];
};
/** Optional top-level warning message returned by the backend (file_server, no-sites, etc.) */
warning?: string;
caddyfile_content?: string;
conflict_details?: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/**
* Uploads a Caddyfile content for import preview.
* @param content - The Caddyfile content as a string
* @returns Promise resolving to ImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadCaddyfile = async (content: string): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload', { content });
return data;
};
/**
* Represents a Caddyfile with its filename and content.
*/
export interface CaddyFile {
filename: string;
content: string;
}
/**
* Uploads multiple Caddyfiles for batch import.
* @param files - Array of CaddyFile objects with filename and content
* @returns Promise resolving to combined ImportPreview
* @throws {AxiosError} If parsing fails
*/
export const uploadCaddyfilesMulti = async (files: CaddyFile[]): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload-multi', { files });
return data;
};
/**
* Gets the current import preview for the active session.
* @returns Promise resolving to ImportPreview
* @throws {AxiosError} If no active session or request fails
*/
export const getImportPreview = async (): Promise<ImportPreview> => {
const { data } = await client.get<ImportPreview>('/import/preview');
return data;
};
/** Result of committing an import operation. */
export interface ImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Commits the import, creating/updating proxy hosts.
* @param sessionUUID - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to ImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitImport = async (
sessionUUID: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<ImportCommitResult> => {
const { data } = await client.post<ImportCommitResult>('/import/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
return data;
};
/**
* Cancels the current import session.
* @param sessionUUID - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelImport = async (sessionUUID: string): Promise<void> => {
await client.delete('/import/cancel', {
params: {
session_uuid: sessionUUID,
},
});
};
/**
* Gets the current import session status.
* @returns Promise resolving to object with pending status and optional session
*/
export const getImportStatus = async (): Promise<{ has_pending: boolean; session?: ImportSession }> => {
// Note: Assuming there might be a status endpoint or we infer from preview.
// If no dedicated status endpoint exists in backend, we might rely on preview returning 404 or empty.
// Based on previous context, there wasn't an explicit status endpoint mentioned in the simple API,
// but the hook used `importAPI.status()`. I'll check the backend routes if needed.
// For now, I'll implement it assuming /import/preview can serve as status check or there is a /import/status.
// Let's check the backend routes to be sure.
try {
const { data } = await client.get<{ has_pending: boolean; session?: ImportSession }>('/import/status');
return data;
} catch {
// Fallback if status endpoint doesn't exist, though the hook used it.
return { has_pending: false };
}
};
+93
View File
@@ -0,0 +1,93 @@
import client from './client';
/** Represents a host parsed from a JSON export. */
export interface JSONHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of a JSON import with hosts and conflicts. */
export interface JSONImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: JSONHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing a JSON import operation. */
export interface JSONImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads JSON export content for import preview.
* @param content - The JSON export content as a string
* @returns Promise resolving to JSONImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadJSONExport = async (content: string): Promise<JSONImportPreview> => {
const { data } = await client.post<JSONImportPreview>('/import/json/upload', { content });
return data;
};
/**
* Commits the JSON import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to JSONImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitJSONImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<JSONImportCommitResult> => {
const { data } = await client.post<JSONImportCommitResult>('/import/json/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current JSON import session.
* @param sessionUuid - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelJSONImport = async (sessionUuid: string): Promise<void> => {
await client.post('/import/json/cancel', {
session_uuid: sessionUuid,
});
};
+339
View File
@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import client from './client'
import { getLogs, getLogContent, downloadLog, connectLiveLogs, connectSecurityLogs } from './logs'
import type { LiveLogEntry, SecurityLogEntry } from './logs'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
}
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 3
static instances: MockWebSocket[] = []
url: string
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: ((event: Event) => void) | null = null
onclose: ((event: CloseEvent) => void) | null = null
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
open() {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
sendMessage(data: string) {
this.onmessage?.({ data })
}
triggerError(event: Event) {
this.onerror?.(event)
}
close() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent)
}
}
const originalWebSocket = globalThis.WebSocket
const originalLocation = { ...window.location }
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = MockWebSocket as unknown as typeof WebSocket
Object.defineProperty(window, 'location', {
value: { ...originalLocation, protocol: 'http:', host: 'localhost', href: '' },
writable: true,
})
})
afterEach(() => {
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = originalWebSocket
Object.defineProperty(window, 'location', { value: originalLocation })
MockWebSocket.instances.length = 0
})
describe('logs api', () => {
it('lists log files', async () => {
mockedClient.get.mockResolvedValue({ data: [{ name: 'access.log', size: 10, mod_time: 'now' }] })
const logs = await getLogs()
expect(mockedClient.get).toHaveBeenCalledWith('/logs')
expect(logs[0].name).toBe('access.log')
})
it('fetches log content with filters applied', async () => {
mockedClient.get.mockResolvedValue({ data: { filename: 'access.log', logs: [], total: 0, limit: 50, offset: 0 } })
await getLogContent('access.log', {
search: 'error',
host: 'example.com',
status: '500',
level: 'error',
limit: 50,
offset: 10,
sort: 'asc',
})
expect(mockedClient.get).toHaveBeenCalledWith(
'/logs/access.log?search=error&host=example.com&status=500&level=error&limit=50&offset=10&sort=asc'
)
})
it('sets window location when downloading logs', () => {
downloadLog('access.log')
expect(window.location.href).toBe('/api/v1/logs/access.log/download')
})
it('connects to live logs websocket and handles lifecycle events', () => {
const received: LiveLogEntry[] = []
const onOpen = vi.fn()
const onError = vi.fn()
const onClose = vi.fn()
const disconnect = connectLiveLogs({ level: 'error', source: 'cerberus' }, (log) => received.push(log), onOpen, onError, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
expect(socket.url).toContain('source=cerberus')
socket.open()
expect(onOpen).toHaveBeenCalled()
socket.sendMessage(JSON.stringify({ level: 'info', timestamp: 'now', message: 'hello' }))
expect(received).toHaveLength(1)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
socket.sendMessage('not-json')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
socket.close()
expect(onClose).toHaveBeenCalled()
disconnect()
})
})
describe('connectSecurityLogs', () => {
it('connects to cerberus logs websocket endpoint', () => {
const received: SecurityLogEntry[] = []
const onOpen = vi.fn()
connectSecurityLogs({}, (log) => received.push(log), onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('/api/v1/cerberus/logs/ws')
})
it('passes source filter to websocket url', () => {
connectSecurityLogs({ source: 'waf' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
})
it('passes level filter to websocket url', () => {
connectSecurityLogs({ level: 'error' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
})
it('passes ip filter to websocket url', () => {
connectSecurityLogs({ ip: '192.168' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('ip=192.168')
})
it('passes host filter to websocket url', () => {
connectSecurityLogs({ host: 'example.com' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('host=example.com')
})
it('passes blocked_only filter to websocket url', () => {
connectSecurityLogs({ blocked_only: true }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('blocked_only=true')
})
it('receives and parses security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const securityLogEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/api/test',
status: 200,
duration: 0.05,
size: 1024,
user_agent: 'TestAgent/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
}
socket.sendMessage(JSON.stringify(securityLogEntry))
expect(received).toHaveLength(1)
expect(received[0].client_ip).toBe('192.168.1.100')
expect(received[0].source).toBe('normal')
expect(received[0].blocked).toBe(false)
})
it('receives blocked security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const blockedEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'warn',
logger: 'http.handlers.waf',
client_ip: '10.0.0.1',
method: 'POST',
uri: '/admin',
status: 403,
duration: 0.001,
size: 0,
user_agent: 'Attack/1.0',
host: 'example.com',
source: 'waf',
blocked: true,
block_reason: 'SQL injection detected',
}
socket.sendMessage(JSON.stringify(blockedEntry))
expect(received).toHaveLength(1)
expect(received[0].blocked).toBe(true)
expect(received[0].block_reason).toBe('SQL injection detected')
expect(received[0].source).toBe('waf')
})
it('handles onOpen callback', () => {
const onOpen = vi.fn()
connectSecurityLogs({}, () => {}, onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(onOpen).toHaveBeenCalled()
})
it('handles onError callback', () => {
const onError = vi.fn()
connectSecurityLogs({}, () => {}, undefined, onError)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
})
it('handles onClose callback', () => {
const onClose = vi.fn()
connectSecurityLogs({}, () => {}, undefined, undefined, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.close()
expect(onClose).toHaveBeenCalled()
})
it('returns disconnect function that closes websocket', () => {
const disconnect = connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(socket.readyState).toBe(MockWebSocket.OPEN)
disconnect()
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
})
it('handles JSON parse errors gracefully', () => {
const received: SecurityLogEntry[] = []
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
socket.sendMessage('invalid-json')
expect(received).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('uses wss protocol when on https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'secure.example.com', href: '' },
writable: true,
})
connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('wss://')
expect(socket.url).toContain('secure.example.com')
})
it('combines multiple filters in websocket url', () => {
connectSecurityLogs(
{
source: 'waf',
level: 'warn',
ip: '10.0.0',
host: 'example.com',
blocked_only: true,
},
() => {}
)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
expect(socket.url).toContain('level=warn')
expect(socket.url).toContain('ip=10.0.0')
expect(socket.url).toContain('host=example.com')
expect(socket.url).toContain('blocked_only=true')
})
})
+262
View File
@@ -0,0 +1,262 @@
import client from './client';
/** Represents a log file on the server. */
export interface LogFile {
name: string;
size: number;
mod_time: string;
}
/** Parsed Caddy access log entry. */
export interface CaddyAccessLog {
level: string;
ts: number;
logger: string;
msg: string;
request: {
remote_ip: string;
method: string;
host: string;
uri: string;
proto: string;
};
status: number;
duration: number;
size: number;
}
/** Paginated log response. */
export interface LogResponse {
filename: string;
logs: CaddyAccessLog[];
total: number;
limit: number;
offset: number;
}
/** Filter options for log queries. */
export interface LogFilter {
search?: string;
host?: string;
status?: string;
level?: string;
limit?: number;
offset?: number;
sort?: 'asc' | 'desc';
}
/**
* Fetches the list of available log files.
* @returns Promise resolving to array of LogFile objects
* @throws {AxiosError} If the request fails
*/
export const getLogs = async (): Promise<LogFile[]> => {
const response = await client.get<LogFile[]>('/logs');
return response.data;
};
/**
* Fetches paginated and filtered log entries from a specific file.
* @param filename - The log file name to read
* @param filter - Optional filter and pagination options
* @returns Promise resolving to LogResponse with entries and metadata
* @throws {AxiosError} If the request fails or file not found
*/
export const getLogContent = async (filename: string, filter: LogFilter = {}): Promise<LogResponse> => {
const params = new URLSearchParams();
if (filter.search) params.append('search', filter.search);
if (filter.host) params.append('host', filter.host);
if (filter.status) params.append('status', filter.status);
if (filter.level) params.append('level', filter.level);
if (filter.limit) params.append('limit', filter.limit.toString());
if (filter.offset) params.append('offset', filter.offset.toString());
if (filter.sort) params.append('sort', filter.sort);
const response = await client.get<LogResponse>(`/logs/${filename}?${params.toString()}`);
return response.data;
};
/**
* Initiates a log file download by redirecting the browser.
* @param filename - The log file name to download
*/
export const downloadLog = (filename: string) => {
// Direct window location change to trigger download
// We need to use the base URL from the client config if possible,
// but for now we assume relative path works with the proxy setup
window.location.href = `/api/v1/logs/${filename}/download`;
};
/** Live log entry from WebSocket stream. */
export interface LiveLogEntry {
level: string;
timestamp: string;
message: string;
source?: string;
data?: Record<string, unknown>;
}
/** Filter options for live log streaming. */
export interface LiveLogFilter {
level?: string;
source?: string;
}
/**
* SecurityLogEntry represents a security-relevant log entry from Cerberus.
* This matches the backend SecurityLogEntry struct from /api/v1/cerberus/logs/ws
*/
export interface SecurityLogEntry {
timestamp: string;
level: string;
logger: string;
client_ip: string;
method: string;
uri: string;
status: number;
duration: number;
size: number;
user_agent: string;
host: string;
source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
blocked: boolean;
block_reason?: string;
details?: Record<string, unknown>;
}
/**
* Filters for the Cerberus security logs WebSocket endpoint.
*/
export interface SecurityLogFilter {
source?: string; // Filter by security module: waf, crowdsec, ratelimit, acl, normal
level?: string; // Filter by log level: info, warn, error
ip?: string; // Filter by client IP (partial match)
host?: string; // Filter by host (partial match)
blocked_only?: boolean; // Only show blocked requests
}
/**
* Connects to the live logs WebSocket endpoint for real-time log streaming.
* Returns a cleanup function to close the connection.
* @param filters - LiveLogFilter options for level and source filtering
* @param onMessage - Callback invoked for each received LiveLogEntry
* @param onOpen - Optional callback when WebSocket connection is established
* @param onError - Optional callback on WebSocket error
* @param onClose - Optional callback when WebSocket connection closes
* @returns Function to close the WebSocket connection
*/
export const connectLiveLogs = (
filters: LiveLogFilter,
onMessage: (log: LiveLogEntry) => void,
onOpen?: () => void,
onError?: (error: Event) => void,
onClose?: () => void
): (() => void) => {
const params = new URLSearchParams();
if (filters.level) params.append('level', filters.level);
if (filters.source) params.append('source', filters.source);
// Authentication is handled via HttpOnly cookies sent automatically by the browser
// This prevents tokens from being logged in access logs or exposed to XSS attacks
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/logs/live?${params.toString()}`;
console.log('Connecting to WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connection established');
onOpen?.();
};
ws.onmessage = (event: MessageEvent) => {
try {
const log = JSON.parse(event.data) as LiveLogEntry;
onMessage(log);
} catch (err) {
console.error('Failed to parse log message:', err);
}
};
ws.onerror = (error: Event) => {
console.error('WebSocket error:', error);
onError?.(error);
};
ws.onclose = (event: CloseEvent) => {
console.log('WebSocket connection closed', { code: event.code, reason: event.reason, wasClean: event.wasClean });
onClose?.();
};
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
};
/**
* Connects to the Cerberus security logs WebSocket endpoint.
* This streams parsed Caddy access logs with security event annotations.
*
* @param filters - Optional filters for source, level, IP, host, and blocked_only
* @param onMessage - Callback for each received SecurityLogEntry
* @param onOpen - Callback when connection is established
* @param onError - Callback on connection error
* @param onClose - Callback when connection closes
* @returns A function to close the WebSocket connection
*/
export const connectSecurityLogs = (
filters: SecurityLogFilter,
onMessage: (log: SecurityLogEntry) => void,
onOpen?: () => void,
onError?: (error: Event) => void,
onClose?: () => void
): (() => void) => {
const params = new URLSearchParams();
if (filters.source) params.append('source', filters.source);
if (filters.level) params.append('level', filters.level);
if (filters.ip) params.append('ip', filters.ip);
if (filters.host) params.append('host', filters.host);
if (filters.blocked_only) params.append('blocked_only', 'true');
// Authentication is handled via HttpOnly cookies sent automatically by the browser
// This prevents tokens from being logged in access logs or exposed to XSS attacks
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/ws?${params.toString()}`;
console.log('Connecting to Cerberus logs WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Cerberus logs WebSocket connection established');
onOpen?.();
};
ws.onmessage = (event: MessageEvent) => {
try {
const log = JSON.parse(event.data) as SecurityLogEntry;
onMessage(log);
} catch (err) {
console.error('Failed to parse security log message:', err);
}
};
ws.onerror = (error: Event) => {
console.error('Cerberus logs WebSocket error:', error);
onError?.(error);
};
ws.onclose = (event: CloseEvent) => {
console.log('Cerberus logs WebSocket closed', { code: event.code, reason: event.reason, wasClean: event.wasClean });
onClose?.();
};
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
};
+115
View File
@@ -0,0 +1,115 @@
import client from './client'
/** Status of a manual DNS challenge */
export type ChallengeStatus = 'created' | 'pending' | 'verifying' | 'verified' | 'expired' | 'failed'
/** Manual DNS challenge response from API */
export interface ManualChallenge {
id: string
status: ChallengeStatus
fqdn: string
value: string
ttl: number
created_at: string
expires_at: string
last_check_at?: string
dns_propagated: boolean
error_message?: string
}
/** Polling response for challenge status */
export interface ChallengePollResponse {
status: ChallengeStatus
dns_propagated: boolean
time_remaining_seconds: number
last_check_at: string
error_message?: string
}
/** Challenge verification result */
export interface ChallengeVerifyResponse {
success: boolean
dns_found: boolean
message: string
}
/** Request to create a new manual challenge */
export interface CreateChallengeRequest {
domain: string
}
/**
* Fetches a manual challenge by ID.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to the challenge details
* @throws {AxiosError} If not found or request fails
*/
export async function getChallenge(providerId: number, challengeId: string): Promise<ManualChallenge> {
const response = await client.get<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}`
)
return response.data
}
/**
* Creates a new manual DNS challenge.
* @param providerId - The DNS provider ID
* @param data - Challenge creation data
* @returns Promise resolving to the created challenge
* @throws {AxiosError} If validation fails or request fails
*/
export async function createChallenge(
providerId: number,
data: CreateChallengeRequest
): Promise<ManualChallenge> {
const response = await client.post<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge`,
data
)
return response.data
}
/**
* Triggers verification of a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to verification result
* @throws {AxiosError} If not found or request fails
*/
export async function verifyChallenge(
providerId: number,
challengeId: string
): Promise<ChallengeVerifyResponse> {
const response = await client.post<ChallengeVerifyResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/verify`
)
return response.data
}
/**
* Polls for challenge status updates.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to poll response
* @throws {AxiosError} If not found or request fails
*/
export async function pollChallenge(
providerId: number,
challengeId: string
): Promise<ChallengePollResponse> {
const response = await client.get<ChallengePollResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/poll`
)
return response.data
}
/**
* Deletes/cancels a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @throws {AxiosError} If not found or request fails
*/
export async function deleteChallenge(providerId: number, challengeId: string): Promise<void> {
await client.delete(`/dns-providers/${providerId}/manual-challenge/${challengeId}`)
}
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
getProviders,
createProvider,
updateProvider,
deleteProvider,
testProvider,
getTemplates,
previewProvider,
getExternalTemplates,
createExternalTemplate,
updateExternalTemplate,
deleteExternalTemplate,
previewExternalTemplate,
getSecurityNotificationSettings,
updateSecurityNotificationSettings,
} from './notifications'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
describe('notifications api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches providers list', async () => {
mockedClient.get.mockResolvedValue({
data: [
{
id: '1',
name: 'PagerDuty',
type: 'webhook',
url: 'https://hooks.example.com',
enabled: true,
notify_proxy_hosts: true,
notify_remote_servers: false,
notify_domains: false,
notify_certs: false,
notify_uptime: true,
created_at: '2025-01-01T00:00:00Z',
},
],
})
const result = await getProviders()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/providers')
expect(result[0].name).toBe('PagerDuty')
})
it('creates, updates, tests, and deletes a provider', async () => {
mockedClient.post.mockResolvedValue({ data: { id: 'new', name: 'Slack' } })
mockedClient.put.mockResolvedValue({ data: { id: 'new', name: 'Slack v2' } })
const created = await createProvider({ name: 'Slack' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', { name: 'Slack', type: 'discord' })
expect(created.id).toBe('new')
const updated = await updateProvider('new', { enabled: false })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/new', { enabled: false, type: 'discord' })
expect(updated.name).toBe('Slack v2')
await testProvider({ id: 'new', name: 'Slack', enabled: true })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/test', {
id: 'new',
name: 'Slack',
enabled: true,
type: 'discord',
})
mockedClient.delete.mockResolvedValue({})
await deleteProvider('new')
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/providers/new')
})
it('supports discord, gotify, and webhook while enforcing token payload contract', async () => {
mockedClient.post.mockResolvedValue({ data: { id: 'ok' } })
mockedClient.put.mockResolvedValue({ data: { id: 'ok' } })
await createProvider({ name: 'Gotify', type: 'gotify', gotify_token: 'secret-token' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', {
name: 'Gotify',
type: 'gotify',
token: 'secret-token',
})
await updateProvider('ok', { type: 'webhook', url: 'https://example.com/webhook', gotify_token: 'should-not-send' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/ok', {
type: 'webhook',
url: 'https://example.com/webhook',
})
await testProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/test', {
id: 'ok',
type: 'gotify',
})
await previewProvider({ id: 'ok', type: 'gotify', gotify_token: 'should-not-send' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', {
id: 'ok',
type: 'gotify',
})
await expect(createProvider({ name: 'Bad', type: 'slack' })).rejects.toThrow('Unsupported notification provider type: slack')
await expect(updateProvider('bad', { type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
await expect(testProvider({ id: 'bad', type: 'email' })).rejects.toThrow('Unsupported notification provider type: email')
})
it('fetches templates and previews provider payloads with data', async () => {
mockedClient.get.mockResolvedValueOnce({ data: [{ id: 'tpl', name: 'default' }] })
mockedClient.post.mockResolvedValue({ data: { preview: 'ok' } })
const templates = await getTemplates()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/templates')
expect(templates[0].id).toBe('tpl')
const preview = await previewProvider({ id: 'p1', name: 'Provider' }, { foo: 'bar' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/preview', {
id: 'p1',
name: 'Provider',
type: 'discord',
data: { foo: 'bar' },
})
expect(preview).toEqual({ preview: 'ok' })
})
it('handles external templates lifecycle and previews', async () => {
mockedClient.get.mockResolvedValueOnce({ data: [{ id: 'ext', name: 'External' }] })
mockedClient.post.mockResolvedValueOnce({ data: { id: 'ext', name: 'created' } })
mockedClient.put.mockResolvedValueOnce({ data: { id: 'ext', name: 'updated' } })
mockedClient.post.mockResolvedValueOnce({ data: { preview: 'rendered' } })
const list = await getExternalTemplates()
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/external-templates')
expect(list[0].id).toBe('ext')
const created = await createExternalTemplate({ name: 'External' })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/external-templates', { name: 'External' })
expect(created.name).toBe('created')
const updated = await updateExternalTemplate('ext', { description: 'desc' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/external-templates/ext', { description: 'desc' })
expect(updated.name).toBe('updated')
await deleteExternalTemplate('ext')
expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/external-templates/ext')
const preview = await previewExternalTemplate('ext', '<tpl>', { a: 1 })
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/external-templates/preview', {
template_id: 'ext',
template: '<tpl>',
data: { a: 1 },
})
expect(preview).toEqual({ preview: 'rendered' })
})
it('reads and updates security notification settings', async () => {
mockedClient.get.mockResolvedValueOnce({ data: { enabled: true, min_log_level: 'info', security_waf_enabled: true, security_acl_enabled: false, security_rate_limit_enabled: true } })
mockedClient.put.mockResolvedValueOnce({ data: { enabled: false, min_log_level: 'error', security_waf_enabled: false, security_acl_enabled: true, security_rate_limit_enabled: false } })
const settings = await getSecurityNotificationSettings()
expect(settings.enabled).toBe(true)
expect(mockedClient.get).toHaveBeenCalledWith('/notifications/settings/security')
const updated = await updateSecurityNotificationSettings({ enabled: false, min_log_level: 'error' })
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/settings/security', { enabled: false, min_log_level: 'error' })
expect(updated.enabled).toBe(false)
})
})
+271
View File
@@ -0,0 +1,271 @@
import client from './client';
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook'] as const;
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
const isSupportedNotificationProviderType = (type: string | undefined): type is SupportedNotificationProviderType =>
typeof type === 'string' && SUPPORTED_NOTIFICATION_PROVIDER_TYPES.includes(type.toLowerCase() as SupportedNotificationProviderType);
const resolveProviderTypeOrThrow = (type: string | undefined): SupportedNotificationProviderType => {
if (typeof type === 'undefined') {
return DEFAULT_PROVIDER_TYPE;
}
const normalizedType = type.toLowerCase();
if (isSupportedNotificationProviderType(normalizedType)) {
return normalizedType;
}
throw new Error(`Unsupported notification provider type: ${type}`);
};
/** Notification provider configuration. */
export interface NotificationProvider {
id: string;
name: string;
type: string;
url: string;
config?: string;
template?: string;
gotify_token?: string;
token?: string;
has_token?: boolean;
enabled: boolean;
notify_proxy_hosts: boolean;
notify_remote_servers: boolean;
notify_domains: boolean;
notify_certs: boolean;
notify_uptime: boolean;
notify_security_waf_blocks: boolean;
notify_security_acl_denies: boolean;
notify_security_rate_limit_hits: boolean;
managed_legacy_security?: boolean;
created_at: string;
}
const sanitizeProviderForWriteAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const type = resolveProviderTypeOrThrow(data.type);
const payload: Partial<NotificationProvider> = {
...data,
type,
};
const normalizedToken = typeof payload.gotify_token === 'string' && payload.gotify_token.trim().length > 0
? payload.gotify_token.trim()
: typeof payload.token === 'string' && payload.token.trim().length > 0
? payload.token.trim()
: undefined;
delete payload.gotify_token;
if (type !== 'gotify') {
delete payload.token;
return payload;
}
if (normalizedToken) {
payload.token = normalizedToken;
} else {
delete payload.token;
}
return payload;
};
const sanitizeProviderForReadLikeAction = (data: Partial<NotificationProvider>): Partial<NotificationProvider> => {
const payload = sanitizeProviderForWriteAction(data);
delete payload.token;
return payload;
};
/**
* Fetches all notification providers.
* @returns Promise resolving to array of NotificationProvider objects
* @throws {AxiosError} If the request fails
*/
export const getProviders = async () => {
const response = await client.get<NotificationProvider[]>('/notifications/providers');
return response.data;
};
/**
* Creates a new notification provider.
* @param data - Partial NotificationProvider configuration
* @returns Promise resolving to the created NotificationProvider
* @throws {AxiosError} If creation fails
*/
export const createProvider = async (data: Partial<NotificationProvider>) => {
const response = await client.post<NotificationProvider>('/notifications/providers', sanitizeProviderForWriteAction(data));
return response.data;
};
/**
* Updates an existing notification provider.
* @param id - The provider ID to update
* @param data - Partial NotificationProvider with fields to update
* @returns Promise resolving to the updated NotificationProvider
* @throws {AxiosError} If update fails or provider not found
*/
export const updateProvider = async (id: string, data: Partial<NotificationProvider>) => {
const response = await client.put<NotificationProvider>(`/notifications/providers/${id}`, sanitizeProviderForWriteAction(data));
return response.data;
};
/**
* Deletes a notification provider.
* @param id - The provider ID to delete
* @throws {AxiosError} If deletion fails or provider not found
*/
export const deleteProvider = async (id: string) => {
await client.delete(`/notifications/providers/${id}`);
};
/**
* Tests a notification provider by sending a test message.
* @param provider - Provider configuration to test
* @throws {AxiosError} If test fails
*/
export const testProvider = async (provider: Partial<NotificationProvider>) => {
await client.post('/notifications/providers/test', sanitizeProviderForReadLikeAction(provider));
};
/**
* Fetches all available notification templates.
* @returns Promise resolving to array of NotificationTemplate objects
* @throws {AxiosError} If the request fails
*/
export const getTemplates = async () => {
const response = await client.get<NotificationTemplate[]>('/notifications/templates');
return response.data;
};
/** Notification template definition. */
export interface NotificationTemplate {
id: string;
name: string;
}
/**
* Previews a notification with sample data.
* @param provider - Provider configuration for preview
* @param data - Optional sample data for template rendering
* @returns Promise resolving to preview result
* @throws {AxiosError} If preview fails
*/
export const previewProvider = async (provider: Partial<NotificationProvider>, data?: Record<string, unknown>) => {
const payload: Record<string, unknown> = sanitizeProviderForReadLikeAction(provider) as Record<string, unknown>;
if (data) payload.data = data;
const response = await client.post('/notifications/providers/preview', payload);
return response.data;
};
// External (saved) templates API
/** External notification template configuration. */
export interface ExternalTemplate {
id: string;
name: string;
description?: string;
config?: string;
template?: string;
created_at?: string;
}
/**
* Fetches all external notification templates.
* @returns Promise resolving to array of ExternalTemplate objects
* @throws {AxiosError} If the request fails
*/
export const getExternalTemplates = async () => {
const response = await client.get<ExternalTemplate[]>('/notifications/external-templates');
return response.data;
};
/**
* Creates a new external notification template.
* @param data - Partial ExternalTemplate configuration
* @returns Promise resolving to the created ExternalTemplate
* @throws {AxiosError} If creation fails
*/
export const createExternalTemplate = async (data: Partial<ExternalTemplate>) => {
const response = await client.post<ExternalTemplate>('/notifications/external-templates', data);
return response.data;
};
/**
* Updates an existing external notification template.
* @param id - The template ID to update
* @param data - Partial ExternalTemplate with fields to update
* @returns Promise resolving to the updated ExternalTemplate
* @throws {AxiosError} If update fails or template not found
*/
export const updateExternalTemplate = async (id: string, data: Partial<ExternalTemplate>) => {
const response = await client.put<ExternalTemplate>(`/notifications/external-templates/${id}`, data);
return response.data;
};
/**
* Deletes an external notification template.
* @param id - The template ID to delete
* @throws {AxiosError} If deletion fails or template not found
*/
export const deleteExternalTemplate = async (id: string) => {
await client.delete(`/notifications/external-templates/${id}`);
};
/**
* Previews an external template with sample data.
* @param templateId - Optional existing template ID to preview
* @param template - Optional template content string
* @param data - Optional sample data for rendering
* @returns Promise resolving to preview result
* @throws {AxiosError} If preview fails
*/
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, unknown>) => {
const payload: Record<string, unknown> = {};
if (templateId) payload.template_id = templateId;
if (template) payload.template = template;
if (data) payload.data = data;
const response = await client.post('/notifications/external-templates/preview', payload);
return response.data;
};
// Security Notification Settings
/** Security notification configuration. */
export interface SecurityNotificationSettings {
enabled: boolean;
min_log_level: string;
security_waf_enabled: boolean;
security_acl_enabled: boolean;
security_rate_limit_enabled: boolean;
destination_ambiguous?: boolean;
webhook_url?: string;
discord_webhook_url?: string;
slack_webhook_url?: string;
gotify_url?: string;
gotify_token?: string;
email_recipients?: string;
}
/**
* Fetches security notification settings.
* @returns Promise resolving to SecurityNotificationSettings
* @throws {AxiosError} If the request fails
*/
export const getSecurityNotificationSettings = async (): Promise<SecurityNotificationSettings> => {
const response = await client.get<SecurityNotificationSettings>('/notifications/settings/security');
return response.data;
};
/**
* Updates security notification settings.
* @param settings - Partial settings to update
* @returns Promise resolving to the updated SecurityNotificationSettings
* @throws {AxiosError} If update fails
*/
export const updateSecurityNotificationSettings = async (
settings: Partial<SecurityNotificationSettings>
): Promise<SecurityNotificationSettings> => {
const response = await client.put<SecurityNotificationSettings>('/notifications/settings/security', settings);
return response.data;
};
+93
View File
@@ -0,0 +1,93 @@
import client from './client';
/** Represents a host parsed from an NPM export. */
export interface NPMHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of an NPM import with hosts and conflicts. */
export interface NPMImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: NPMHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing an NPM import operation. */
export interface NPMImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads NPM export content for import preview.
* @param content - The NPM export JSON content as a string
* @returns Promise resolving to NPMImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadNPMExport = async (content: string): Promise<NPMImportPreview> => {
const { data } = await client.post<NPMImportPreview>('/import/npm/upload', { content });
return data;
};
/**
* Commits the NPM import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to NPMImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitNPMImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<NPMImportCommitResult> => {
const { data } = await client.post<NPMImportCommitResult>('/import/npm/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current NPM import session.
* @param sessionUuid - The import session UUID
* @throws {AxiosError} If cancellation fails
*/
export const cancelNPMImport = async (sessionUuid: string): Promise<void> => {
await client.post('/import/npm/cancel', {
session_uuid: sessionUuid,
});
};
+76
View File
@@ -0,0 +1,76 @@
import client from './client'
/** Plugin status types */
export type PluginStatus = 'pending' | 'loaded' | 'error'
/** Plugin information */
export interface PluginInfo {
id: number
uuid: string
name: string
type: string
enabled: boolean
status: PluginStatus
error?: string
version?: string
author?: string
is_built_in: boolean
description?: string
documentation_url?: string
loaded_at?: string
created_at: string
updated_at: string
}
/**
* Fetches all plugins (built-in and external).
* @returns Promise resolving to array of plugin info
* @throws {AxiosError} If the request fails
*/
export async function getPlugins(): Promise<PluginInfo[]> {
const response = await client.get<PluginInfo[]>('/admin/plugins')
return response.data
}
/**
* Fetches a single plugin by ID.
* @param id - The plugin ID
* @returns Promise resolving to the plugin info
* @throws {AxiosError} If not found or request fails
*/
export async function getPlugin(id: number): Promise<PluginInfo> {
const response = await client.get<PluginInfo>(`/admin/plugins/${id}`)
return response.data
}
/**
* Enables a disabled plugin.
* @param id - The plugin ID
* @returns Promise resolving to success message
* @throws {AxiosError} If not found or request fails
*/
export async function enablePlugin(id: number): Promise<{ message: string }> {
const response = await client.post<{ message: string }>(`/admin/plugins/${id}/enable`)
return response.data
}
/**
* Disables an active plugin.
* @param id - The plugin ID
* @returns Promise resolving to success message
* @throws {AxiosError} If not found, in use, or request fails
*/
export async function disablePlugin(id: number): Promise<{ message: string }> {
const response = await client.post<{ message: string }>(`/admin/plugins/${id}/disable`)
return response.data
}
/**
* Reloads all plugins from the plugin directory.
* @returns Promise resolving to success message and count
* @throws {AxiosError} If request fails
*/
export async function reloadPlugins(): Promise<{ message: string; count: number }> {
const response = await client.post<{ message: string; count: number }>('/admin/plugins/reload')
return response.data
}
+104
View File
@@ -0,0 +1,104 @@
import client from './client'
/** Summary of an available CrowdSec preset. */
export interface CrowdsecPresetSummary {
slug: string
title: string
summary: string
source: string
tags?: string[]
requires_hub: boolean
available: boolean
cached: boolean
cache_key?: string
etag?: string
retrieved_at?: string
}
/** Response from pulling a CrowdSec preset. */
export interface PullCrowdsecPresetResponse {
status: string
slug: string
preview: string
cache_key: string
etag?: string
retrieved_at?: string
source?: string
}
/** Response from applying a CrowdSec preset. */
export interface ApplyCrowdsecPresetResponse {
status: string
backup?: string
reload_hint?: boolean
used_cscli?: boolean
cache_key?: string
slug?: string
}
/** Cached CrowdSec preset preview data. */
export interface CachedCrowdsecPresetPreview {
preview: string
cache_key: string
etag?: string
}
/**
* Lists all available CrowdSec presets.
* @returns Promise resolving to object containing presets array
* @throws {AxiosError} If the request fails
*/
export async function listCrowdsecPresets() {
const resp = await client.get<{ presets: CrowdsecPresetSummary[] }>('/admin/crowdsec/presets')
return resp.data
}
/**
* Gets all CrowdSec presets (alias for listCrowdsecPresets).
* @returns Promise resolving to object containing presets array
* @throws {AxiosError} If the request fails
*/
export async function getCrowdsecPresets() {
return listCrowdsecPresets()
}
/**
* Pulls a CrowdSec preset from the remote source.
* @param slug - The preset slug identifier
* @returns Promise resolving to PullCrowdsecPresetResponse with preview
* @throws {AxiosError} If pull fails or preset not found
*/
export async function pullCrowdsecPreset(slug: string) {
const resp = await client.post<PullCrowdsecPresetResponse>('/admin/crowdsec/presets/pull', { slug })
return resp.data
}
/**
* Applies a CrowdSec preset to the configuration.
* @param payload - Object with preset slug and optional cache_key
* @returns Promise resolving to ApplyCrowdsecPresetResponse
* @throws {AxiosError} If application fails
*/
export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) {
const resp = await client.post<ApplyCrowdsecPresetResponse>('/admin/crowdsec/presets/apply', payload)
return resp.data
}
/**
* Gets a cached CrowdSec preset preview.
* @param slug - The preset slug identifier
* @returns Promise resolving to CachedCrowdsecPresetPreview
* @throws {AxiosError} If not cached or request fails
*/
export async function getCrowdsecPresetCache(slug: string) {
const resp = await client.get<CachedCrowdsecPresetPreview>(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`)
return resp.data
}
export default {
listCrowdsecPresets,
getCrowdsecPresets,
pullCrowdsecPreset,
applyCrowdsecPreset,
getCrowdsecPresetCache,
}
+188
View File
@@ -0,0 +1,188 @@
import client from './client';
export interface Location {
uuid?: string;
path: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
}
export interface Certificate {
id: number;
uuid: string;
name: string;
provider: string;
domains: string;
expires_at: string;
}
export type ApplicationPreset = 'none' | 'plex' | 'jellyfin' | 'emby' | 'homeassistant' | 'nextcloud' | 'vaultwarden';
export interface ProxyHost {
uuid: string;
name: string;
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
http2_support: boolean;
hsts_enabled: boolean;
hsts_subdomains: boolean;
block_exploits: boolean;
websocket_support: boolean;
enable_standard_headers?: boolean;
forward_auth_enabled?: boolean;
waf_disabled?: boolean;
application: ApplicationPreset;
locations: Location[];
advanced_config?: string;
advanced_config_backup?: string;
enabled: boolean;
certificate_id?: number | null;
certificate?: Certificate | null;
access_list_id?: number | string | null;
access_list?: {
uuid: string;
name: string;
description: string;
type: string;
} | null;
security_header_profile_id?: number | string | null;
dns_provider_id?: number | null;
security_header_profile?: {
id?: number;
uuid: string;
name: string;
description: string;
security_score: number;
is_preset: boolean;
} | null;
created_at: string;
updated_at: string;
}
/**
* Fetches all proxy hosts from the API.
* @returns Promise resolving to array of ProxyHost objects
* @throws {AxiosError} If the request fails
*/
export const getProxyHosts = async (): Promise<ProxyHost[]> => {
const { data } = await client.get<ProxyHost[]>('/proxy-hosts');
return data;
};
/**
* Fetches a single proxy host by UUID.
* @param uuid - The unique identifier of the proxy host
* @returns Promise resolving to the ProxyHost object
* @throws {AxiosError} If the request fails or host not found
*/
export const getProxyHost = async (uuid: string): Promise<ProxyHost> => {
const { data } = await client.get<ProxyHost>(`/proxy-hosts/${uuid}`);
return data;
};
/**
* Creates a new proxy host.
* @param host - Partial ProxyHost object with configuration
* @returns Promise resolving to the created ProxyHost
* @throws {AxiosError} If the request fails or validation errors occur
*/
export const createProxyHost = async (host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.post<ProxyHost>('/proxy-hosts', host);
return data;
};
/**
* Updates an existing proxy host.
* @param uuid - The unique identifier of the proxy host to update
* @param host - Partial ProxyHost object with fields to update
* @returns Promise resolving to the updated ProxyHost
* @throws {AxiosError} If the request fails or host not found
*/
export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.put<ProxyHost>(`/proxy-hosts/${uuid}`, host);
return data;
};
/**
* Deletes a proxy host.
* @param uuid - The unique identifier of the proxy host to delete
* @param deleteUptime - Optional flag to also delete associated uptime monitors
* @throws {AxiosError} If the request fails or host not found
*/
export const deleteProxyHost = async (uuid: string, deleteUptime?: boolean): Promise<void> => {
const url = `/proxy-hosts/${uuid}${deleteUptime ? '?delete_uptime=true' : ''}`
await client.delete(url);
};
/**
* Tests connectivity to a backend host.
* @param host - The hostname or IP address to test
* @param port - The port number to test
* @throws {AxiosError} If the connection test fails
*/
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
await client.post('/proxy-hosts/test', { forward_host: host, forward_port: port });
};
export interface BulkUpdateACLRequest {
host_uuids: string[];
access_list_id: number | null;
}
export interface BulkUpdateACLResponse {
updated: number;
errors: { uuid: string; error: string }[];
}
/**
* Bulk updates access control list assignments for multiple proxy hosts.
* @param hostUUIDs - Array of proxy host UUIDs to update
* @param accessListID - The access list ID to assign, or null to remove
* @returns Promise resolving to the bulk update result with success/error counts
* @throws {AxiosError} If the request fails
*/
export const bulkUpdateACL = async (
hostUUIDs: string[],
accessListID: number | null
): Promise<BulkUpdateACLResponse> => {
const { data } = await client.put<BulkUpdateACLResponse>('/proxy-hosts/bulk-update-acl', {
host_uuids: hostUUIDs,
access_list_id: accessListID,
});
return data;
};
export interface BulkUpdateSecurityHeadersRequest {
host_uuids: string[];
security_header_profile_id: number | null;
}
export interface BulkUpdateSecurityHeadersResponse {
updated: number;
errors: { uuid: string; error: string }[];
}
/**
* Bulk updates security header profile assignments for multiple proxy hosts.
* @param hostUUIDs - Array of proxy host UUIDs to update
* @param securityHeaderProfileId - The security header profile ID to assign, or null to remove
* @returns Promise resolving to the bulk update result with success/error counts
* @throws {AxiosError} If the request fails
*/
export const bulkUpdateSecurityHeaders = async (
hostUUIDs: string[],
securityHeaderProfileId: number | null
): Promise<BulkUpdateSecurityHeadersResponse> => {
const { data } = await client.put<BulkUpdateSecurityHeadersResponse>(
'/proxy-hosts/bulk-update-security-headers',
{
host_uuids: hostUUIDs,
security_header_profile_id: securityHeaderProfileId,
}
);
return data;
};
+94
View File
@@ -0,0 +1,94 @@
import client from './client';
/** Remote server configuration for Docker host connections. */
export interface RemoteServer {
uuid: string;
name: string;
provider: string;
host: string;
port: number;
username?: string;
enabled: boolean;
reachable: boolean;
last_check?: string;
created_at: string;
updated_at: string;
}
/**
* Fetches all remote servers.
* @param enabledOnly - If true, only returns enabled servers
* @returns Promise resolving to array of RemoteServer objects
* @throws {AxiosError} If the request fails
*/
export const getRemoteServers = async (enabledOnly = false): Promise<RemoteServer[]> => {
const params = enabledOnly ? { enabled: true } : {};
const { data } = await client.get<RemoteServer[]>('/remote-servers', { params });
return data;
};
/**
* Fetches a single remote server by UUID.
* @param uuid - The unique identifier of the remote server
* @returns Promise resolving to the RemoteServer object
* @throws {AxiosError} If the request fails or server not found
*/
export const getRemoteServer = async (uuid: string): Promise<RemoteServer> => {
const { data } = await client.get<RemoteServer>(`/remote-servers/${uuid}`);
return data;
};
/**
* Creates a new remote server.
* @param server - Partial RemoteServer configuration
* @returns Promise resolving to the created RemoteServer
* @throws {AxiosError} If creation fails
*/
export const createRemoteServer = async (server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.post<RemoteServer>('/remote-servers', server);
return data;
};
/**
* Updates an existing remote server.
* @param uuid - The unique identifier of the server to update
* @param server - Partial RemoteServer with fields to update
* @returns Promise resolving to the updated RemoteServer
* @throws {AxiosError} If update fails or server not found
*/
export const updateRemoteServer = async (uuid: string, server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.put<RemoteServer>(`/remote-servers/${uuid}`, server);
return data;
};
/**
* Deletes a remote server.
* @param uuid - The unique identifier of the server to delete
* @throws {AxiosError} If deletion fails or server not found
*/
export const deleteRemoteServer = async (uuid: string): Promise<void> => {
await client.delete(`/remote-servers/${uuid}`);
};
/**
* Tests connectivity to an existing remote server.
* @param uuid - The unique identifier of the server to test
* @returns Promise resolving to object with server address
* @throws {AxiosError} If connection test fails
*/
export const testRemoteServerConnection = async (uuid: string): Promise<{ address: string }> => {
const { data } = await client.post<{ address: string }>(`/remote-servers/${uuid}/test`);
return data;
};
/**
* Tests connectivity to a custom host and port.
* @param host - The hostname or IP to test
* @param port - The port number to test
* @returns Promise resolving to connection result with reachable status
* @throws {AxiosError} If request fails
*/
export const testCustomRemoteServerConnection = async (host: string, port: number): Promise<{ address: string; reachable: boolean; error?: string }> => {
const { data } = await client.post<{ address: string; reachable: boolean; error?: string }>('/remote-servers/test', { host, port });
return data;
};
+189
View File
@@ -0,0 +1,189 @@
import client from './client'
/** Security module status information. */
export interface SecurityStatus {
cerberus?: { enabled: boolean }
crowdsec: {
mode: 'disabled' | 'local'
api_url: string
enabled: boolean
}
waf: {
mode: 'disabled' | 'enabled'
enabled: boolean
}
rate_limit: {
mode?: 'disabled' | 'enabled'
enabled: boolean
}
acl: {
enabled: boolean
}
}
/**
* Gets the current security status for all modules.
* @returns Promise resolving to SecurityStatus
* @throws {AxiosError} If the request fails
*/
export const getSecurityStatus = async (): Promise<SecurityStatus> => {
const response = await client.get<SecurityStatus>('/security/status')
return response.data
}
/** Security configuration payload. */
export interface SecurityConfigPayload {
name?: string
enabled?: boolean
admin_whitelist?: string
crowdsec_mode?: string
crowdsec_api_url?: string
waf_mode?: string
waf_rules_source?: string
waf_learning?: boolean
rate_limit_enable?: boolean
rate_limit_burst?: number
rate_limit_requests?: number
rate_limit_window_sec?: number
}
/**
* Gets the current security configuration.
* @returns Promise resolving to the security configuration
* @throws {AxiosError} If the request fails
*/
export const getSecurityConfig = async () => {
const response = await client.get('/security/config')
return response.data
}
/**
* Updates security configuration.
* @param payload - SecurityConfigPayload with settings to update
* @returns Promise resolving to the updated configuration
* @throws {AxiosError} If update fails
*/
export const updateSecurityConfig = async (payload: SecurityConfigPayload) => {
const response = await client.post('/security/config', payload)
return response.data
}
/**
* Generates a break-glass token for emergency access.
* @returns Promise resolving to object containing the token
* @throws {AxiosError} If generation fails
*/
export const generateBreakGlassToken = async () => {
const response = await client.post('/security/breakglass/generate')
return response.data
}
/**
* Enables the Cerberus security module.
* @param payload - Optional configuration for enabling
* @returns Promise resolving to enable result
* @throws {AxiosError} If enabling fails
*/
export const enableCerberus = async (payload?: Record<string, unknown>) => {
const response = await client.post('/security/enable', payload || {})
return response.data
}
/**
* Disables the Cerberus security module.
* @param payload - Optional configuration for disabling
* @returns Promise resolving to disable result
* @throws {AxiosError} If disabling fails
*/
export const disableCerberus = async (payload?: Record<string, unknown>) => {
const response = await client.post('/security/disable', payload || {})
return response.data
}
/**
* Gets security decisions (bans, captchas) with optional limit.
* @param limit - Maximum number of decisions to return (default: 50)
* @returns Promise resolving to decisions list
* @throws {AxiosError} If the request fails
*/
export const getDecisions = async (limit = 50) => {
const response = await client.get(`/security/decisions?limit=${limit}`)
return response.data
}
/** Payload for creating a security decision. */
export interface CreateDecisionPayload {
type: string
value: string
duration: string
reason?: string
}
/**
* Creates a new security decision (e.g., ban an IP).
* @param payload - Decision configuration
* @returns Promise resolving to the created decision
* @throws {AxiosError} If creation fails
*/
export const createDecision = async (payload: CreateDecisionPayload) => {
const response = await client.post('/security/decisions', payload)
return response.data
}
// WAF Ruleset types
/** WAF security ruleset configuration. */
export interface SecurityRuleSet {
id: number
uuid: string
name: string
source_url: string
mode: string
last_updated: string
content: string
}
/** Response containing WAF rulesets. */
export interface RuleSetsResponse {
rulesets: SecurityRuleSet[]
}
/** Payload for creating/updating a WAF ruleset. */
export interface UpsertRuleSetPayload {
id?: number
name: string
content?: string
source_url?: string
mode?: 'blocking' | 'detection'
}
/**
* Gets all WAF rulesets.
* @returns Promise resolving to RuleSetsResponse
* @throws {AxiosError} If the request fails
*/
export const getRuleSets = async (): Promise<RuleSetsResponse> => {
const response = await client.get<RuleSetsResponse>('/security/rulesets')
return response.data
}
/**
* Creates or updates a WAF ruleset.
* @param payload - Ruleset configuration
* @returns Promise resolving to the upserted ruleset
* @throws {AxiosError} If upsert fails
*/
export const upsertRuleSet = async (payload: UpsertRuleSetPayload) => {
const response = await client.post('/security/rulesets', payload)
return response.data
}
/**
* Deletes a WAF ruleset.
* @param id - The ruleset ID to delete
* @returns Promise resolving to delete result
* @throws {AxiosError} If deletion fails or ruleset not found
*/
export const deleteRuleSet = async (id: number) => {
const response = await client.delete(`/security/rulesets/${id}`)
return response.data
}
+188
View File
@@ -0,0 +1,188 @@
import client from './client';
// Types
export interface SecurityHeaderProfile {
id: number;
uuid: string;
name: string;
hsts_enabled: boolean;
hsts_max_age: number;
hsts_include_subdomains: boolean;
hsts_preload: boolean;
csp_enabled: boolean;
csp_directives: string;
csp_report_only: boolean;
csp_report_uri: string;
x_frame_options: string;
x_content_type_options: boolean;
referrer_policy: string;
permissions_policy: string;
cross_origin_opener_policy: string;
cross_origin_resource_policy: string;
cross_origin_embedder_policy: string;
xss_protection: boolean;
cache_control_no_store: boolean;
security_score: number;
is_preset: boolean;
preset_type: string;
description: string;
created_at: string;
updated_at: string;
}
export interface SecurityHeaderPreset {
preset_type: 'basic' | 'strict' | 'paranoid';
name: string;
description: string;
security_score: number;
config: Partial<SecurityHeaderProfile>;
}
export interface ScoreBreakdown {
score: number;
max_score: number;
breakdown: Record<string, number>;
suggestions: string[];
}
export interface CSPDirective {
directive: string;
values: string[];
}
export interface CreateProfileRequest {
name: string;
description?: string;
hsts_enabled?: boolean;
hsts_max_age?: number;
hsts_include_subdomains?: boolean;
hsts_preload?: boolean;
csp_enabled?: boolean;
csp_directives?: string;
csp_report_only?: boolean;
csp_report_uri?: string;
x_frame_options?: string;
x_content_type_options?: boolean;
referrer_policy?: string;
permissions_policy?: string;
cross_origin_opener_policy?: string;
cross_origin_resource_policy?: string;
cross_origin_embedder_policy?: string;
xss_protection?: boolean;
cache_control_no_store?: boolean;
}
export interface ApplyPresetRequest {
preset_type: string;
name: string;
}
// API Functions
export const securityHeadersApi = {
/**
* Lists all security header profiles.
* @returns Promise resolving to array of SecurityHeaderProfile objects
* @throws {AxiosError} If the request fails
*/
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<{profiles: SecurityHeaderProfile[]}>('/security/headers/profiles');
return response.data.profiles;
},
/**
* Gets a single security header profile by ID or UUID.
* @param id - The profile ID (number) or UUID (string)
* @returns Promise resolving to the SecurityHeaderProfile object
* @throws {AxiosError} If the request fails or profile not found
*/
async getProfile(id: number | string): Promise<SecurityHeaderProfile> {
const response = await client.get<{profile: SecurityHeaderProfile}>(`/security/headers/profiles/${id}`);
return response.data.profile;
},
/**
* Creates a new security header profile.
* @param data - CreateProfileRequest with profile configuration
* @returns Promise resolving to the created SecurityHeaderProfile
* @throws {AxiosError} If creation fails or validation errors occur
*/
async createProfile(data: CreateProfileRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<{profile: SecurityHeaderProfile}>('/security/headers/profiles', data);
return response.data.profile;
},
/**
* Updates an existing security header profile.
* @param id - The profile ID to update
* @param data - Partial CreateProfileRequest with fields to update
* @returns Promise resolving to the updated SecurityHeaderProfile
* @throws {AxiosError} If update fails or profile not found
*/
async updateProfile(id: number, data: Partial<CreateProfileRequest>): Promise<SecurityHeaderProfile> {
const response = await client.put<{profile: SecurityHeaderProfile}>(`/security/headers/profiles/${id}`, data);
return response.data.profile;
},
/**
* Deletes a security header profile.
* @param id - The profile ID to delete (cannot delete preset profiles)
* @throws {AxiosError} If deletion fails, profile not found, or is a preset
*/
async deleteProfile(id: number): Promise<void> {
await client.delete(`/security/headers/profiles/${id}`);
},
/**
* Gets all built-in security header presets.
* @returns Promise resolving to array of SecurityHeaderPreset objects
* @throws {AxiosError} If the request fails
*/
async getPresets(): Promise<SecurityHeaderPreset[]> {
const response = await client.get<{presets: SecurityHeaderPreset[]}>('/security/headers/presets');
return response.data.presets;
},
/**
* Applies a preset to create or update a security header profile.
* @param data - ApplyPresetRequest with preset type and profile name
* @returns Promise resolving to the created/updated SecurityHeaderProfile
* @throws {AxiosError} If preset application fails
*/
async applyPreset(data: ApplyPresetRequest): Promise<SecurityHeaderProfile> {
const response = await client.post<{profile: SecurityHeaderProfile}>('/security/headers/presets/apply', data);
return response.data.profile;
},
/**
* Calculates the security score for given header settings.
* @param config - Partial CreateProfileRequest with settings to evaluate
* @returns Promise resolving to ScoreBreakdown with score, max, breakdown, and suggestions
* @throws {AxiosError} If calculation fails
*/
async calculateScore(config: Partial<CreateProfileRequest>): Promise<ScoreBreakdown> {
const response = await client.post<ScoreBreakdown>('/security/headers/score', config);
return response.data;
},
/**
* Validates a Content Security Policy string.
* @param csp - The CSP string to validate
* @returns Promise resolving to object with validity status and any errors
* @throws {AxiosError} If validation request fails
*/
async validateCSP(csp: string): Promise<{ valid: boolean; errors: string[] }> {
const response = await client.post<{ valid: boolean; errors: string[] }>('/security/headers/csp/validate', { csp });
return response.data;
},
/**
* Builds a Content Security Policy string from directives.
* @param directives - Array of CSPDirective objects to combine
* @returns Promise resolving to object containing the built CSP string
* @throws {AxiosError} If build request fails
*/
async buildCSP(directives: CSPDirective[]): Promise<{ csp: string }> {
const response = await client.post<{ csp: string }>('/security/headers/csp/build', { directives });
return response.data;
},
};
+57
View File
@@ -0,0 +1,57 @@
import client from './client'
/** Map of setting keys to string values. */
export interface SettingsMap {
[key: string]: string
}
/**
* Fetches all application settings.
* @returns Promise resolving to SettingsMap
* @throws {AxiosError} If the request fails
*/
export const getSettings = async (): Promise<SettingsMap> => {
const response = await client.get('/settings')
return response.data
}
/**
* Updates a single application setting.
* @param key - The setting key to update
* @param value - The new value for the setting
* @param category - Optional category for organization
* @param type - Optional type hint for the setting
* @throws {AxiosError} If the update fails
*/
export const updateSetting = async (key: string, value: string, category?: string, type?: string): Promise<void> => {
await client.post('/settings', { key, value, category, type })
}
/**
* Validates a URL for use as the application URL.
* @param url - The URL to validate
* @returns Promise resolving to validation result
*/
export const validatePublicURL = async (url: string): Promise<{
valid: boolean
normalized?: string
error?: string
}> => {
const response = await client.post('/settings/validate-url', { url })
return response.data
}
/**
* Tests if a URL is reachable from the server with SSRF protection.
* @param url - The URL to test
* @returns Promise resolving to test result with reachability status and latency
*/
export const testPublicURL = async (url: string): Promise<{
reachable: boolean
latency?: number
message?: string
error?: string
}> => {
const response = await client.post('/settings/test-url', { url })
return response.data
}
+32
View File
@@ -0,0 +1,32 @@
import client from './client';
/** Status indicating if initial setup is required. */
export interface SetupStatus {
setupRequired: boolean;
}
/** Request payload for initial setup. */
export interface SetupRequest {
name: string;
email: string;
password: string;
}
/**
* Checks if initial setup is required.
* @returns Promise resolving to SetupStatus
* @throws {AxiosError} If the request fails
*/
export const getSetupStatus = async (): Promise<SetupStatus> => {
const response = await client.get<SetupStatus>('/setup');
return response.data;
};
/**
* Performs initial application setup with admin user creation.
* @param data - SetupRequest with admin user details
* @throws {AxiosError} If setup fails or already completed
*/
export const performSetup = async (data: SetupRequest): Promise<void> => {
await client.post('/setup', data);
};
+76
View File
@@ -0,0 +1,76 @@
import client from './client'
/** SMTP server configuration. */
export interface SMTPConfig {
host: string
port: number
username: string
password: string
from_address: string
encryption: 'none' | 'ssl' | 'starttls'
configured: boolean
}
/** Request payload for SMTP configuration. */
export interface SMTPConfigRequest {
host: string
port: number
username: string
password: string
from_address: string
encryption: 'none' | 'ssl' | 'starttls'
}
/** Request payload for sending a test email. */
export interface TestEmailRequest {
to: string
}
/** Result of an SMTP test operation. */
export interface SMTPTestResult {
success: boolean
message?: string
error?: string
}
/**
* Fetches the current SMTP configuration.
* @returns Promise resolving to SMTPConfig
* @throws {AxiosError} If the request fails
*/
export const getSMTPConfig = async (): Promise<SMTPConfig> => {
const response = await client.get<SMTPConfig>('/settings/smtp')
return response.data
}
/**
* Updates the SMTP configuration.
* @param config - SMTPConfigRequest with new settings
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails
*/
export const updateSMTPConfig = async (config: SMTPConfigRequest): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/settings/smtp', config)
return response.data
}
/**
* Tests the SMTP connection with current settings.
* @returns Promise resolving to SMTPTestResult
* @throws {AxiosError} If test request fails
*/
export const testSMTPConnection = async (): Promise<SMTPTestResult> => {
const response = await client.post<SMTPTestResult>('/settings/smtp/test')
return response.data
}
/**
* Sends a test email to verify SMTP configuration.
* @param request - TestEmailRequest with recipient address
* @returns Promise resolving to SMTPTestResult
* @throws {AxiosError} If sending fails
*/
export const sendTestEmail = async (request: TestEmailRequest): Promise<SMTPTestResult> => {
const response = await client.post<SMTPTestResult>('/settings/smtp/test-email', request)
return response.data
}
+72
View File
@@ -0,0 +1,72 @@
import client from './client';
/** Update availability information. */
export interface UpdateInfo {
available: boolean;
latest_version: string;
changelog_url: string;
}
/** System notification entry. */
export interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
read: boolean;
created_at: string;
}
/**
* Checks for available application updates.
* @returns Promise resolving to UpdateInfo
* @throws {AxiosError} If the request fails
*/
export const checkUpdates = async (): Promise<UpdateInfo> => {
const response = await client.get('/system/updates');
return response.data;
};
/**
* Fetches system notifications.
* @param unreadOnly - If true, only returns unread notifications
* @returns Promise resolving to array of Notification objects
* @throws {AxiosError} If the request fails
*/
export const getNotifications = async (unreadOnly = false): Promise<Notification[]> => {
const response = await client.get('/notifications', { params: { unread: unreadOnly } });
return response.data;
};
/**
* Marks a notification as read.
* @param id - The notification ID to mark as read
* @throws {AxiosError} If marking fails or notification not found
*/
export const markNotificationRead = async (id: string): Promise<void> => {
await client.post(`/notifications/${id}/read`);
};
/**
* Marks all notifications as read.
* @throws {AxiosError} If the request fails
*/
export const markAllNotificationsRead = async (): Promise<void> => {
await client.post('/notifications/read-all');
};
/** Response containing the client's public IP address. */
export interface MyIPResponse {
ip: string;
source: string;
}
/**
* Gets the client's public IP address as seen by the server.
* @returns Promise resolving to MyIPResponse with IP address
* @throws {AxiosError} If the request fails
*/
export const getMyIP = async (): Promise<MyIPResponse> => {
const response = await client.get<MyIPResponse>('/system/my-ip');
return response.data;
};
+112
View File
@@ -0,0 +1,112 @@
import client from './client';
/** Uptime monitor configuration. */
export interface UptimeMonitor {
id: string;
upstream_host?: string;
proxy_host_id?: number;
remote_server_id?: number;
name: string;
type: string;
url: string;
interval: number;
enabled: boolean;
status: string;
last_check?: string | null;
latency: number;
max_retries: number;
}
/** Uptime heartbeat (check result) entry. */
export interface UptimeHeartbeat {
id: number;
monitor_id: string;
status: string;
latency: number;
message: string;
created_at: string;
}
/**
* Fetches all uptime monitors.
* @returns Promise resolving to array of UptimeMonitor objects
* @throws {AxiosError} If the request fails
*/
export const getMonitors = async () => {
const response = await client.get<UptimeMonitor[]>('/uptime/monitors');
return response.data;
};
/**
* Fetches heartbeat history for a monitor.
* @param id - The monitor ID
* @param limit - Maximum number of heartbeats to return (default: 50)
* @returns Promise resolving to array of UptimeHeartbeat objects
* @throws {AxiosError} If the request fails or monitor not found
*/
export const getMonitorHistory = async (id: string, limit: number = 50) => {
const response = await client.get<UptimeHeartbeat[]>(`/uptime/monitors/${id}/history?limit=${limit}`);
return response.data;
};
/**
* Updates an uptime monitor configuration.
* @param id - The monitor ID to update
* @param data - Partial UptimeMonitor with fields to update
* @returns Promise resolving to the updated UptimeMonitor
* @throws {AxiosError} If update fails or monitor not found
*/
export const updateMonitor = async (id: string, data: Partial<UptimeMonitor>) => {
const response = await client.put<UptimeMonitor>(`/uptime/monitors/${id}`, data);
return response.data;
};
/**
* Deletes an uptime monitor.
* @param id - The monitor ID to delete
* @returns Promise resolving to void
* @throws {AxiosError} If deletion fails or monitor not found
*/
export const deleteMonitor = async (id: string) => {
const response = await client.delete<void>(`/uptime/monitors/${id}`);
return response.data;
};
/**
* Creates a new uptime monitor.
* @param data - Monitor configuration (name, url, type, interval, max_retries)
* @returns Promise resolving to the created UptimeMonitor
* @throws {AxiosError} If creation fails
*/
export const createMonitor = async (data: {
name: string;
url: string;
type: string;
interval?: number;
max_retries?: number;
}): Promise<UptimeMonitor> => {
const response = await client.post<UptimeMonitor>('/uptime/monitors', data);
return response.data;
};
/**
* Syncs monitors with proxy hosts and remote servers.
* @param body - Optional configuration for sync (interval, max_retries)
* @returns Promise resolving to sync result with message
* @throws {AxiosError} If sync fails
*/
export async function syncMonitors(body?: { interval?: number; max_retries?: number }): Promise<{ message: string }> {
const res = await client.post<{ message: string }>('/uptime/sync', body || {});
return res.data;
}
/**
* Triggers an immediate check for a monitor.
* @param id - The monitor ID to check
* @returns Promise resolving to object with result message
* @throws {AxiosError} If check fails or monitor not found
*/
export const checkMonitor = async (id: string) => {
const response = await client.post<{ message: string }>(`/uptime/monitors/${id}/check`);
return response.data;
};
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import client from './client'
import {
listUsers,
getUser,
createUser,
inviteUser,
updateUser,
deleteUser,
updateUserPermissions,
validateInvite,
acceptInvite,
} from './users'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
describe('users api', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lists and fetches users', async () => {
mockedClient.get
.mockResolvedValueOnce({ data: [{ id: 1, uuid: 'u1', email: 'a@example.com', name: 'A', role: 'admin', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' }] })
.mockResolvedValueOnce({ data: { id: 2, uuid: 'u2', email: 'b@example.com', name: 'B', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } })
const users = await listUsers()
expect(mockedClient.get).toHaveBeenCalledWith('/users')
expect(users[0].email).toBe('a@example.com')
const user = await getUser(2)
expect(mockedClient.get).toHaveBeenCalledWith('/users/2')
expect(user.uuid).toBe('u2')
})
it('creates, invites, updates, and deletes users', async () => {
mockedClient.post
.mockResolvedValueOnce({ data: { id: 3, uuid: 'u3', email: 'c@example.com', name: 'C', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } })
.mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token_masked: '********', invite_url: '[REDACTED]', email_sent: true, expires_at: '' } })
mockedClient.put.mockResolvedValueOnce({ data: { message: 'updated' } })
mockedClient.delete.mockResolvedValueOnce({ data: { message: 'deleted' } })
const created = await createUser({ email: 'c@example.com', name: 'C', password: 'pw' })
expect(mockedClient.post).toHaveBeenCalledWith('/users', { email: 'c@example.com', name: 'C', password: 'pw' })
expect(created.id).toBe(3)
const invite = await inviteUser({ email: 'invite@example.com', role: 'user' })
expect(mockedClient.post).toHaveBeenCalledWith('/users/invite', { email: 'invite@example.com', role: 'user' })
expect(invite.invite_token_masked).toBe('********')
await updateUser(3, { enabled: false })
expect(mockedClient.put).toHaveBeenCalledWith('/users/3', { enabled: false })
await deleteUser(3)
expect(mockedClient.delete).toHaveBeenCalledWith('/users/3')
})
it('updates permissions and validates/accepts invites', async () => {
mockedClient.put.mockResolvedValueOnce({ data: { message: 'perms updated' } })
mockedClient.get.mockResolvedValueOnce({ data: { valid: true, email: 'invite@example.com' } })
mockedClient.post.mockResolvedValueOnce({ data: { message: 'accepted', email: 'invite@example.com' } })
const perms = await updateUserPermissions(5, { permission_mode: 'deny_all', permitted_hosts: [1, 2] })
expect(mockedClient.put).toHaveBeenCalledWith('/users/5/permissions', {
permission_mode: 'deny_all',
permitted_hosts: [1, 2],
})
expect(perms.message).toBe('perms updated')
const validation = await validateInvite('token-abc')
expect(mockedClient.get).toHaveBeenCalledWith('/invite/validate', { params: { token: 'token-abc' } })
expect(validation.valid).toBe(true)
const accept = await acceptInvite({ token: 'token-abc', name: 'New', password: 'pw' })
expect(mockedClient.post).toHaveBeenCalledWith('/invite/accept', { token: 'token-abc', name: 'New', password: 'pw' })
expect(accept.message).toBe('accepted')
})
})
+262
View File
@@ -0,0 +1,262 @@
import client from './client'
/** User permission mode type. */
export type PermissionMode = 'allow_all' | 'deny_all'
/** User account information. */
export interface User {
id: number
uuid: string
email: string
name: string
role: 'admin' | 'user' | 'passthrough'
enabled: boolean
last_login?: string
invite_status?: 'pending' | 'accepted' | 'expired'
invited_at?: string
permission_mode: PermissionMode
permitted_hosts?: number[]
created_at: string
updated_at: string
}
/** Request payload for creating a user. */
export interface CreateUserRequest {
email: string
name: string
password: string
role?: string
permission_mode?: PermissionMode
permitted_hosts?: number[]
}
/** Request payload for inviting a user. */
export interface InviteUserRequest {
email: string
role?: string
permission_mode?: PermissionMode
permitted_hosts?: number[]
}
/** Response from user invitation. */
export interface InviteUserResponse {
id: number
uuid: string
email: string
role: string
invite_token_masked: string
invite_url?: string
email_sent: boolean
expires_at: string
}
/** Request payload for updating a user. */
export interface UpdateUserRequest {
name?: string
email?: string
role?: string
enabled?: boolean
}
/** Request payload for updating user permissions. */
export interface UpdateUserPermissionsRequest {
permission_mode: PermissionMode
permitted_hosts: number[]
}
/** Response from invite validation. */
export interface ValidateInviteResponse {
valid: boolean
email: string
}
/** Request payload for accepting an invitation. */
export interface AcceptInviteRequest {
token: string
name: string
password: string
}
/**
* Lists all users.
* @returns Promise resolving to array of User objects
* @throws {AxiosError} If the request fails
*/
export const listUsers = async (): Promise<User[]> => {
const response = await client.get<User[]>('/users')
return response.data
}
/**
* Fetches a single user by ID.
* @param id - The user ID
* @returns Promise resolving to the User object
* @throws {AxiosError} If the request fails or user not found
*/
export const getUser = async (id: number): Promise<User> => {
const response = await client.get<User>(`/users/${id}`)
return response.data
}
/**
* Creates a new user.
* @param data - CreateUserRequest with user details
* @returns Promise resolving to the created User
* @throws {AxiosError} If creation fails or email already exists
*/
export const createUser = async (data: CreateUserRequest): Promise<User> => {
const response = await client.post<User>('/users', data)
return response.data
}
/**
* Invites a new user via email.
* @param data - InviteUserRequest with invitation details
* @returns Promise resolving to InviteUserResponse with token
* @throws {AxiosError} If invitation fails
*/
export const inviteUser = async (data: InviteUserRequest): Promise<InviteUserResponse> => {
const response = await client.post<InviteUserResponse>('/users/invite', data)
return response.data
}
/**
* Updates an existing user.
* @param id - The user ID to update
* @param data - UpdateUserRequest with fields to update
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails or user not found
*/
export const updateUser = async (id: number, data: UpdateUserRequest): Promise<{ message: string }> => {
const response = await client.put<{ message: string }>(`/users/${id}`, data)
return response.data
}
/**
* Deletes a user.
* @param id - The user ID to delete
* @returns Promise resolving to success message
* @throws {AxiosError} If deletion fails or user not found
*/
export const deleteUser = async (id: number): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>(`/users/${id}`)
return response.data
}
/**
* Updates a user's permissions.
* @param id - The user ID to update
* @param data - UpdateUserPermissionsRequest with new permissions
* @returns Promise resolving to success message
* @throws {AxiosError} If update fails or user not found
*/
export const updateUserPermissions = async (
id: number,
data: UpdateUserPermissionsRequest
): Promise<{ message: string }> => {
const response = await client.put<{ message: string }>(`/users/${id}/permissions`, data)
return response.data
}
// Public endpoints (no auth required)
/**
* Validates an invitation token.
* @param token - The invitation token to validate
* @returns Promise resolving to ValidateInviteResponse
* @throws {AxiosError} If validation fails
*/
export const validateInvite = async (token: string): Promise<ValidateInviteResponse> => {
const response = await client.get<ValidateInviteResponse>('/invite/validate', {
params: { token }
})
return response.data
}
/**
* Accepts an invitation and creates the user account.
* @param data - AcceptInviteRequest with token and user details
* @returns Promise resolving to success message and email
* @throws {AxiosError} If acceptance fails or token invalid/expired
*/
export const acceptInvite = async (data: AcceptInviteRequest): Promise<{ message: string; email: string }> => {
const response = await client.post<{ message: string; email: string }>('/invite/accept', data)
return response.data
}
/** Response from invite URL preview. */
export interface PreviewInviteURLResponse {
preview_url: string
base_url: string
is_configured: boolean
email: string
warning: boolean
warning_message: string
}
/**
* Previews what the invite URL will look like for a given email.
* @param email - The email to preview
* @returns Promise resolving to PreviewInviteURLResponse
*/
export const previewInviteURL = async (email: string): Promise<PreviewInviteURLResponse> => {
const response = await client.post<PreviewInviteURLResponse>('/users/preview-invite-url', { email })
return response.data
}
/**
* Resends an invitation email to a pending user.
* @param id - The user ID to resend invite to
* @returns Promise resolving to InviteUserResponse with new token
*/
export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
return response.data
}
// --- Self-service profile endpoints (merged from api/user.ts) ---
/** Current user profile information. */
export interface UserProfile {
id: number
email: string
name: string
role: 'admin' | 'user' | 'passthrough'
has_api_key: boolean
api_key_masked: string
}
/** Response from API key regeneration. */
export interface RegenerateApiKeyResponse {
message: string
has_api_key: boolean
api_key_masked: string
api_key_updated: string
}
/**
* Fetches the current user's profile.
* @returns Promise resolving to UserProfile
*/
export const getProfile = async (): Promise<UserProfile> => {
const response = await client.get<UserProfile>('/user/profile')
return response.data
}
/**
* Updates the current user's profile.
* @param data - Object with name, email, and optional current_password for verification
* @returns Promise resolving to success message
*/
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/user/profile', data)
return response.data
}
/**
* Regenerates the current user's API key.
* @returns Promise resolving to object containing the new API key
*/
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
return response.data
}
+47
View File
@@ -0,0 +1,47 @@
import client from './client';
/** Information about a WebSocket connection. */
export interface ConnectionInfo {
id: string;
type: 'logs' | 'cerberus';
connected_at: string;
last_activity_at: string;
remote_addr?: string;
user_agent?: string;
filters?: string;
}
/** Aggregate statistics for WebSocket connections. */
export interface ConnectionStats {
total_active: number;
logs_connections: number;
cerberus_connections: number;
oldest_connection?: string;
last_updated: string;
}
/** Response containing WebSocket connections list. */
export interface ConnectionsResponse {
connections: ConnectionInfo[];
count: number;
}
/**
* Gets all active WebSocket connections.
* @returns Promise resolving to ConnectionsResponse with connections list
* @throws {AxiosError} If the request fails
*/
export const getWebSocketConnections = async (): Promise<ConnectionsResponse> => {
const response = await client.get('/websocket/connections');
return response.data;
};
/**
* Gets aggregate WebSocket connection statistics.
* @returns Promise resolving to ConnectionStats
* @throws {AxiosError} If the request fails
*/
export const getWebSocketStats = async (): Promise<ConnectionStats> => {
const response = await client.get('/websocket/stats');
return response.data;
};
+558
View File
@@ -0,0 +1,558 @@
import { useState } from 'react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Switch } from './ui/Switch';
import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download, Trash2 } from 'lucide-react';
import type { AccessList, AccessListRule } from '../api/accessLists';
import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets';
import { getMyIP } from '../api/system';
import toast from 'react-hot-toast';
interface AccessListFormProps {
initialData?: AccessList;
onSubmit: (data: AccessListFormData) => void;
onCancel: () => void;
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
}
export interface AccessListFormData {
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string;
country_codes: string;
local_network_only: boolean;
enabled: boolean;
}
const COUNTRIES = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'BE', name: 'Belgium' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'FI', name: 'Finland' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'AT', name: 'Austria' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'AU', name: 'Australia' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' },
{ code: 'IN', name: 'India' },
{ code: 'BR', name: 'Brazil' },
{ code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' },
{ code: 'RU', name: 'Russia' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'TR', name: 'Turkey' },
{ code: 'IL', name: 'Israel' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'EG', name: 'Egypt' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'TH', name: 'Thailand' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'PH', name: 'Philippines' },
{ code: 'VN', name: 'Vietnam' },
];
export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) {
const [formData, setFormData] = useState<AccessListFormData>({
name: initialData?.name || '',
description: initialData?.description || '',
type: initialData?.type || 'whitelist',
ip_rules: initialData?.ip_rules || '',
country_codes: initialData?.country_codes || '',
local_network_only: initialData?.local_network_only || false,
enabled: initialData?.enabled ?? true,
});
const [ipRules, setIPRules] = useState<AccessListRule[]>(() => {
if (initialData?.ip_rules) {
try {
return JSON.parse(initialData.ip_rules);
} catch {
return [];
}
}
return [];
});
const [selectedCountries, setSelectedCountries] = useState<string[]>(() => {
if (initialData?.country_codes) {
return initialData.country_codes.split(',').map((c) => c.trim());
}
return [];
});
const [newIP, setNewIP] = useState('');
const [newIPDescription, setNewIPDescription] = useState('');
const [showPresets, setShowPresets] = useState(false);
const [loadingMyIP, setLoadingMyIP] = useState(false);
const isGeoType = formData.type.startsWith('geo_');
const isIPType = !isGeoType;
// Calculate total IPs in current rules
const totalIPs = isIPType && !formData.local_network_only
? calculateTotalIPs(ipRules.map(r => r.cidr))
: 0;
const handleAddIP = () => {
if (!newIP.trim()) return;
const newRule: AccessListRule = {
cidr: newIP.trim(),
description: newIPDescription.trim(),
};
const updatedRules = [...ipRules, newRule];
setIPRules(updatedRules);
setNewIP('');
setNewIPDescription('');
};
const handleRemoveIP = (index: number) => {
setIPRules(ipRules.filter((_, i) => i !== index));
};
const handleAddCountry = (countryCode: string) => {
if (!selectedCountries.includes(countryCode)) {
setSelectedCountries([...selectedCountries, countryCode]);
}
};
const handleRemoveCountry = (countryCode: string) => {
setSelectedCountries(selectedCountries.filter((c) => c !== countryCode));
};
const handleApplyPreset = (preset: SecurityPreset) => {
if (preset.type === 'geo_blacklist' && preset.countryCodes) {
setFormData({ ...formData, type: 'geo_blacklist' });
setSelectedCountries([...new Set([...selectedCountries, ...preset.countryCodes])]);
toast.success(`Applied preset: ${preset.name}`);
} else if (preset.type === 'blacklist' && preset.ipRanges) {
setFormData({ ...formData, type: 'blacklist' });
const newRules = preset.ipRanges.filter(
(newRule) => !ipRules.some((existing) => existing.cidr === newRule.cidr)
);
setIPRules([...ipRules, ...newRules]);
toast.success(`Applied preset: ${preset.name} (${newRules.length} rules added)`);
}
setShowPresets(false);
};
const handleGetMyIP = async () => {
setLoadingMyIP(true);
try {
const result = await getMyIP();
setNewIP(result.ip);
toast.success(`Your IP: ${result.ip} (from ${result.source})`);
} catch {
toast.error('Failed to fetch your IP address');
} finally {
setLoadingMyIP(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: AccessListFormData = {
...formData,
ip_rules: isIPType && !formData.local_network_only ? JSON.stringify(ipRules) : '',
country_codes: isGeoType ? selectedCountries.join(',') : '',
};
onSubmit(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Name *
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Access List"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description"
rows={2}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
Type *
<a
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
>
<ExternalLink className="inline h-3 w-3" /> Best Practices
</a>
</label>
<select
id="type"
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist', local_network_only: false })
}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="whitelist">🛡 IP Whitelist (Allow Only)</option>
<option value="blacklist"> IP Blacklist (Block Only) - Recommended</option>
<option value="geo_whitelist">🌍 Geo Whitelist (Allow Countries)</option>
<option value="geo_blacklist">🌍 Geo Blacklist (Block Countries) - Recommended</option>
</select>
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
<div className="mt-2 flex items-start gap-2 p-3 bg-blue-900/20 border border-blue-700/50 rounded-lg">
<Info className="h-4 w-4 text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-300">
<strong>Recommended:</strong> Block lists are safer than allow lists. They block known bad actors while allowing everyone else access, preventing lockouts.
</p>
</div>
)}
</div>
{/* Security Presets */}
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-green-400" />
<h3 className="text-sm font-medium text-gray-300">Security Presets</h3>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowPresets(!showPresets)}
>
{showPresets ? 'Hide' : 'Show'} Presets
</Button>
</div>
{showPresets && (
<div className="space-y-3 mt-4">
<p className="text-xs text-gray-400 mb-3">
Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources.
</p>
{/* Security Category - filter by current type */}
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Recommended Security Presets</h4>
<div className="space-y-2">
{SECURITY_PRESETS.filter(p => p.category === 'security' && p.type === formData.type).map((preset) => (
<div
key={preset.id}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
<a
href={preset.dataSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-400"
title={`Data from: ${preset.dataSource}`}
>
<Info className="h-3 w-3" />
</a>
</div>
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
<span className="text-gray-600">|</span>
<span className="text-gray-500">{preset.dataSource}</span>
</div>
{preset.warning && (
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{preset.warning}</span>
</div>
)}
</div>
<Button
type="button"
size="sm"
onClick={() => handleApplyPreset(preset)}
className="ml-3"
>
Apply
</Button>
</div>
</div>
))}
</div>
</div>
{/* Advanced Category - filter by current type */}
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Advanced Presets</h4>
<div className="space-y-2">
{SECURITY_PRESETS.filter(p => p.category === 'advanced' && p.type === formData.type).map((preset) => (
<div
key={preset.id}
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
<a
href={preset.dataSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-400"
title={`Data from: ${preset.dataSource}`}
>
<Info className="h-3 w-3" />
</a>
</div>
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
<span className="text-gray-600">|</span>
<span className="text-gray-500">{preset.dataSource}</span>
</div>
{preset.warning && (
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{preset.warning}</span>
</div>
)}
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => handleApplyPreset(preset)}
className="ml-3"
>
Apply
</Button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
<div className="flex items-center justify-between">
<div>
<label id="access-list-enabled-label" className="block text-sm font-medium text-gray-300">Enabled</label>
<p className="text-xs text-gray-500">Apply this access list to hosts</p>
</div>
<Switch
aria-labelledby="access-list-enabled-label"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
{/* IP-based Rules */}
{isIPType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<label id="access-list-local-network-label" className="block text-sm font-medium text-gray-300">Local Network Only (RFC1918)</label>
<p className="text-xs text-gray-500">
Allow only private network IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
</p>
</div>
<Switch
aria-labelledby="access-list-local-network-label"
checked={formData.local_network_only}
onCheckedChange={(checked) =>
setFormData({ ...formData, local_network_only: checked })
}
/>
</div>
{!formData.local_network_only && (
<>
<div className="mb-2 text-xs text-gray-500">
Note: IP-based blocklists (botnets, cloud scanners, VPN ranges) are better handled by CrowdSec, WAF, or rate limiting. Use IP-based ACLs sparingly for static or known ranges.
</div>
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">IP Addresses / CIDR Ranges</label>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleGetMyIP}
disabled={loadingMyIP}
className="flex items-center gap-1"
>
<Download className="h-3 w-3" />
{loadingMyIP ? 'Loading...' : 'Get My IP'}
</Button>
</div>
<div className="flex gap-2">
<Input
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
placeholder="192.168.1.0/24 or 10.0.0.1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Input
value={newIPDescription}
onChange={(e) => setNewIPDescription(e.target.value)}
placeholder="Description (optional)"
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Button type="button" onClick={handleAddIP} size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
{totalIPs > 0 && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<Info className="h-3 w-3" />
<span>Current rules cover approximately <strong className="text-white">{formatIPCount(totalIPs)}</strong> IP addresses</span>
</div>
)}
</div>
{ipRules.length > 0 && (
<div className="space-y-2">
{ipRules.map((rule, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg border border-gray-600 bg-gray-700"
>
<div>
<p className="font-mono text-sm text-white">{rule.cidr}</p>
{rule.description && (
<p className="text-xs text-gray-400">{rule.description}</p>
)}
</div>
<button
type="button"
onClick={() => handleRemoveIP(index)}
className="text-gray-400 hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
{/* Geo-blocking Rules */}
{isGeoType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div>
<label htmlFor="country-select" className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
<select
id="country-select"
onChange={(e) => {
if (e.target.value) {
handleAddCountry(e.target.value);
e.target.value = '';
}
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Add a country...</option>
{COUNTRIES.filter((c) => !selectedCountries.includes(c.code)).map((country) => (
<option key={country.code} value={country.code}>
{country.name} ({country.code})
</option>
))}
</select>
</div>
{selectedCountries.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCountries.map((code) => {
const country = COUNTRIES.find((c) => c.code === code);
return (
<span
key={code}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-gray-700 text-gray-200 border border-gray-600"
>
{country?.name || code}
<X
className="h-3 w-3 cursor-pointer hover:text-red-400"
onClick={() => handleRemoveCountry(code)}
/>
</span>
);
})}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-between gap-2">
<div>
{initialData && onDelete && (
<Button
type="button"
variant="danger"
onClick={onDelete}
disabled={isLoading || isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onCancel} disabled={isLoading || isDeleting}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isLoading || isDeleting}>
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
</Button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,199 @@
import { useAccessLists } from '../hooks/useAccessLists';
import { ExternalLink } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/Select';
interface AccessListSelectorProps {
value: number | string | null;
onChange: (id: number | string | null) => void;
}
function resolveAccessListToken(
value: number | string | null | undefined,
accessLists?: Array<{ id?: number | string; uuid?: string }>
): string {
if (value === null || value === undefined) {
return 'none';
}
if (typeof value === 'number') {
return `id:${value}`;
}
const trimmed = value.trim();
if (trimmed === '') {
return 'none';
}
if (trimmed.startsWith('id:')) {
return trimmed;
}
if (trimmed.startsWith('uuid:')) {
const uuid = trimmed.slice(5);
const matchingACL = accessLists?.find((acl) => acl.uuid === uuid);
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
return matchingToken ?? trimmed;
}
if (/^\d+$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
return `id:${parsed}`;
}
const matchingACL = accessLists?.find((acl) => acl.uuid === trimmed);
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
return matchingToken ?? `uuid:${trimmed}`;
}
function getOptionToken(acl: { id?: number | string; uuid?: string }): string | null {
if (typeof acl.id === 'number' && Number.isFinite(acl.id)) {
return `id:${acl.id}`;
}
if (typeof acl.id === 'string') {
const trimmed = acl.id.trim();
if (trimmed !== '' && /^\d+$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isNaN(parsed)) {
return `id:${parsed}`;
}
}
}
if (acl.uuid) {
return `uuid:${acl.uuid}`;
}
return null;
}
export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) {
const { data: accessLists } = useAccessLists();
const selectedToken = resolveAccessListToken(value, accessLists);
const selectedACL = accessLists?.find((acl) => getOptionToken(acl) === selectedToken);
// Keep select value stable for both numeric-ID and UUID-only payload shapes.
const selectValue = selectedToken;
const handleValueChange = (newValue: string) => {
if (newValue === 'none') {
onChange(null);
return;
}
if (newValue.startsWith('id:')) {
const numericId = Number.parseInt(newValue.slice(3), 10);
if (!Number.isNaN(numericId)) {
onChange(numericId);
}
return;
}
if (newValue.startsWith('uuid:')) {
const selectedUUID = newValue.slice(5);
const matchingACL = accessLists?.find((acl) => acl.uuid === selectedUUID);
const matchingToken = matchingACL ? getOptionToken(matchingACL) : null;
if (matchingToken?.startsWith('id:')) {
const numericId = Number.parseInt(matchingToken.slice(3), 10);
if (!Number.isNaN(numericId)) {
onChange(numericId);
return;
}
}
onChange(selectedUUID);
return;
}
if (/^\d+$/.test(newValue)) {
const numericId = Number.parseInt(newValue, 10);
onChange(numericId);
return;
}
onChange(newValue);
};
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Access Control List
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<Select
value={selectValue}
onValueChange={handleValueChange}
>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Access Control List">
<SelectValue placeholder="Select an ACL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Access Control (Public)</SelectItem>
{accessLists
?.filter((acl) => acl.enabled)
.map((acl) => {
const optionToken = getOptionToken(acl);
if (!optionToken) {
return null;
}
return (
<SelectItem key={optionToken} value={optionToken}>
{acl.name} ({acl.type.replace('_', ' ')})
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedACL && (
<div className="mt-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-200">{selectedACL.name}</span>
<span className="px-2 py-0.5 text-xs bg-gray-700 border border-gray-600 rounded">
{selectedACL.type.replace('_', ' ')}
</span>
</div>
{selectedACL.description && (
<p className="text-xs text-gray-400 mb-2">{selectedACL.description}</p>
)}
{selectedACL.local_network_only && (
<div className="text-xs text-blue-400">
🏠 Local Network Only (RFC1918)
</div>
)}
{selectedACL.type.startsWith('geo_') && selectedACL.country_codes && (
<div className="text-xs text-gray-400">
🌍 Countries: {selectedACL.country_codes}
</div>
)}
</div>
)}
<p className="text-xs text-gray-500 mt-1">
Restrict access based on IP address, CIDR ranges, or geographic location.{' '}
<a href="/security/access-lists" className="text-blue-400 hover:underline">
Manage lists
</a>
{' • '}
<a
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="inline h-3 w-3" />
Best Practices
</a>
</p>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { useState, useEffect } from 'react';
import { Plus, X, AlertCircle, Check, Code } from 'lucide-react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Card } from './ui/Card';
import { Badge } from './ui/Badge';
import { Alert } from './ui/Alert';
import type { CSPDirective } from '../api/securityHeaders';
interface CSPBuilderProps {
value: string; // JSON string of CSPDirective[]
onChange: (value: string) => void;
onValidate?: (valid: boolean, errors: string[]) => void;
}
const CSP_DIRECTIVES = [
'default-src',
'script-src',
'style-src',
'img-src',
'font-src',
'connect-src',
'frame-src',
'object-src',
'media-src',
'worker-src',
'form-action',
'base-uri',
'frame-ancestors',
'manifest-src',
'prefetch-src',
];
const CSP_VALUES = [
"'self'",
"'none'",
"'unsafe-inline'",
"'unsafe-eval'",
'data:',
'https:',
'http:',
'blob:',
'filesystem:',
"'strict-dynamic'",
"'report-sample'",
"'unsafe-hashes'",
];
const CSP_PRESETS: Record<string, CSPDirective[]> = {
'Strict Default': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
{ directive: 'connect-src', values: ["'self'"] },
{ directive: 'frame-src', values: ["'none'"] },
{ directive: 'object-src', values: ["'none'"] },
],
'Allow Inline Styles': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
{ directive: 'font-src', values: ["'self'", 'data:'] },
],
'Development Mode': [
{ directive: 'default-src', values: ["'self'"] },
{ directive: 'script-src', values: ["'self'", "'unsafe-inline'", "'unsafe-eval'"] },
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
{ directive: 'img-src', values: ["'self'", 'data:', 'https:', 'http:'] },
],
};
export function CSPBuilder({ value, onChange, onValidate }: CSPBuilderProps) {
const [directives, setDirectives] = useState<CSPDirective[]>([]);
const [newDirective, setNewDirective] = useState('default-src');
const [newValue, setNewValue] = useState('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [showPreview, setShowPreview] = useState(false);
// Parse initial value
useEffect(() => {
try {
if (value) {
const parsed = JSON.parse(value) as CSPDirective[];
setDirectives(parsed);
} else {
setDirectives([]);
}
} catch {
setDirectives([]);
}
}, [value]);
// Generate CSP string preview
const generateCSPString = (dirs: CSPDirective[]): string => {
return dirs
.map((dir) => `${dir.directive} ${dir.values.join(' ')}`)
.join('; ');
};
const cspString = generateCSPString(directives);
// Update parent component
const updateDirectives = (newDirectives: CSPDirective[]) => {
setDirectives(newDirectives);
onChange(JSON.stringify(newDirectives));
validateCSP(newDirectives);
};
const validateCSP = (dirs: CSPDirective[]) => {
const errors: string[] = [];
// Check for duplicate directives
const directiveNames = dirs.map((d) => d.directive);
const duplicates = directiveNames.filter((name, index) => directiveNames.indexOf(name) !== index);
if (duplicates.length > 0) {
errors.push(`Duplicate directives found: ${duplicates.join(', ')}`);
}
// Check for dangerous combinations
const hasUnsafeInline = dirs.some((d) =>
d.values.some((v) => v === "'unsafe-inline'" || v === "'unsafe-eval'")
);
if (hasUnsafeInline) {
errors.push('Using unsafe-inline or unsafe-eval weakens CSP protection');
}
// Check if default-src is set
const hasDefaultSrc = dirs.some((d) => d.directive === 'default-src');
if (!hasDefaultSrc && dirs.length > 0) {
errors.push('Consider setting default-src as a fallback for all directives');
}
setValidationErrors(errors);
onValidate?.(errors.length === 0, errors);
};
const handleAddDirective = () => {
if (!newValue.trim()) return;
const existingIndex = directives.findIndex((d) => d.directive === newDirective);
let updated: CSPDirective[];
if (existingIndex >= 0) {
// Add to existing directive
const existing = directives[existingIndex];
if (!existing.values.includes(newValue.trim())) {
const updatedDirective = {
...existing,
values: [...existing.values, newValue.trim()],
};
updated = [
...directives.slice(0, existingIndex),
updatedDirective,
...directives.slice(existingIndex + 1),
];
} else {
return; // Value already exists
}
} else {
// Create new directive
updated = [...directives, { directive: newDirective, values: [newValue.trim()] }];
}
updateDirectives(updated);
setNewValue('');
};
const handleRemoveDirective = (directive: string) => {
updateDirectives(directives.filter((d) => d.directive !== directive));
};
const handleRemoveValue = (directive: string, value: string) => {
updateDirectives(
directives.map((d) =>
d.directive === directive
? { ...d, values: d.values.filter((v) => v !== value) }
: d
).filter((d) => d.values.length > 0)
);
};
const handleApplyPreset = (presetName: string) => {
const preset = CSP_PRESETS[presetName];
if (preset) {
updateDirectives(preset);
}
};
return (
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Content Security Policy Builder</h3>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Code className="w-4 h-4 mr-2" />
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
{/* Preset Buttons */}
<div className="flex flex-wrap gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 self-center">Quick Presets:</span>
{Object.keys(CSP_PRESETS).map((presetName) => (
<Button
key={presetName}
variant="outline"
size="sm"
onClick={() => handleApplyPreset(presetName)}
>
{presetName}
</Button>
))}
</div>
{/* Add Directive Form */}
<div className="flex gap-2">
<NativeSelect
value={newDirective}
onChange={(e) => setNewDirective(e.target.value)}
className="w-48"
>
{CSP_DIRECTIVES.map((dir) => (
<option key={dir} value={dir}>
{dir}
</option>
))}
</NativeSelect>
<div className="flex-1 flex gap-2">
<Input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddDirective()}
placeholder="Enter value or select from suggestions..."
list="csp-values"
/>
<datalist id="csp-values">
{CSP_VALUES.map((val) => (
<option key={val} value={val} />
))}
</datalist>
<Button onClick={handleAddDirective} disabled={!newValue.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Current Directives */}
<div className="space-y-2">
{directives.length === 0 ? (
<Alert variant="info">
<AlertCircle className="w-4 h-4" />
<span>No CSP directives configured. Add directives above to build your policy.</span>
</Alert>
) : (
directives.map((dir) => (
<div key={dir.directive} className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{dir.directive}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveDirective(dir.directive)}
className="ml-auto"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1">
{dir.values.map((val) => (
<Badge
key={val}
variant="outline"
className="flex items-center gap-1 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={() => handleRemoveValue(dir.directive, val)}
>
<span className="font-mono text-xs">{val}</span>
<X className="w-3 h-3" />
</Badge>
))}
</div>
</div>
</div>
))
)}
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<Alert variant="warning">
<AlertCircle className="w-4 h-4" />
<div>
<p className="font-semibold mb-1">CSP Validation Warnings:</p>
<ul className="list-disc list-inside text-sm space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</Alert>
)}
{validationErrors.length === 0 && directives.length > 0 && (
<Alert variant="success">
<Check className="w-4 h-4" />
<span>CSP configuration looks good!</span>
</Alert>
)}
{/* CSP String Preview */}
{showPreview && cspString && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated CSP Header:</label>
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
{cspString || '(empty)'}
</pre>
</div>
)}
</Card>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useState, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useCertificates } from '../hooks/useCertificates'
import { deleteCertificate } from '../api/certificates'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { createBackup } from '../api/backups'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { toast } from '../utils/toast'
type SortColumn = 'name' | 'expires'
type SortDirection = 'asc' | 'desc'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const { hosts } = useProxyHosts()
const queryClient = useQueryClient()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const deleteMutation = useMutation({
// Perform backup before actual deletion
mutationFn: async (id: number) => {
await createBackup()
await deleteCertificate(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
toast.success('Certificate deleted')
},
onError: (error: Error) => {
toast.error(`Failed to delete certificate: ${error.message}`)
},
})
const sortedCertificates = useMemo(() => {
return [...certificates].sort((a, b) => {
let comparison = 0
switch (sortColumn) {
case 'name': {
const aName = (a.name || a.domain || '').toLowerCase()
const bName = (b.name || b.domain || '').toLowerCase()
comparison = aName.localeCompare(bName)
break
}
case 'expires': {
const aDate = new Date(a.expires_at).getTime()
const bDate = new Date(b.expires_at).getTime()
comparison = aDate - bDate
break
}
}
return sortDirection === 'asc' ? comparison : -comparison
})
}, [certificates, sortColumn, sortDirection])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const SortIcon = ({ column }: { column: SortColumn }) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
return (
<>
{deleteMutation.isPending && (
<ConfigReloadOverlay
message="Returning to shore..."
submessage="Certificate departure in progress"
type="charon"
/>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th
onClick={() => handleSort('name')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Name
<SortIcon column="name" />
</div>
</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th
onClick={() => handleSort('expires')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Expires
<SortIcon column="expires" />
</div>
</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{certificates.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
sortedCertificates.map((cert) => (
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span>{cert.issuer}</span>
{cert.issuer?.toLowerCase().includes('staging') && (
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded">
STAGING
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
<button
onClick={() => {
// Determine if certificate is in use by any proxy host
const inUse = hosts.some(h => {
const cid = h.certificate_id ?? h.certificate?.id
return cid === cert.id
})
if (inUse) {
toast.error('Certificate cannot be deleted because it is in use by a proxy host')
return
}
// Allow deletion for custom/staging certs not in use (status check removed)
const message = cert.provider === 'custom'
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
: 'Delete this staging certificate? It will be regenerated on next request.'
if (confirm(message)) {
deleteMutation.mutate(cert.id!)
}
}}
className="text-red-400 hover:text-red-300 transition-colors"
title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
valid: 'bg-green-900/30 text-green-400 border-green-800',
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
expired: 'bg-red-900/30 text-red-400 border-red-800',
untrusted: 'bg-orange-900/30 text-orange-400 border-orange-800',
}
const labels = {
valid: 'Valid',
expiring: 'Expiring Soon',
expired: 'Expired',
untrusted: 'Untrusted (Staging)',
}
const style = styles[status as keyof typeof styles] || styles.valid
const label = labels[status as keyof typeof labels] || status
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{label}
</span>
)
}
@@ -0,0 +1,143 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FileKey, Loader2 } from 'lucide-react'
import { Card, CardHeader, CardContent, Badge, Skeleton, Progress } from './ui'
import type { Certificate } from '../api/certificates'
import type { ProxyHost } from '../api/proxyHosts'
interface CertificateStatusCardProps {
certificates: Certificate[]
hosts: ProxyHost[]
isLoading?: boolean
}
export default function CertificateStatusCard({ certificates, hosts, isLoading }: CertificateStatusCardProps) {
const validCount = certificates.filter(c => c.status === 'valid').length
const expiringCount = certificates.filter(c => c.status === 'expiring').length
const untrustedCount = certificates.filter(c => c.status === 'untrusted').length
// Build a set of all domains that have certificates (case-insensitive)
// ACME certificates (Let's Encrypt) are auto-managed and don't set certificate_id,
// so we match by domain name instead
const certifiedDomains = useMemo(() => {
const domains = new Set<string>()
certificates.forEach(cert => {
// Handle missing or undefined domain field
if (!cert.domain) return
// Certificate domain field can be comma-separated
cert.domain.split(',').forEach(d => {
const trimmed = d.trim().toLowerCase()
if (trimmed) domains.add(trimmed)
})
})
return domains
}, [certificates])
// Calculate pending hosts: SSL-enabled hosts without any domain covered by a certificate
const { pendingCount, totalSSLHosts, hostsWithCerts } = useMemo(() => {
const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled)
let withCerts = 0
sslHosts.forEach(host => {
// Check if any of the host's domains have a certificate
const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase())
if (hostDomains.some(domain => certifiedDomains.has(domain))) {
withCerts++
}
})
return {
pendingCount: sslHosts.length - withCerts,
totalSSLHosts: sslHosts.length,
hostsWithCerts: withCerts,
}
}, [hosts, certifiedDomains])
const hasProvisioning = pendingCount > 0
const progressPercent = totalSSLHosts > 0
? Math.round((hostsWithCerts / totalSSLHosts) * 100)
: 100
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-28" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-8 w-16" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
</CardContent>
</Card>
)
}
return (
<Link to="/certificates" className="block group">
<Card variant="interactive" className="h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-brand-500/10 p-2 text-brand-500">
<FileKey className="h-5 w-5" />
</div>
<span className="text-sm font-medium text-content-secondary">SSL Certificates</span>
</div>
{hasProvisioning && (
<Badge variant="primary" size="sm" className="animate-pulse">
Provisioning
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-3xl font-bold text-content-primary tabular-nums">
{certificates.length}
</div>
{/* Status breakdown */}
<div className="flex flex-wrap gap-2">
{validCount > 0 && (
<Badge variant="success" size="sm">
{validCount} valid
</Badge>
)}
{expiringCount > 0 && (
<Badge variant="warning" size="sm">
{expiringCount} expiring
</Badge>
)}
{untrustedCount > 0 && (
<Badge variant="outline" size="sm">
{untrustedCount} staging
</Badge>
)}
{certificates.length === 0 && (
<Badge variant="outline" size="sm">
No certificates
</Badge>
)}
</div>
{/* Pending indicator */}
{hasProvisioning && (
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2 text-brand-400 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate</span>
</div>
<Progress value={progressPercent} variant="default" />
<div className="text-xs text-content-muted">{progressPercent}% provisioned</div>
</div>
)}
</CardContent>
</Card>
</Link>
)
}
@@ -0,0 +1,609 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Plus, Edit, Trash2, CheckCircle, XCircle, TestTube } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
Input,
Label,
Checkbox,
EmptyState,
} from './ui'
import {
useCredentials,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredential,
type DNSProviderCredential,
type CredentialRequest,
} from '../hooks/useCredentials'
import type { DNSProvider, DNSProviderTypeInfo } from '../api/dnsProviders'
import { toast } from '../utils/toast'
interface CredentialManagerProps {
open: boolean
onOpenChange: (open: boolean) => void
provider: DNSProvider
providerTypeInfo?: DNSProviderTypeInfo
}
export default function CredentialManager({
open,
onOpenChange,
provider,
providerTypeInfo,
}: CredentialManagerProps) {
const { t } = useTranslation()
const { data: credentials = [], isLoading, refetch } = useCredentials(provider.id)
const deleteMutation = useDeleteCredential()
const testMutation = useTestCredential()
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingCredential, setEditingCredential] = useState<DNSProviderCredential | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [testingId, setTestingId] = useState<number | null>(null)
const handleAddCredential = () => {
setEditingCredential(null)
setIsFormOpen(true)
}
const handleEditCredential = (credential: DNSProviderCredential) => {
setEditingCredential(credential)
setIsFormOpen(true)
}
const handleDeleteClick = (id: number) => {
setDeleteConfirm(id)
}
const handleDeleteConfirm = async (id: number) => {
try {
await deleteMutation.mutateAsync({ providerId: provider.id, credentialId: id })
toast.success(t('credentials.deleteSuccess', 'Credential deleted successfully'))
setDeleteConfirm(null)
refetch()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.deleteFailed', 'Failed to delete credential') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
const handleTestCredential = async (id: number) => {
setTestingId(id)
try {
const result = await testMutation.mutateAsync({
providerId: provider.id,
credentialId: id,
})
if (result.success) {
toast.success(result.message || t('credentials.testSuccess', 'Credential test passed'))
} else {
toast.error(result.error || t('credentials.testFailed', 'Credential test failed'))
}
refetch()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.testFailed', 'Failed to test credential') +
': ' +
(err.response?.data?.error || err.message)
)
} finally {
setTestingId(null)
}
}
const handleFormSuccess = () => {
toast.success(
editingCredential
? t('credentials.updateSuccess', 'Credential updated successfully')
: t('credentials.createSuccess', 'Credential created successfully')
)
setIsFormOpen(false)
refetch()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t('credentials.manageTitle', 'Manage Credentials')}: {provider.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Add Button */}
<div className="flex justify-between items-center">
<Button onClick={handleAddCredential} size="sm">
<Plus className="w-4 h-4 mr-2" />
{t('credentials.addCredential', 'Add Credential')}
</Button>
</div>
{/* Loading State */}
{isLoading && (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading', 'Loading...')}
</div>
)}
{/* Empty State */}
{!isLoading && credentials.length === 0 && (
<EmptyState
icon={<CheckCircle className="w-10 h-10" />}
title={t('credentials.noCredentials', 'No credentials configured')}
description={t(
'credentials.noCredentialsDescription',
'Add credentials to enable zone-specific DNS challenge configuration'
)}
action={{
label: t('credentials.addFirst', 'Add First Credential'),
onClick: handleAddCredential,
}}
/>
)}
{/* Credentials Table */}
{!isLoading && credentials.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.label', 'Label')}
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.zones', 'Zones')}
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
{t('credentials.status', 'Status')}
</th>
<th className="px-4 py-3 text-right text-sm font-medium">
{t('common.actions', 'Actions')}
</th>
</tr>
</thead>
<tbody className="divide-y">
{credentials.map((credential) => (
<tr key={credential.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{credential.label}</div>
{!credential.enabled && (
<span className="text-xs text-muted-foreground">
{t('common.disabled', 'Disabled')}
</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{credential.zone_filter || (
<span className="text-muted-foreground italic">
{t('credentials.allZones', 'All zones (catch-all)')}
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{credential.failure_count > 0 ? (
<XCircle className="w-4 h-4 text-destructive" />
) : (
<CheckCircle className="w-4 h-4 text-success" />
)}
<span className="text-sm">
{credential.success_count}/{credential.failure_count}
</span>
</div>
{credential.last_used_at && (
<div className="text-xs text-muted-foreground">
{t('credentials.lastUsed', 'Last used')}:{' '}
{new Date(credential.last_used_at).toLocaleString()}
</div>
)}
{credential.last_error && (
<div className="text-xs text-destructive mt-1">
{credential.last_error}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleTestCredential(credential.id)}
disabled={testingId === credential.id}
>
<TestTube className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditCredential(credential)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteClick(credential.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
{/* Credential Form Dialog */}
{isFormOpen && (
<CredentialForm
open={isFormOpen}
onOpenChange={setIsFormOpen}
providerId={provider.id}
providerTypeInfo={providerTypeInfo}
credential={editingCredential}
onSuccess={handleFormSuccess}
/>
)}
{/* Delete Confirmation Dialog */}
{deleteConfirm !== null && (
<Dialog open={true} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('credentials.deleteConfirm', 'Delete Credential?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t(
'credentials.deleteWarning',
'Are you sure you want to delete this credential? This action cannot be undone.'
)}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="danger"
onClick={() => handleDeleteConfirm(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{t('common.delete', 'Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Dialog>
)
}
interface CredentialFormProps {
open: boolean
onOpenChange: (open: boolean) => void
providerId: number
providerTypeInfo?: DNSProviderTypeInfo
credential: DNSProviderCredential | null
onSuccess: () => void
}
function CredentialForm({
open,
onOpenChange,
providerId,
providerTypeInfo,
credential,
onSuccess,
}: CredentialFormProps) {
const { t } = useTranslation()
const createMutation = useCreateCredential()
const updateMutation = useUpdateCredential()
const testMutation = useTestCredential()
const [label, setLabel] = useState('')
const [zoneFilter, setZoneFilter] = useState('')
const [credentials, setCredentials] = useState<Record<string, string>>({})
const [propagationTimeout, setPropagationTimeout] = useState(120)
const [pollingInterval, setPollingInterval] = useState(5)
const [enabled, setEnabled] = useState(true)
const [errors, setErrors] = useState<Record<string, string>>({})
useEffect(() => {
if (credential) {
setLabel(credential.label)
setZoneFilter(credential.zone_filter)
setPropagationTimeout(credential.propagation_timeout)
setPollingInterval(credential.polling_interval)
setEnabled(credential.enabled)
setCredentials({}) // Don't pre-fill credentials (they're encrypted)
} else {
resetForm()
}
}, [credential, open])
const resetForm = () => {
setLabel('')
setZoneFilter('')
setCredentials({})
setPropagationTimeout(120)
setPollingInterval(5)
setEnabled(true)
setErrors({})
}
const validateZoneFilter = (value: string): boolean => {
if (!value) return true // Empty is valid (catch-all)
const zones = value.split(',').map((z) => z.trim())
for (const zone of zones) {
// Basic domain validation
if (zone && !/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(zone)) {
setErrors((prev) => ({
...prev,
zone_filter: t('credentials.invalidZone', 'Invalid domain format: ') + zone,
}))
return false
}
}
setErrors((prev) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { zone_filter: _, ...rest } = prev
return rest
})
return true
}
const handleCredentialChange = (fieldName: string, value: string) => {
setCredentials((prev) => ({ ...prev, [fieldName]: value }))
}
const handleSubmit = async () => {
// Validate
if (!label.trim()) {
setErrors({ label: t('credentials.labelRequired', 'Label is required') })
return
}
if (!validateZoneFilter(zoneFilter)) {
return
}
// Check required credential fields
const missingFields: string[] = []
providerTypeInfo?.fields
.filter((f) => f.required)
.forEach((field) => {
if (!credentials[field.name]) {
missingFields.push(field.label)
}
})
if (missingFields.length > 0 && !credential) {
// Only enforce for new credentials
toast.error(
t('credentials.missingFields', 'Missing required fields: ') + missingFields.join(', ')
)
return
}
const data: CredentialRequest = {
label: label.trim(),
zone_filter: zoneFilter.trim(),
credentials,
propagation_timeout: propagationTimeout,
polling_interval: pollingInterval,
enabled,
}
try {
if (credential) {
await updateMutation.mutateAsync({
providerId,
credentialId: credential.id,
data,
})
} else {
await createMutation.mutateAsync({ providerId, data })
}
onSuccess()
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.saveFailed', 'Failed to save credential') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
const handleTest = async () => {
if (!credential) {
toast.info(t('credentials.saveBeforeTest', 'Please save the credential before testing'))
return
}
try {
const result = await testMutation.mutateAsync({
providerId,
credentialId: credential.id,
})
if (result.success) {
toast.success(result.message || t('credentials.testSuccess', 'Test passed'))
} else {
toast.error(result.error || t('credentials.testFailed', 'Test failed'))
}
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string }
toast.error(
t('credentials.testFailed', 'Test failed') +
': ' +
(err.response?.data?.error || err.message)
)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{credential
? t('credentials.editCredential', 'Edit Credential')
: t('credentials.addCredential', 'Add Credential')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Label */}
<div>
<Label htmlFor="label">
{t('credentials.label', 'Label')} <span className="text-destructive">*</span>
</Label>
<Input
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('credentials.labelPlaceholder', 'e.g., Production, Customer A')}
error={errors.label}
/>
</div>
{/* Zone Filter */}
<div>
<Label htmlFor="zone_filter">{t('credentials.zoneFilter', 'Zone Filter')}</Label>
<Input
id="zone_filter"
value={zoneFilter}
onChange={(e) => {
setZoneFilter(e.target.value)
validateZoneFilter(e.target.value)
}}
placeholder="example.com, *.staging.example.com"
error={errors.zone_filter}
/>
<p className="text-xs text-muted-foreground mt-1">
{t(
'credentials.zoneFilterHint',
'Comma-separated domains. Leave empty for catch-all. Supports wildcards (*.example.com)'
)}
</p>
</div>
{/* Credentials Fields */}
{providerTypeInfo?.fields.map((field) => (
<div key={field.name}>
<Label htmlFor={field.name}>
{field.label} {field.required && <span className="text-destructive">*</span>}
</Label>
<Input
id={field.name}
type={field.type === 'password' ? 'password' : 'text'}
value={credentials[field.name] || ''}
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
placeholder={
credential
? t('credentials.leaveBlankToKeep', '(leave blank to keep existing)')
: field.default || ''
}
/>
{field.hint && (
<p className="text-xs text-muted-foreground mt-1">{field.hint}</p>
)}
</div>
))}
{/* Enabled Checkbox */}
<div className="flex items-center gap-2">
<Checkbox
id="enabled"
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
/>
<Label htmlFor="enabled" className="cursor-pointer">
{t('credentials.enabled', 'Enabled')}
</Label>
</div>
{/* Advanced Options */}
<details className="border rounded-lg p-4">
<summary className="cursor-pointer font-medium">
{t('common.advancedOptions', 'Advanced Options')}
</summary>
<div className="space-y-4 mt-4">
<div>
<Label htmlFor="propagation_timeout">
{t('dnsProviders.propagationTimeout', 'Propagation Timeout (seconds)')}
</Label>
<Input
id="propagation_timeout"
type="number"
min="10"
max="600"
value={propagationTimeout}
onChange={(e) => setPropagationTimeout(parseInt(e.target.value) || 120)}
/>
</div>
<div>
<Label htmlFor="polling_interval">
{t('dnsProviders.pollingInterval', 'Polling Interval (seconds)')}
</Label>
<Input
id="polling_interval"
type="number"
min="1"
max="60"
value={pollingInterval}
onChange={(e) => setPollingInterval(parseInt(e.target.value) || 5)}
/>
</div>
</div>
</details>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel', 'Cancel')}
</Button>
{credential && (
<Button
variant="secondary"
onClick={handleTest}
disabled={testMutation.isPending}
>
{t('common.test', 'Test')}
</Button>
)}
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,147 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Copy, Check, Key, AlertCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from './ui/Button'
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card'
import { Badge } from './ui/Badge'
import { Skeleton } from './ui/Skeleton'
import { toast } from '../utils/toast'
import client from '../api/client'
interface BouncerInfo {
name: string
key_preview: string
key_source: 'env_var' | 'file' | 'none'
file_path: string
registered: boolean
}
interface BouncerKeyResponse {
key: string
source: string
}
async function fetchBouncerInfo(): Promise<BouncerInfo> {
const response = await client.get<BouncerInfo>('/admin/crowdsec/bouncer')
return response.data
}
async function fetchBouncerKey(): Promise<string> {
const response = await client.get<BouncerKeyResponse>('/admin/crowdsec/bouncer/key')
return response.data.key
}
export function CrowdSecBouncerKeyDisplay() {
const { t, ready } = useTranslation()
const [copied, setCopied] = useState(false)
const [isCopying, setIsCopying] = useState(false)
const { data: info, isLoading, error } = useQuery({
queryKey: ['crowdsec-bouncer-info'],
queryFn: fetchBouncerInfo,
refetchInterval: 30000,
retry: 1,
})
const handleCopyKey = async () => {
if (isCopying) return
setIsCopying(true)
try {
const key = await fetchBouncerKey()
await navigator.clipboard.writeText(key)
setCopied(true)
toast.success(t('security.crowdsec.keyCopied'))
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error(t('security.crowdsec.copyFailed'))
} finally {
setIsCopying(false)
}
}
if (!ready || isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-5 w-48" />
</CardContent>
</Card>
)
}
if (error || !info) {
return null
}
if (info.key_source === 'none') {
return (
<Card className="border-yellow-500/30 bg-yellow-500/5">
<CardContent className="flex items-center gap-2 py-3">
<AlertCircle className="h-4 w-4 text-yellow-500" />
<span className="text-sm text-yellow-200">
{t('security.crowdsec.noKeyConfigured')}
</span>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Key className="h-4 w-4" />
{t('security.crowdsec.bouncerApiKey')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between gap-2">
<code className="rounded bg-gray-900 px-3 py-1.5 font-mono text-sm text-gray-200">
{info.key_preview}
</code>
<Button
variant="secondary"
size="sm"
onClick={handleCopyKey}
disabled={copied || isCopying}
>
{copied ? (
<>
<Check className="mr-1 h-3 w-3" />
{t('common.success')}
</>
) : (
<>
<Copy className="mr-1 h-3 w-3" />
{t('common.copy') || 'Copy'}
</>
)}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={info.registered ? 'success' : 'error'}>
{info.registered
? t('security.crowdsec.registered')
: t('security.crowdsec.notRegistered')}
</Badge>
<Badge variant="outline">
{info.key_source === 'env_var'
? t('security.crowdsec.sourceEnvVar')
: t('security.crowdsec.sourceFile')}
</Badge>
</div>
<p className="text-xs text-gray-400">
{t('security.crowdsec.keyStoredAt')}: <code className="text-gray-300">{info.file_path}</code>
</p>
</CardContent>
</Card>
)
}
@@ -0,0 +1,158 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Copy, Check, AlertTriangle, X, Eye, EyeOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert } from './ui/Alert'
import { Button } from './ui/Button'
import { toast } from '../utils/toast'
import { getCrowdsecKeyStatus, type CrowdSecKeyStatus } from '../api/crowdsec'
const DISMISSAL_STORAGE_KEY = 'crowdsec-key-warning-dismissed'
interface DismissedState {
dismissed: boolean
key?: string
}
function getDismissedState(): DismissedState {
try {
const stored = localStorage.getItem(DISMISSAL_STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch {
// Ignore parse errors
}
return { dismissed: false }
}
function setDismissedState(fullKey: string) {
try {
localStorage.setItem(DISMISSAL_STORAGE_KEY, JSON.stringify({ dismissed: true, key: fullKey }))
} catch {
// Ignore storage errors
}
}
export function CrowdSecKeyWarning() {
const { t, ready } = useTranslation()
const [copied, setCopied] = useState(false)
const [dismissed, setDismissed] = useState(false)
const [showKey, setShowKey] = useState(false)
const { data: keyStatus, isLoading } = useQuery<CrowdSecKeyStatus>({
queryKey: ['crowdsec-key-status'],
queryFn: getCrowdsecKeyStatus,
refetchInterval: 60000,
retry: 1,
})
useEffect(() => {
if (keyStatus?.env_key_rejected && keyStatus.full_key) {
const storedState = getDismissedState()
// If dismissed but for a different key, show the warning again
if (storedState.dismissed && storedState.key !== keyStatus.full_key) {
setDismissed(false)
} else if (storedState.dismissed && storedState.key === keyStatus.full_key) {
setDismissed(true)
}
}
}, [keyStatus])
const handleCopy = async () => {
if (!keyStatus?.full_key) return
try {
await navigator.clipboard.writeText(keyStatus.full_key)
setCopied(true)
toast.success(t('security.crowdsec.keyWarning.copied'))
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error(t('security.crowdsec.copyFailed'))
}
}
const handleDismiss = () => {
if (keyStatus?.full_key) {
setDismissedState(keyStatus.full_key)
}
setDismissed(true)
}
if (!ready || isLoading || !keyStatus?.env_key_rejected || !keyStatus?.full_key || dismissed) {
return null
}
const envVarLine = `CHARON_SECURITY_CROWDSEC_API_KEY=${keyStatus.full_key}`
const maskedKey = `CHARON_SECURITY_CROWDSEC_API_KEY=${'•'.repeat(Math.min(keyStatus.full_key.length, 40))}`
return (
<Alert variant="warning" className="relative">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-warning flex-shrink-0" />
<h4 className="font-semibold text-content-primary">
{t('security.crowdsec.keyWarning.title')}
</h4>
</div>
<button
type="button"
onClick={handleDismiss}
className="p-1 rounded-md text-content-muted hover:text-content-primary hover:bg-surface-muted transition-colors"
aria-label={t('common.close')}
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-content-secondary">
{t('security.crowdsec.keyWarning.description')}
</p>
<div className="bg-surface-subtle border border-border rounded-md p-3">
<p className="text-xs text-content-muted mb-2">
{t('security.crowdsec.keyWarning.instructions')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-surface-elevated rounded px-3 py-2 font-mono text-sm text-content-primary overflow-x-auto whitespace-nowrap">
{showKey ? envVarLine : maskedKey}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => setShowKey(!showKey)}
className="flex-shrink-0"
title={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopy}
disabled={copied}
className="flex-shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-1" />
{t('security.crowdsec.keyWarning.copied')}
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
{t('security.crowdsec.keyWarning.copyButton')}
</>
)}
</Button>
</div>
</div>
<p className="text-xs text-content-muted">
{t('security.crowdsec.keyWarning.restartNote')}
</p>
</div>
</Alert>
)
}
@@ -0,0 +1,129 @@
import { CheckCircle2, AlertCircle, Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge, Button, Alert } from './ui'
import type { DetectionResult } from '../api/dnsDetection'
import type { DNSProvider } from '../api/dnsProviders'
interface DNSDetectionResultProps {
result: DetectionResult
onUseSuggested?: (provider: DNSProvider) => void
onSelectManually?: () => void
isLoading?: boolean
}
export function DNSDetectionResult({
result,
onUseSuggested,
onSelectManually,
isLoading = false,
}: DNSDetectionResultProps) {
const { t } = useTranslation()
if (isLoading) {
return (
<Alert variant="info">
<Info className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.detecting')}</p>
</div>
</Alert>
)
}
if (result.error) {
return (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.error', { error: result.error })}</p>
</div>
</Alert>
)
}
if (!result.detected) {
return (
<Alert variant="info">
<Info className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{t('dns_detection.not_detected')}</p>
{result.nameservers.length > 0 && (
<div className="mt-2">
<p className="text-xs text-content-secondary">{t('dns_detection.nameservers')}:</p>
<ul className="text-xs text-content-secondary mt-1 space-y-0.5">
{result.nameservers.map((ns, i) => (
<li key={i} className="font-mono">
{ns}
</li>
))}
</ul>
</div>
)}
</div>
</Alert>
)
}
const getConfidenceBadgeVariant = (confidence: string) => {
switch (confidence) {
case 'high':
return 'success'
case 'medium':
return 'warning'
case 'low':
return 'outline'
default:
return 'outline'
}
}
const getConfidenceLabel = (confidence: string) => {
return t(`dns_detection.confidence_${confidence}`)
}
return (
<Alert variant="success" className="border-brand-500/30 bg-brand-500/5">
<CheckCircle2 className="h-4 w-4 text-brand-500" />
<div className="ml-2 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium">
{t('dns_detection.detected', { provider: result.provider_type })}
</p>
<Badge variant={getConfidenceBadgeVariant(result.confidence)} size="sm">
{getConfidenceLabel(result.confidence)}
</Badge>
</div>
{result.suggested_provider && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
onClick={() => onUseSuggested?.(result.suggested_provider!)}
>
{t('dns_detection.use_suggested', { provider: result.suggested_provider.name })}
</Button>
<Button size="sm" variant="outline" onClick={onSelectManually}>
{t('dns_detection.select_manually')}
</Button>
</div>
)}
{result.nameservers.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-content-secondary cursor-pointer hover:text-content-primary">
{t('dns_detection.nameservers')} ({result.nameservers.length})
</summary>
<ul className="text-xs text-content-secondary mt-2 space-y-0.5 ml-4">
{result.nameservers.map((ns, i) => (
<li key={i} className="font-mono">
{ns}
</li>
))}
</ul>
</details>
)}
</div>
</Alert>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Edit,
Trash2,
TestTube,
Star,
CheckCircle,
XCircle,
AlertTriangle,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Badge,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui'
import type { DNSProvider } from '../api/dnsProviders'
interface DNSProviderCardProps {
provider: DNSProvider
onEdit: (provider: DNSProvider) => void
onDelete: (id: number) => void
onTest: (id: number) => void
isTesting?: boolean
}
export default function DNSProviderCard({
provider,
onEdit,
onDelete,
onTest,
isTesting = false,
}: DNSProviderCardProps) {
const { t } = useTranslation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const getStatusBadge = () => {
if (!provider.has_credentials) {
return (
<Badge variant="warning">
<AlertTriangle className="w-3 h-3 mr-1" />
{t('dnsProviders.unconfigured')}
</Badge>
)
}
if (provider.last_error) {
return (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
{t('dnsProviders.error')}
</Badge>
)
}
if (provider.enabled) {
return (
<Badge variant="success">
<CheckCircle className="w-3 h-3 mr-1" />
{t('dnsProviders.active')}
</Badge>
)
}
return (
<Badge variant="secondary">
{t('common.disabled')}
</Badge>
)
}
const getProviderIcon = (type: string) => {
const iconMap: Record<string, string> = {
cloudflare: '☁️',
route53: '🔶',
digitalocean: '🐙',
googleclouddns: '🔵',
namecheap: '🏢',
godaddy: '🟢',
azure: '⚡',
hetzner: '🟠',
vultr: '🔷',
dnsimple: '💎',
}
return iconMap[type] || '🌐'
}
const handleDeleteConfirm = () => {
onDelete(provider.id)
setShowDeleteDialog(false)
}
return (
<>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="text-3xl">{getProviderIcon(provider.provider_type)}</div>
<div>
<CardTitle className="flex items-center gap-2">
{provider.name}
{provider.is_default && (
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" aria-label={t('dnsProviders.default')} />
)}
</CardTitle>
<p className="text-sm text-content-secondary mt-1">
{t(`dnsProviders.types.${provider.provider_type}`, provider.provider_type)}
</p>
</div>
</div>
{getStatusBadge()}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Usage Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-content-muted">{t('dnsProviders.lastUsed')}</p>
<p className="font-medium text-content-primary">
{provider.last_used_at
? formatDistanceToNow(new Date(provider.last_used_at), { addSuffix: true })
: t('dnsProviders.neverUsed')}
</p>
</div>
<div>
<p className="text-content-muted">{t('dnsProviders.successRate')}</p>
<p className="font-medium text-content-primary">
{provider.success_count} / {provider.failure_count}
</p>
</div>
</div>
{/* Settings */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-content-muted">{t('dnsProviders.propagationTimeout')}</p>
<p className="font-medium text-content-primary">{provider.propagation_timeout}s</p>
</div>
<div>
<p className="text-content-muted">{t('dnsProviders.pollingInterval')}</p>
<p className="font-medium text-content-primary">{provider.polling_interval}s</p>
</div>
</div>
{/* Last Error */}
{provider.last_error && (
<div className="bg-error/10 border border-error/20 rounded-lg p-3">
<p className="text-xs font-medium text-error mb-1">{t('dnsProviders.lastError')}</p>
<p className="text-xs text-content-secondary">{provider.last_error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(provider)}
className="flex-1"
>
<Edit className="w-4 h-4 mr-2" />
{t('common.edit')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onTest(provider.id)}
isLoading={isTesting}
disabled={!provider.has_credentials}
className="flex-1"
>
<TestTube className="w-4 h-4 mr-2" />
{t('common.test')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dnsProviders.deleteProvider')}</DialogTitle>
<DialogDescription>
{t('dnsProviders.deleteConfirmation', { name: provider.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowDeleteDialog(false)}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={handleDeleteConfirm}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More