chore: refactor tests to improve clarity and reliability

- Removed unnecessary test.skip() calls in various test files, replacing them with comments for clarity.
- Enhanced retry logic in TestDataManager for API requests to handle rate limiting more gracefully.
- Updated security helper functions to include retry mechanisms for fetching security status and setting module states.
- Improved loading completion checks to handle page closure scenarios.
- Adjusted WebKit-specific tests to run in all browsers, removing the previous skip logic.
- General cleanup and refactoring across multiple test files to enhance readability and maintainability.
This commit is contained in:
GitHub Actions
2026-02-08 00:02:09 +00:00
parent 5054a334f2
commit aa85c911c0
71 changed files with 22475 additions and 3241 deletions
+4 -3
View File
@@ -130,9 +130,10 @@ describe('api client', () => {
expect(handler).toBeDefined()
// Call handler with auth endpoint error to verify it skips the auth error handler
if (handler) {
await handler(error)
}
const resultPromise = handler ? handler(error) : Promise.reject(new Error('handler missing'))
await expect(resultPromise).rejects.toBe(error)
expect(onAuthError).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
+10 -7
View File
@@ -10,6 +10,9 @@ vi.mock('../client', () => ({
}));
describe('import API', () => {
const mockedGet = vi.mocked(client.get);
const mockedPost = vi.mocked(client.post);
beforeEach(() => {
vi.clearAllMocks();
});
@@ -17,7 +20,7 @@ describe('import API', () => {
it('uploadCaddyfile posts content', async () => {
const content = 'example.com';
const mockResponse = { preview: { hosts: [] } };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfile(content);
expect(client.post).toHaveBeenCalledWith('/import/upload', { content });
@@ -27,7 +30,7 @@ describe('import API', () => {
it('uploadCaddyfilesMulti posts files', async () => {
const files = [{ filename: 'Caddyfile', content: 'foo.com' }];
const mockResponse = { preview: { hosts: [] } };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await uploadCaddyfilesMulti(files);
expect(client.post).toHaveBeenCalledWith('/import/upload-multi', { files });
@@ -36,7 +39,7 @@ describe('import API', () => {
it('getImportPreview gets preview', async () => {
const mockResponse = { preview: { hosts: [] } };
(client.get as any).mockResolvedValue({ data: mockResponse });
mockedGet.mockResolvedValue({ data: mockResponse });
const result = await getImportPreview();
expect(client.get).toHaveBeenCalledWith('/import/preview');
@@ -49,7 +52,7 @@ describe('import API', () => {
const names = { 'foo.com': 'My Site' };
const mockResponse = { created: 1, updated: 0, skipped: 0, errors: [] };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await commitImport(sessionUUID, resolutions, names);
expect(client.post).toHaveBeenCalledWith('/import/commit', {
@@ -61,7 +64,7 @@ describe('import API', () => {
});
it('cancelImport posts cancel', async () => {
(client.post as any).mockResolvedValue({});
mockedPost.mockResolvedValue({});
await cancelImport();
expect(client.post).toHaveBeenCalledWith('/import/cancel');
@@ -69,7 +72,7 @@ describe('import API', () => {
it('getImportStatus gets status', async () => {
const mockResponse = { has_pending: true };
(client.get as any).mockResolvedValue({ data: mockResponse });
mockedGet.mockResolvedValue({ data: mockResponse });
const result = await getImportStatus();
expect(client.get).toHaveBeenCalledWith('/import/status');
@@ -77,7 +80,7 @@ describe('import API', () => {
});
it('getImportStatus handles error', async () => {
(client.get as any).mockRejectedValue(new Error('Failed'));
mockedGet.mockRejectedValue(new Error('Failed'));
const result = await getImportStatus();
expect(result).toEqual({ has_pending: false });
@@ -54,8 +54,7 @@ describe('logs API - connectLiveLogs', () => {
beforeEach(() => {
// Mock global WebSocket
mockWebSocket = new MockWebSocket('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).WebSocket = class MockedWebSocket extends 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
@@ -12,13 +12,18 @@ vi.mock('../client', () => ({
}));
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' }];
(client.get as any).mockResolvedValue({ data: { profiles: mockProfiles } });
mockedGet.mockResolvedValue({ data: { profiles: mockProfiles } });
const result = await securityHeadersApi.listProfiles();
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles');
@@ -27,7 +32,7 @@ describe('securityHeadersApi', () => {
it('getProfile returns a profile', async () => {
const mockProfile = { id: 1, name: 'Profile 1' };
(client.get as any).mockResolvedValue({ data: { profile: mockProfile } });
mockedGet.mockResolvedValue({ data: { profile: mockProfile } });
const result = await securityHeadersApi.getProfile(1);
expect(client.get).toHaveBeenCalledWith('/security/headers/profiles/1');
@@ -37,7 +42,7 @@ describe('securityHeadersApi', () => {
it('createProfile creates a profile', async () => {
const newProfile = { name: 'New Profile' };
const mockResponse = { id: 1, ...newProfile };
(client.post as any).mockResolvedValue({ data: { profile: mockResponse } });
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.createProfile(newProfile);
expect(client.post).toHaveBeenCalledWith('/security/headers/profiles', newProfile);
@@ -47,7 +52,7 @@ describe('securityHeadersApi', () => {
it('updateProfile updates a profile', async () => {
const updates = { name: 'Updated Profile' };
const mockResponse = { id: 1, ...updates };
(client.put as any).mockResolvedValue({ data: { profile: mockResponse } });
mockedPut.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.updateProfile(1, updates);
expect(client.put).toHaveBeenCalledWith('/security/headers/profiles/1', updates);
@@ -55,7 +60,7 @@ describe('securityHeadersApi', () => {
});
it('deleteProfile deletes a profile', async () => {
(client.delete as any).mockResolvedValue({});
mockedDelete.mockResolvedValue({});
await securityHeadersApi.deleteProfile(1);
expect(client.delete).toHaveBeenCalledWith('/security/headers/profiles/1');
@@ -63,7 +68,7 @@ describe('securityHeadersApi', () => {
it('getPresets returns presets', async () => {
const mockPresets = [{ name: 'Basic' }];
(client.get as any).mockResolvedValue({ data: { presets: mockPresets } });
mockedGet.mockResolvedValue({ data: { presets: mockPresets } });
const result = await securityHeadersApi.getPresets();
expect(client.get).toHaveBeenCalledWith('/security/headers/presets');
@@ -73,7 +78,7 @@ describe('securityHeadersApi', () => {
it('applyPreset applies a preset', async () => {
const request = { preset_type: 'basic', name: 'My Preset' };
const mockResponse = { id: 1, ...request };
(client.post as any).mockResolvedValue({ data: { profile: mockResponse } });
mockedPost.mockResolvedValue({ data: { profile: mockResponse } });
const result = await securityHeadersApi.applyPreset(request);
expect(client.post).toHaveBeenCalledWith('/security/headers/presets/apply', request);
@@ -83,7 +88,7 @@ describe('securityHeadersApi', () => {
it('calculateScore calculates score', async () => {
const config = { hsts_enabled: true };
const mockResponse = { score: 90 };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.calculateScore(config);
expect(client.post).toHaveBeenCalledWith('/security/headers/score', config);
@@ -93,7 +98,7 @@ describe('securityHeadersApi', () => {
it('validateCSP validates CSP', async () => {
const csp = "default-src 'self'";
const mockResponse = { valid: true, errors: [] };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.validateCSP(csp);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/validate', { csp });
@@ -103,7 +108,7 @@ describe('securityHeadersApi', () => {
it('buildCSP builds CSP', async () => {
const directives = [{ directive: 'default-src', values: ["'self'"] }];
const mockResponse = { csp: "default-src 'self'" };
(client.post as any).mockResolvedValue({ data: mockResponse });
mockedPost.mockResolvedValue({ data: mockResponse });
const result = await securityHeadersApi.buildCSP(directives);
expect(client.post).toHaveBeenCalledWith('/security/headers/csp/build', { directives });
+4 -2
View File
@@ -378,10 +378,11 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-300">Enabled</label>
<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 })}
/>
@@ -393,12 +394,13 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
<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 className="block text-sm font-medium text-gray-300">Local Network Only (RFC1918)</label>
<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 })
+25 -15
View File
@@ -1,5 +1,12 @@
import { useAccessLists } from '../hooks/useAccessLists';
import { ExternalLink } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/Select';
interface AccessListSelectorProps {
value: number | null;
@@ -13,25 +20,28 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect
return (
<div>
<label htmlFor="access-list-select" className="block text-sm font-medium text-gray-300 mb-2">
<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
id="access-list-select"
value={value || 0}
onChange={(e) => onChange(parseInt(e.target.value) || null)}
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"
<Select
value={String(value || 0)}
onValueChange={(val) => onChange(parseInt(val) || null)}
>
<option value={0}>No Access Control (Public)</option>
{accessLists
?.filter((acl) => acl.enabled)
.map((acl) => (
<option key={acl.id} value={acl.id}>
{acl.name} ({acl.type.replace('_', ' ')})
</option>
))}
</select>
<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="0">No Access Control (Public)</SelectItem>
{accessLists
?.filter((acl) => acl.enabled)
.map((acl) => (
<SelectItem key={acl.id} value={String(acl.id)}>
{acl.name} ({acl.type.replace('_', ' ')})
</SelectItem>
))}
</SelectContent>
</Select>
{selectedACL && (
<div className="mt-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
+137 -107
View File
@@ -18,6 +18,13 @@ import DNSProviderSelector from './DNSProviderSelector'
import { useDetectDNSProvider } from '../hooks/useDNSDetection'
import { DNSDetectionResult } from './DNSDetectionResult'
import type { DNSProvider } from '../api/dnsProviders'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/Select'
// Application preset configurations
const APPLICATION_PRESETS: { value: ApplicationPreset; label: string; description: string }[] = [
@@ -126,6 +133,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
const { mutateAsync: detectProvider, isPending: isDetecting, data: detectionResult, reset: resetDetection } = useDetectDNSProvider()
const [manualProviderSelection, setManualProviderSelection] = useState(false)
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const portInputRef = useRef<HTMLInputElement | null>(null)
// Fetch Charon internal IP on mount (legacy: CPMP internal IP)
useEffect(() => {
@@ -384,6 +392,20 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.forward_port) {
portInputRef.current?.setCustomValidity('Port is required')
portInputRef.current?.reportValidity()
portInputRef.current?.focus()
return
}
if (formData.forward_port < 1 || formData.forward_port > 65535) {
portInputRef.current?.setCustomValidity('Port must be between 1 and 65535')
portInputRef.current?.reportValidity()
portInputRef.current?.focus()
return
}
// Validate DNS provider for wildcard domains
if (hasWildcardDomain && !formData.dns_provider_id) {
toast.error('DNS provider is required for wildcard domains')
@@ -571,50 +593,51 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Docker Container Quick Select */}
<div>
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
Source
</label>
<select
id="connection-source"
value={connectionSource}
onChange={e => setConnectionSource(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="custom">Custom / Manual</option>
<option value="local">Local (Docker Socket)</option>
{remoteServers
.filter(s => s.provider === 'docker' && s.enabled)
.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host})
</option>
))
}
</select>
<Select value={connectionSource} onValueChange={setConnectionSource}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Source">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">Custom / Manual</SelectItem>
<SelectItem value="local">Local (Docker Socket)</SelectItem>
{remoteServers
.filter(s => s.provider === 'docker' && s.enabled)
.map(server => (
<SelectItem key={server.uuid} value={server.uuid}>
{server.name} ({server.host})
</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
Containers
</label>
<select
id="quick-select-docker"
onChange={e => handleContainerSelect(e.target.value)}
disabled={dockerLoading || connectionSource === 'custom'}
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 disabled:opacity-50"
<Select
value=""
onValueChange={e => e && handleContainerSelect(e)}
>
<option value="">
{connectionSource === 'custom'
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white disabled:opacity-50" disabled={dockerLoading || connectionSource === 'custom'} aria-label="Containers">
<SelectValue placeholder={connectionSource === 'custom'
? 'Select a source to view containers'
: (dockerLoading ? 'Loading containers...' : '-- Select a container --')}
</option>
{dockerContainers.map(container => (
<option key={container.id} value={container.id}>
{container.names[0]} ({container.image})
</option>
))}
</select>
: (dockerLoading ? 'Loading containers...' : 'Select a container')}
/>
</SelectTrigger>
<SelectContent>
{dockerContainers.map(container => (
<SelectItem key={container.id} value={container.id}>
{container.names[0]} ({container.image})
</SelectItem>
))}
</SelectContent>
</Select>
{dockerError && connectionSource !== 'custom' && (
<div className="mt-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-start gap-2">
@@ -639,22 +662,21 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<div className="space-y-4">
{domains.length > 0 && (
<div>
<label htmlFor="base-domain" className="block text-sm font-medium text-gray-300 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
Base Domain (Auto-fill)
</label>
<select
id="base-domain"
value={selectedDomain}
onChange={e => handleBaseDomainChange(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="">-- Select a base domain --</option>
{domains.map(domain => (
<option key={domain.uuid} value={domain.name}>
{domain.name}
</option>
))}
</select>
<Select value={selectedDomain} onValueChange={handleBaseDomainChange}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Base Domain (Auto-fill)">
<SelectValue placeholder="Select a base domain" />
</SelectTrigger>
<SelectContent>
{domains.map(domain => (
<SelectItem key={domain.uuid} value={domain.name}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
@@ -677,16 +699,16 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
{/* Forward Details */}
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor="forward-scheme" className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<select
id="forward-scheme"
value={formData.forward_scheme}
onChange={e => setFormData({ ...formData, forward_scheme: 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="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
<label className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<Select value={formData.forward_scheme} onValueChange={scheme => setFormData({ ...formData, forward_scheme: scheme })}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Scheme">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="forward-host" className="block text-sm font-medium text-gray-300 mb-2">
@@ -721,9 +743,11 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
required
min="1"
max="65535"
ref={portInputRef}
value={formData.forward_port}
onChange={e => {
const v = parseInt(e.target.value)
portInputRef.current?.setCustomValidity('')
setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v })
}}
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"
@@ -736,19 +760,20 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<label className="block text-sm font-medium text-gray-300 mb-2">
SSL Certificate
</label>
<select
value={formData.certificate_id || 0}
onChange={e => setFormData({ ...formData, certificate_id: parseInt(e.target.value) || null })}
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={0}>Auto-manage with Let's Encrypt (recommended)</option>
{certificates.map(cert => (
<option key={cert.id || cert.domain} value={cert.id ?? 0}>
{(cert.name || cert.domain)}
{cert.provider ? ` (${cert.provider})` : ''}
</option>
))}
</select>
<Select value={String(formData.certificate_id || 0)} onValueChange={e => setFormData({ ...formData, certificate_id: parseInt(e) || null })}>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="SSL Certificate">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Auto-manage with Let's Encrypt (recommended)</SelectItem>
{certificates.map(cert => (
<SelectItem key={cert.id || cert.domain} value={String(cert.id ?? 0)}>
{(cert.name || cert.domain)}
{cert.provider ? ` (${cert.provider})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
Choose an existing certificate if already issued for these domains, or let Charon request/renew via Let's Encrypt automatically.
</p>
@@ -804,37 +829,39 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
value={formData.security_header_profile_id || 0}
onChange={e => {
const value = e.target.value === "0" ? null : parseInt(e.target.value) || null
<Select
value={String(formData.security_header_profile_id || 0)}
onValueChange={e => {
const value = e === "0" ? null : parseInt(e) || null
setFormData({ ...formData, security_header_profile_id: 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={0}>None (No Security Headers)</option>
<optgroup label="Quick Presets">
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Security Headers">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">None (No Security Headers)</SelectItem>
{securityProfiles
?.filter(p => p.is_preset)
.sort((a, b) => a.security_score - b.security_score)
.map(profile => (
<option key={profile.id} value={profile.id}>
<SelectItem key={profile.id} value={String(profile.id)}>
{profile.name} (Score: {profile.security_score}/100)
</option>
</SelectItem>
))}
</optgroup>
{(securityProfiles?.filter(p => !p.is_preset) || []).length > 0 && (
<optgroup label="Custom Profiles">
{(securityProfiles || [])
.filter(p => !p.is_preset)
.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} (Score: {profile.security_score}/100)
</option>
))}
</optgroup>
)}
</select>
{(securityProfiles?.filter(p => !p.is_preset) || []).length > 0 && (
<>
{(securityProfiles || [])
.filter(p => !p.is_preset)
.map(profile => (
<SelectItem key={profile.id} value={String(profile.id)}>
{profile.name} (Score: {profile.security_score}/100)
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{formData.security_header_profile_id && (() => {
const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id)
@@ -893,30 +920,33 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
{/* Application Preset */}
<div>
<label htmlFor="application-preset" className="block text-sm font-medium text-gray-300 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
Application Preset
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
id="application-preset"
<Select
value={formData.application}
onChange={e => {
const preset = e.target.value as ApplicationPreset
onValueChange={preset => {
const presetVal = preset as ApplicationPreset
// Apply with advanced_config logic
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(preset)
const needsWebsockets = ['plex', 'jellyfin', 'emby', 'homeassistant', 'vaultwarden'].includes(presetVal)
// Delegate to shared logic which will auto-apply or prompt
tryApplyPreset(preset)
tryApplyPreset(presetVal)
// Ensure we still enable websockets when preset implies it
setFormData(prev => ({ ...prev, websocket_support: needsWebsockets || prev.websocket_support }))
}}
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"
>
{APPLICATION_PRESETS.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.label} - {preset.description}
</option>
))}
</select>
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Application Preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPLICATION_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label} - {preset.description}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
Presets automatically configure headers for remote access behind tunnels/CGNAT.
</p>
@@ -232,12 +232,8 @@ describe('AccessListForm', () => {
await user.selectOptions(typeSelect, 'whitelist');
// Toggle local network only
const localNetworkSwitch = screen.getByLabelText(/Local Network Only/i)
.querySelector('input[type="checkbox"]');
if (localNetworkSwitch) {
await user.click(localNetworkSwitch);
}
const localNetworkSwitch = screen.getByRole('checkbox', { name: /Local Network Only/i });
await user.click(localNetworkSwitch);
// IP inputs should be hidden
expect(screen.queryByPlaceholderText(/192.168.1.0\/24/i)).not.toBeInTheDocument();
@@ -260,7 +256,7 @@ describe('AccessListForm', () => {
/>
);
const submitBtn = screen.getByRole('button', { name: /Create/i });
const submitBtn = screen.getByRole('button', { name: /Saving.../i });
expect(submitBtn).toBeDisabled();
const cancelBtn = screen.getByRole('button', { name: /Cancel/i });
@@ -278,7 +274,7 @@ describe('AccessListForm', () => {
/>
);
const deleteBtn = screen.getByRole('button', { name: /Delete/i });
const deleteBtn = screen.getByRole('button', { name: /Deleting.../i });
expect(deleteBtn).toBeDisabled();
});
@@ -401,17 +397,17 @@ describe('AccessListForm', () => {
/>
);
const deleteBtn = screen.getByRole('button', { name: /Delete/i });
const deleteBtn = screen.getByRole('button', { name: /Deleting.../i });
expect(deleteBtn).toBeDisabled();
});
it('applies security preset for blacklist', async () => {
it('applies security preset for geo blacklist', async () => {
render(<AccessListForm onSubmit={mockSubmit} onCancel={mockCancel} />);
await user.type(screen.getByLabelText(/Name/i), 'Preset Test');
const typeSelect = screen.getByLabelText(/Type/i);
await user.selectOptions(typeSelect, 'blacklist');
await user.selectOptions(typeSelect, 'geo_blacklist');
const showBtn = screen.getByRole('button', { name: /Show Presets/i });
await user.click(showBtn);
@@ -449,12 +445,8 @@ describe('AccessListForm', () => {
await user.type(screen.getByLabelText(/Name/i), 'Switch Test');
const enabledSwitch = screen.getByLabelText(/^Enabled$/)
.querySelector('input[type="checkbox"]');
if (enabledSwitch) {
await user.click(enabledSwitch);
}
const enabledSwitch = screen.getByRole('checkbox', { name: /^Enabled$/i });
await user.click(enabledSwitch);
await user.click(screen.getByRole('button', { name: /Create/i }));
@@ -565,7 +557,7 @@ describe('AccessListForm', () => {
const typeSelect = screen.getByLabelText(/Type/i);
await user.selectOptions(typeSelect, 'blacklist');
expect(screen.getByText(/Recommended: Block lists are safer/i)).toBeInTheDocument();
expect(screen.getByText(/Block lists are safer/i)).toBeInTheDocument();
});
it('renders best practices link', () => {
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import userEvent from '@testing-library/user-event';
import AccessListSelector from '../AccessListSelector';
import * as useAccessListsHook from '../../hooks/useAccessLists';
import type { AccessList } from '../../api/accessLists';
@@ -35,11 +36,12 @@ describe('AccessListSelector', () => {
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
const trigger = screen.getByRole('combobox', { name: /Access Control List/i });
expect(trigger).toBeInTheDocument();
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
});
it('should render with access lists and show only enabled ones', () => {
it('should render with access lists and show only enabled ones', async () => {
const mockLists: AccessList[] = [
{
id: 1,
@@ -75,6 +77,7 @@ describe('AccessListSelector', () => {
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
const user = userEvent.setup();
render(
<Wrapper>
@@ -82,9 +85,11 @@ describe('AccessListSelector', () => {
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('Test ACL 1 (whitelist)')).toBeInTheDocument();
expect(screen.queryByText('Test ACL 2 (blacklist)')).not.toBeInTheDocument();
const trigger = screen.getByRole('combobox', { name: /Access Control List/i });
await user.click(trigger);
expect(screen.getByRole('option', { name: 'Test ACL 1 (whitelist)' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Test ACL 2 (blacklist)' })).not.toBeInTheDocument();
});
it('should show selected ACL details', () => {
@@ -1,20 +1,16 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClientProvider } from '@tanstack/react-query'
import CertificateList from '../CertificateList'
import { createTestQueryClient } from '../../test/createTestQueryClient'
import { useCertificates } from '../../hooks/useCertificates'
import { useProxyHosts } from '../../hooks/useProxyHosts'
import type { Certificate } from '../../api/certificates'
import type { ProxyHost } from '../../api/proxyHosts'
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: vi.fn(() => ({
certificates: [
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'expired', provider: 'custom' },
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: new Date().toISOString(), status: 'untrusted', provider: 'letsencrypt-staging' },
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'valid', provider: 'custom' },
],
isLoading: false,
error: null,
}))
useCertificates: vi.fn(),
}))
vi.mock('../../api/certificates', () => ({
@@ -26,19 +22,7 @@ vi.mock('../../api/backups', () => ({
}))
vi.mock('../../hooks/useProxyHosts', () => ({
useProxyHosts: vi.fn(() => ({
hosts: [
{ uuid: 'h1', name: 'Host1', certificate_id: 3 },
],
loading: false,
isFetching: false,
error: null,
createHost: vi.fn(),
updateHost: vi.fn(),
deleteHost: vi.fn(),
bulkUpdateACL: vi.fn(),
isBulkUpdating: false,
})),
useProxyHosts: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
@@ -50,6 +34,76 @@ function renderWithClient(ui: React.ReactNode) {
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
}
const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertificates>> = {}) => {
const certificates: Certificate[] = [
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' },
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
]
return {
certificates,
isLoading: false,
error: null,
refetch: vi.fn(),
...overrides,
}
}
const createProxyHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
uuid: 'h1',
name: 'Host1',
domain_names: 'host1.example.com',
forward_scheme: 'http',
forward_host: '127.0.0.1',
forward_port: 80,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: false,
websocket_support: false,
application: 'none',
locations: [],
enabled: true,
created_at: '2026-02-01T00:00:00Z',
updated_at: '2026-02-01T00:00:00Z',
certificate_id: 3,
...overrides,
})
const createProxyHostsValue = (overrides: Partial<ReturnType<typeof useProxyHosts>> = {}): ReturnType<typeof useProxyHosts> => ({
hosts: [
createProxyHost(),
],
loading: false,
isFetching: false,
error: null,
createHost: vi.fn(),
updateHost: vi.fn(),
deleteHost: vi.fn(),
bulkUpdateACL: vi.fn(),
bulkUpdateSecurityHeaders: vi.fn(),
isCreating: false,
isUpdating: false,
isDeleting: false,
isBulkUpdating: false,
...overrides,
})
const getRowNames = () =>
screen
.getAllByRole('row')
.slice(1)
.map(row => row.querySelector('td')?.textContent?.trim() ?? '')
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue())
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue())
})
describe('CertificateList', () => {
it('deletes custom certificate when confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
@@ -86,28 +140,54 @@ describe('CertificateList', () => {
confirmSpy.mockRestore()
})
it('blocks deletion when certificate is in use by a proxy host', async () => {
const { toast } = await import('../../utils/toast')
it('deletes valid custom certificate when not in use', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
const { deleteCertificate } = await import('../../api/certificates')
const { createBackup } = await import('../../api/backups')
const user = userEvent.setup()
renderWithClient(<CertificateList />)
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
// Find button corresponding to ActiveCert (id 3)
const activeButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
expect(activeButton).toBeTruthy()
if (activeButton) await user.click(activeButton)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('in use')))
const rows = await screen.findAllByRole('row')
const unusedRow = rows.find(r => r.querySelector('td')?.textContent?.includes('UnusedValidCert')) as HTMLElement
expect(unusedRow).toBeTruthy()
const unusedButton = unusedRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
expect(unusedButton).toBeTruthy()
await user.click(unusedButton)
await waitFor(() => expect(createBackup).toHaveBeenCalled())
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(4))
confirmSpy.mockRestore()
})
it('blocks deletion when certificate status is active (valid/expiring)', async () => {
const { toast } = await import('../../utils/toast')
const user = userEvent.setup()
it('renders empty state when no certificates exist', async () => {
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ certificates: [] }))
renderWithClient(<CertificateList />)
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
// ActiveCert (valid) should block even if not linked ensure hosts mock links it so previous test covers linkage.
// Here, simulate clicking a valid cert button if present
const validButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
expect(validButton).toBeTruthy()
if (validButton) await user.click(validButton)
await waitFor(() => expect(toast.error).toHaveBeenCalled())
expect(await screen.findByText('No certificates found.')).toBeInTheDocument()
})
it('shows error state when certificate load fails', async () => {
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ error: new Error('boom') }))
renderWithClient(<CertificateList />)
expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument()
})
it('sorts certificates by name and expiry when headers are clicked', async () => {
const certificates: Certificate[] = [
{ id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
{ id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' },
]
const user = userEvent.setup()
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue({ certificates }))
renderWithClient(<CertificateList />)
expect(getRowNames()).toEqual(['Alpha', 'Zulu'])
await user.click(screen.getByText('Expires'))
expect(getRowNames()).toEqual(['Alpha', 'Zulu'])
await user.click(screen.getByText('Expires'))
expect(getRowNames()).toEqual(['Zulu', 'Alpha'])
})
})
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider, type UseMutationResult } from '@tanstack/react-query'
import CredentialManager from '../CredentialManager'
import {
useCredentials,
@@ -20,7 +20,7 @@ vi.mock('react-i18next', () => ({
}),
}))
import type { DNSProvider, DNSProviderTypeInfo } from '../../api/dnsProviders'
import type { DNSProviderCredential } from '../../api/credentials'
import type { CredentialRequest, CredentialTestResult, DNSProviderCredential } from '../../api/credentials'
vi.mock('../../hooks/useCredentials')
vi.mock('../../utils/toast', () => ({
@@ -87,6 +87,28 @@ const mockCredentials: DNSProviderCredential[] = [
},
]
const createCredentialsQueryResult = (
overrides: Record<string, unknown> = {}
): ReturnType<typeof useCredentials> => ({
data: mockCredentials,
isLoading: false,
refetch: vi.fn(),
error: null,
isError: false,
isSuccess: true,
...overrides,
} as unknown as ReturnType<typeof useCredentials>)
const createMutationResult = <TData, TVariables>(
mutateAsync: ReturnType<typeof vi.fn>,
overrides: Partial<UseMutationResult<TData, Error, TVariables, unknown>> = {}
): UseMutationResult<TData, Error, TVariables, unknown> => ({
mutate: vi.fn() as UseMutationResult<TData, Error, TVariables, unknown>['mutate'],
mutateAsync: mutateAsync as UseMutationResult<TData, Error, TVariables, unknown>['mutateAsync'],
isPending: false,
...overrides,
} as UseMutationResult<TData, Error, TVariables, unknown>)
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -99,7 +121,6 @@ const renderWithClient = (ui: React.ReactElement) => {
describe('CredentialManager', () => {
const mockOnOpenChange = vi.fn()
const mockRefetch = vi.fn()
const mockCreateMutate = vi.fn()
const mockUpdateMutate = vi.fn()
const mockDeleteMutate = vi.fn()
@@ -108,34 +129,32 @@ describe('CredentialManager', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCredentials).mockReturnValue({
data: mockCredentials,
isLoading: false,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: true,
} as any)
vi.mocked(useCredentials).mockReturnValue(createCredentialsQueryResult())
vi.mocked(useCreateCredential).mockReturnValue({
mutateAsync: mockCreateMutate,
isPending: false,
} as any)
vi.mocked(useCreateCredential).mockReturnValue(
createMutationResult<DNSProviderCredential, { providerId: number; data: CredentialRequest }>(
mockCreateMutate
)
)
vi.mocked(useUpdateCredential).mockReturnValue({
mutateAsync: mockUpdateMutate,
isPending: false,
} as any)
vi.mocked(useUpdateCredential).mockReturnValue(
createMutationResult<
DNSProviderCredential,
{ providerId: number; credentialId: number; data: CredentialRequest }
>(mockUpdateMutate)
)
vi.mocked(useDeleteCredential).mockReturnValue({
mutateAsync: mockDeleteMutate,
isPending: false,
} as any)
vi.mocked(useDeleteCredential).mockReturnValue(
createMutationResult<void, { providerId: number; credentialId: number }>(
mockDeleteMutate
)
)
vi.mocked(useTestCredential).mockReturnValue({
mutateAsync: mockTestMutate,
isPending: false,
} as any)
vi.mocked(useTestCredential).mockReturnValue(
createMutationResult<CredentialTestResult, { providerId: number; credentialId: number }>(
mockTestMutate
)
)
})
// 1. Rendering Checks
@@ -350,14 +369,9 @@ describe('CredentialManager', () => {
// 7. Empty Credential List Rendering
it('renders empty state when no credentials exist', () => {
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: false,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: true,
} as any)
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [] })
)
renderWithClient(
<CredentialManager
@@ -375,14 +389,15 @@ describe('CredentialManager', () => {
// 8. Loading State
it('renders loading state while fetching credentials', () => {
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: true,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: false,
} as any)
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({
data: [],
isLoading: true,
isSuccess: false,
status: 'loading',
fetchStatus: 'fetching',
})
)
renderWithClient(
<CredentialManager
@@ -527,21 +542,16 @@ describe('CredentialManager', () => {
key_version: 1,
success_count: 5,
failure_count: 2,
last_used_at: null,
last_error: null,
last_used_at: undefined,
last_error: undefined,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
}
]
vi.mocked(useCredentials).mockReturnValue({
data: multipleCreds,
isLoading: false,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: true,
} as any)
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: multipleCreds })
)
renderWithClient(
<CredentialManager
@@ -564,14 +574,9 @@ describe('CredentialManager', () => {
enabled: false,
}
vi.mocked(useCredentials).mockReturnValue({
data: [disabledCred],
isLoading: false,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: true,
} as any)
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [disabledCred] })
)
renderWithClient(
<CredentialManager
@@ -593,14 +598,9 @@ describe('CredentialManager', () => {
last_error: 'API rate limit exceeded',
}
vi.mocked(useCredentials).mockReturnValue({
data: [errorCred],
isLoading: false,
refetch: mockRefetch,
error: null,
isError: false,
isSuccess: true,
} as any)
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [errorCred] })
)
renderWithClient(
<CredentialManager
@@ -77,23 +77,40 @@ describe('Layout', () => {
})
it('renders all navigation items', async () => {
const user = userEvent.setup()
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Certificates')).toBeInTheDocument()
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
expect(await screen.findByText('Proxy Hosts')).toBeInTheDocument()
expect(await screen.findByText('Remote Servers')).toBeInTheDocument()
expect(await screen.findByText('Domains')).toBeInTheDocument()
expect(await screen.findByText('Certificates')).toBeInTheDocument()
expect(await screen.findByText('DNS')).toBeInTheDocument()
expect(await screen.findByText('Settings')).toBeInTheDocument()
// Expand DNS to see nested items
await user.click(await screen.findByRole('button', { name: /dns/i }))
expect(await screen.findByText('DNS Providers')).toBeInTheDocument()
expect(await screen.findByText('Plugins')).toBeInTheDocument()
// Expand Security to see nested items
await user.click(await screen.findByRole('button', { name: /security/i }))
expect(await screen.findByText('Access Lists')).toBeInTheDocument()
expect(await screen.findByText('Rate Limiting')).toBeInTheDocument()
// Expand Tasks and Import to see nested items
await userEvent.click(screen.getByText('Tasks'))
expect(screen.getByText('Import')).toBeInTheDocument()
await userEvent.click(screen.getByText('Import'))
expect(screen.getByText('Caddyfile')).toBeInTheDocument()
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: /tasks/i }))
expect(await screen.findByText('Import')).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: /import/i }))
expect(await screen.findByText('Caddyfile')).toBeInTheDocument()
const crowdSecLinks = await screen.findAllByRole('link', { name: 'CrowdSec' })
expect(crowdSecLinks.some(link => link.getAttribute('href') === '/tasks/import/crowdsec')).toBe(true)
expect(await screen.findByText('Import NPM')).toBeInTheDocument()
expect(await screen.findByText('Import JSON')).toBeInTheDocument()
})
it('renders children content', () => {
@@ -281,8 +298,7 @@ describe('Layout', () => {
})
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({} as any)
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
renderWithProviders(
<Layout>
@@ -87,11 +87,12 @@ describe('NotificationCenter', () => {
})
it('opens notification panel on click', async () => {
const user = userEvent.setup()
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
render(<NotificationCenter />, { wrapper: createWrapper() })
const bellButton = screen.getByRole('button', { name: /notifications/i })
await userEvent.click(bellButton)
await user.click(bellButton)
await waitFor(() => {
expect(screen.getByText('Notifications')).toBeInTheDocument()
@@ -103,11 +104,12 @@ describe('NotificationCenter', () => {
})
it('displays empty state when no notifications', async () => {
const user = userEvent.setup()
vi.mocked(api.getNotifications).mockResolvedValue([])
render(<NotificationCenter />, { wrapper: createWrapper() })
const bellButton = screen.getByRole('button', { name: /notifications/i })
await userEvent.click(bellButton)
await user.click(bellButton)
await waitFor(() => {
expect(screen.getByText('No new notifications')).toBeInTheDocument()
@@ -115,19 +117,20 @@ describe('NotificationCenter', () => {
})
it('marks single notification as read', async () => {
const user = userEvent.setup()
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
vi.mocked(api.markNotificationRead).mockResolvedValue()
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await user.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Info Notification')).toBeInTheDocument()
})
const closeButtons = screen.getAllByRole('button', { name: /close/i })
await userEvent.click(closeButtons[0])
await user.click(closeButtons[0])
await waitFor(() => {
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
@@ -135,18 +138,19 @@ describe('NotificationCenter', () => {
})
it('marks all notifications as read', async () => {
const user = userEvent.setup()
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await user.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Mark all read')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('Mark all read'))
await user.click(screen.getByText('Mark all read'))
await waitFor(() => {
expect(api.markAllNotificationsRead).toHaveBeenCalled()
@@ -154,16 +158,17 @@ describe('NotificationCenter', () => {
})
it('closes panel when clicking outside', async () => {
const user = userEvent.setup()
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
render(<NotificationCenter />, { wrapper: createWrapper() })
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
await user.click(screen.getByRole('button', { name: /notifications/i }))
await waitFor(() => {
expect(screen.getByText('Notifications')).toBeInTheDocument()
})
await userEvent.click(screen.getByTestId('notification-backdrop'))
await user.click(screen.getByTestId('notification-backdrop'))
await waitFor(() => {
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
@@ -81,6 +81,30 @@ vi.mock('../../hooks/useDNSProviders', () => ({
})),
}))
vi.mock('../../hooks/useDNSDetection', () => ({
useDetectDNSProvider: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({
domain: 'example.com',
detected: false,
nameservers: [],
confidence: 'none',
}),
isPending: false,
data: undefined,
reset: vi.fn(),
})),
}))
vi.mock('../../api/dnsDetection', () => ({
detectDNSProvider: vi.fn().mockResolvedValue({
domain: 'example.com',
detected: false,
nameservers: [],
confidence: 'none',
}),
getDetectionPatterns: vi.fn().mockResolvedValue([]),
}))
vi.mock('../../api/proxyHosts', () => ({
testProxyHostConnection: vi.fn(),
}))
@@ -83,7 +83,12 @@ vi.mock('../../hooks/useSecurityHeaders', () => ({
vi.mock('../../hooks/useDNSDetection', () => ({
useDetectDNSProvider: vi.fn(() => ({
mutateAsync: vi.fn(),
mutateAsync: vi.fn().mockResolvedValue({
domain: 'example.com',
detected: false,
nameservers: [],
confidence: 'none',
}),
isPending: false,
data: null,
reset: vi.fn(),
@@ -144,6 +149,13 @@ const renderWithClientAct = async (ui: React.ReactElement) => {
})
}
const selectComboboxOption = async (label: string | RegExp, optionText: string) => {
const trigger = screen.getByRole('combobox', { name: label })
await userEvent.click(trigger)
const option = await screen.findByRole('option', { name: optionText })
await userEvent.click(option)
}
import { testProxyHostConnection } from '../../api/proxyHosts'
describe('ProxyHostForm', () => {
@@ -170,12 +182,7 @@ describe('ProxyHostForm', () => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
// Find scheme select - it defaults to HTTP
// We can find it by label "Scheme"
const schemeSelect = screen.getByLabelText('Scheme') as HTMLSelectElement
await userEvent.selectOptions(schemeSelect, 'https')
expect(schemeSelect).toHaveValue('https')
await selectComboboxOption('Scheme', 'HTTPS')
})
it('prompts to save new base domain', async () => {
@@ -289,15 +296,15 @@ describe('ProxyHostForm', () => {
expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument()
})
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
await selectComboboxOption(/Base Domain/i, 'existing.com')
// Should not update domain names yet as no container selected
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('')
// Select container then base domain
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'container-123')
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
await selectComboboxOption('Source', 'Local (Docker Socket)')
await selectComboboxOption('Containers', 'my-app (nginx:latest)')
await selectComboboxOption(/Base Domain/i, 'existing.com')
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
})
@@ -309,17 +316,20 @@ describe('ProxyHostForm', () => {
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const presetSelect = screen.getByLabelText(/Application Preset/i)
expect(presetSelect).toBeInTheDocument()
const presetTrigger = screen.getByRole('combobox', { name: /Application Preset/i })
expect(presetTrigger).toBeInTheDocument()
await userEvent.click(presetTrigger)
const presetListbox = screen.getByRole('listbox')
// Check that all presets are available
expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
expect(within(presetListbox).getByText('None - Standard reverse proxy')).toBeInTheDocument()
expect(within(presetListbox).getByText('Plex - Media server with remote access')).toBeInTheDocument()
expect(within(presetListbox).getByText('Jellyfin - Open source media server')).toBeInTheDocument()
expect(within(presetListbox).getByText('Emby - Media server')).toBeInTheDocument()
expect(within(presetListbox).getByText('Home Assistant - Home automation')).toBeInTheDocument()
expect(within(presetListbox).getByText('Nextcloud - File sync and share')).toBeInTheDocument()
expect(within(presetListbox).getByText('Vaultwarden - Password manager')).toBeInTheDocument()
})
it('defaults to none preset', async () => {
@@ -327,8 +337,8 @@ describe('ProxyHostForm', () => {
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const presetSelect = screen.getByLabelText(/Application Preset/i)
expect(presetSelect).toHaveValue('none')
const presetTrigger = screen.getByRole('combobox', { name: /Application Preset/i })
expect(presetTrigger).toHaveTextContent('None - Standard reverse proxy')
})
it('enables websockets when selecting plex preset', async () => {
@@ -343,9 +353,7 @@ describe('ProxyHostForm', () => {
}
// Select Plex preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
})
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Websockets should be enabled
expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
@@ -360,7 +368,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Should show the helper with external URL
await waitFor(() => {
@@ -378,9 +386,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'jellyfin.mydomain.com')
// Select Jellyfin preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'jellyfin')
})
await selectComboboxOption(/Application Preset/i, 'Jellyfin - Open source media server')
// Wait for health API fetch and show helper
await waitFor(() => {
@@ -398,9 +404,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ha.mydomain.com')
// Select Home Assistant preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'homeassistant')
})
await selectComboboxOption(/Application Preset/i, 'Home Assistant - Home automation')
// Wait for health API fetch and show helper
await waitFor(() => {
@@ -419,9 +423,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'nextcloud.mydomain.com')
// Select Nextcloud preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'nextcloud')
})
await selectComboboxOption(/Application Preset/i, 'Nextcloud - File sync and share')
// Wait for health API fetch and show helper
await waitFor(() => {
@@ -440,7 +442,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'vault.mydomain.com')
// Select Vaultwarden preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'vaultwarden')
await selectComboboxOption(/Application Preset/i, 'Vaultwarden - Password manager')
// Wait for helper text
await waitFor(() => {
@@ -476,17 +478,17 @@ describe('ProxyHostForm', () => {
)
// Select local source
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
await selectComboboxOption('Source', 'Local (Docker Socket)')
// Select the plex container
await waitFor(() => {
expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
})
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'plex-container')
await selectComboboxOption('Containers', 'plex (linuxserver/plex:latest)')
// The preset should be auto-detected as plex
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
expect(screen.getByRole('combobox', { name: /Application Preset/i })).toHaveTextContent('Plex')
})
it('auto-populates advanced_config when selecting plex preset and field empty', async () => {
@@ -499,9 +501,7 @@ describe('ProxyHostForm', () => {
expect(textarea).toHaveValue('')
// Select Plex preset
await act(async () => {
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
})
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Value should now be populated with a snippet containing X-Real-IP which is used by the preset
await waitFor(() => {
@@ -537,7 +537,7 @@ describe('ProxyHostForm', () => {
)
// Select Plex preset (should prompt since advanced_config is non-empty)
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
await waitFor(() => {
expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument()
@@ -604,7 +604,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByLabelText(/^Port$/), '32400')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Submit form
await userEvent.click(screen.getByText('Save'))
@@ -645,7 +645,7 @@ describe('ProxyHostForm', () => {
)
// The preset should be pre-selected
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
expect(screen.getByRole('combobox', { name: /Application Preset/i })).toHaveTextContent('Plex')
// The config helper should be visible
await waitFor(() => {
@@ -684,7 +684,7 @@ describe('ProxyHostForm', () => {
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Wait for helper to appear
await waitFor(() => {
@@ -742,11 +742,10 @@ describe('ProxyHostForm', () => {
// Select 'Trusted IPs'
// Need to find the select by label/aria-label? AccessListSelector has label "Access Control List"
const aclSelect = screen.getByLabelText(/Access Control List/i)
await userEvent.selectOptions(aclSelect, '10')
await selectComboboxOption(/Access Control List/i, 'Trusted IPs (allow list)')
// Verify it was selected
expect(aclSelect).toHaveValue('10')
expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('Trusted IPs')
// Verify description appears
expect(screen.getByText('Trusted IPs')).toBeInTheDocument()
@@ -836,8 +835,8 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'My Service')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'myservice.com')
await userEvent.selectOptions(screen.getByLabelText(/^Scheme/), 'https')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'myservice.existing.com')
await selectComboboxOption('Scheme', 'HTTPS')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.clear(screen.getByLabelText(/^Port/))
await userEvent.type(screen.getByLabelText(/^Port/), '8080')
@@ -847,7 +846,7 @@ describe('ProxyHostForm', () => {
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'My Service',
domain_names: 'myservice.com',
domain_names: 'myservice.existing.com',
forward_scheme: 'https',
forward_host: '192.168.1.100',
forward_port: 8080,
@@ -861,13 +860,12 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Cert Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'cert.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'cert.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Select certificate
const certSelect = screen.getByLabelText(/Certificate/i)
await userEvent.selectOptions(certSelect, '1')
await selectComboboxOption(/SSL Certificate/i, 'Cert 1 (custom)')
await userEvent.click(screen.getByText('Save'))
@@ -884,13 +882,12 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Security Headers Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'secure.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'secure.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
// Select security header profile
const profileSelect = screen.getByLabelText(/Security Headers/i)
await userEvent.selectOptions(profileSelect, '100')
await selectComboboxOption(/Security Headers/i, 'Strict Profile (Score: 90/100)')
await userEvent.click(screen.getByText('Save'))
@@ -936,7 +933,7 @@ describe('ProxyHostForm', () => {
// Fields should be pre-filled
expect(screen.getByDisplayValue('Existing Service')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing.com')).toBeInTheDocument()
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('existing.com')
expect(screen.getByDisplayValue('192.168.1.50')).toBeInTheDocument()
// Update and save
@@ -997,61 +994,25 @@ describe('ProxyHostForm', () => {
})
describe('Scheme Selection', () => {
it('shows scheme options http, https, ws, wss', async () => {
it('shows scheme options http and https', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const schemeSelect = screen.getByLabelText('Scheme')
expect(schemeSelect).toBeInTheDocument()
const schemeTrigger = screen.getByRole('combobox', { name: 'Scheme' })
await userEvent.click(schemeTrigger)
const options = schemeSelect.querySelectorAll('option')
const values = Array.from(options).map(o => o.value)
expect(values).toContain('http')
expect(values).toContain('https')
expect(values).toContain('ws')
expect(values).toContain('wss')
expect(await screen.findByRole('option', { name: 'HTTP' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'HTTPS' })).toBeInTheDocument()
})
it('accepts websockets scheme', async () => {
it('accepts https scheme', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'WS Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ws.example.com')
await userEvent.selectOptions(screen.getByLabelText('Scheme') as HTMLSelectElement, 'ws')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '8000')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_scheme: 'ws',
}))
})
})
it('accepts secure websockets scheme', async () => {
await renderWithClientAct(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await userEvent.type(screen.getByLabelText(/^Name/), 'WSS Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'wss.example.com')
await userEvent.selectOptions(screen.getByLabelText('Scheme') as HTMLSelectElement, 'wss')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '8000')
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({
forward_scheme: 'wss',
}))
})
await selectComboboxOption('Scheme', 'HTTPS')
expect(screen.getByRole('combobox', { name: 'Scheme' })).toHaveTextContent('HTTPS')
})
})
@@ -1075,11 +1036,11 @@ describe('ProxyHostForm', () => {
)
// Select Plex preset
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access')
// Find advanced config field (it's in a collapsible section)
// Check that advanced config JSON for plex has been populated
const advancedConfigField = screen.getByPlaceholderText(/Caddy JSON config/i) as HTMLTextAreaElement
const advancedConfigField = screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement
// Verify it contains JSON (Plex has some default config)
if (advancedConfigField.value) {
@@ -1093,7 +1054,7 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Custom Config')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'custom.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'custom.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
@@ -1117,7 +1078,7 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Port Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'port.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'port.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
// Clear and set invalid port
@@ -1125,13 +1086,11 @@ describe('ProxyHostForm', () => {
await userEvent.clear(portInput)
await userEvent.type(portInput, '99999')
// The form should still allow submission (validation happens server-side usually)
// But port should be converted to number
// Invalid port should block submission via native validation
await userEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})
expect(portInput).toBeInvalid()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
@@ -1142,8 +1101,9 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Docker Container')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'docker.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'docker.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '172.17.0.2')
await userEvent.clear(screen.getByLabelText(/^Port/))
await userEvent.type(screen.getByLabelText(/^Port/), '8080')
await userEvent.click(screen.getByText('Save'))
@@ -1161,8 +1121,9 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Localhost')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'localhost.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'localhost.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), 'localhost')
await userEvent.clear(screen.getByLabelText(/^Port/))
await userEvent.type(screen.getByLabelText(/^Port/), '3000')
await userEvent.click(screen.getByText('Save'))
@@ -1182,7 +1143,7 @@ describe('ProxyHostForm', () => {
)
await userEvent.type(screen.getByLabelText(/^Name/), 'Enabled Test')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'enabled.example.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'enabled.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
@@ -1214,7 +1175,7 @@ describe('ProxyHostForm', () => {
expect(standardHeadersCheckbox).not.toBeChecked()
await userEvent.type(screen.getByLabelText(/^Name/), 'No Headers')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'no-headers.com')
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'no-headers.existing.com')
await userEvent.type(screen.getByLabelText(/^Host/), '192.168.1.100')
await userEvent.type(screen.getByLabelText(/^Port/), '80')
@@ -70,18 +70,14 @@ describe('SecurityNotificationSettingsModal', () => {
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
expect(levelSelect.value).toBe('warn');
expect(webhookInput.value).toBe('https://example.com/webhook');
});
// Check that settings are loaded
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
expect(levelSelect.value).toBe('warn');
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(webhookInput.value).toBe('https://example.com/webhook');
});
it('closes modal when close button is clicked', async () => {
+1 -1
View File
@@ -137,7 +137,7 @@ describe('Tabs', () => {
const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
tab1.focus()
await user.click(tab1)
expect(tab1).toHaveFocus()
// Arrow right should move focus and activate tab2
@@ -1,5 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { createRef } from 'react'
import { Search, Mail, Lock } from 'lucide-react'
import { Input } from '../Input'
@@ -100,14 +102,14 @@ describe('Input', () => {
})
it('forwards ref correctly', () => {
const ref = vi.fn()
const ref = createRef<HTMLInputElement>()
render(<Input ref={ref} />)
expect(ref).toHaveBeenCalled()
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement)
expect(ref.current).toBeInstanceOf(HTMLInputElement)
})
it('handles password type with toggle visibility', () => {
it('handles password type with toggle visibility', async () => {
const user = userEvent.setup()
render(<Input type="password" placeholder="Enter password" />)
const input = screen.getByPlaceholderText('Enter password')
@@ -118,12 +120,12 @@ describe('Input', () => {
expect(toggleButton).toBeInTheDocument()
// Click to show password
fireEvent.click(toggleButton)
await user.click(toggleButton)
expect(input).toHaveAttribute('type', 'text')
expect(screen.getByRole('button', { name: /hide password/i })).toBeInTheDocument()
// Click again to hide
fireEvent.click(screen.getByRole('button', { name: /hide password/i }))
await user.click(screen.getByRole('button', { name: /hide password/i }))
expect(input).toHaveAttribute('type', 'password')
})
@@ -133,12 +135,13 @@ describe('Input', () => {
expect(screen.queryByRole('button', { name: /password/i })).not.toBeInTheDocument()
})
it('handles value changes', () => {
it('handles value changes', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<Input onChange={handleChange} placeholder="Input" />)
const input = screen.getByPlaceholderText('Input')
fireEvent.change(input, { target: { value: 'test value' } })
await user.type(input, 'test value')
expect(handleChange).toHaveBeenCalled()
expect(input).toHaveValue('test value')
+1
View File
@@ -77,6 +77,7 @@ export default function Certificates() {
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<Input
id="certificate-name"
label={t('certificates.friendlyName')}
value={name}
onChange={(e) => setName(e.target.value)}
+96 -18
View File
@@ -923,7 +923,13 @@ export default function CrowdSecConfig() {
</div>
</div>
<p className="text-sm text-gray-400">{t('crowdsecConfig.packages.description')}</p>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
data-testid="import-file"
accept=".tar.gz,.zip"
aria-label={t('crowdsecConfig.packages.selectFile') || "Select CrowdSec package"}
/>
</div>
</Card>
@@ -940,6 +946,7 @@ export default function CrowdSecConfig() {
<input
type="text"
placeholder={t('crowdsecConfig.presets.searchPlaceholder')}
aria-label={t('crowdsecConfig.presets.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -947,6 +954,7 @@ export default function CrowdSecConfig() {
</div>
<select
value={sortBy}
aria-label={t('crowdsecConfig.presets.sortBy') || "Sort presets"}
onChange={(e) => setSortBy(e.target.value as 'alpha' | 'type' | 'source')}
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
@@ -961,7 +969,16 @@ export default function CrowdSecConfig() {
<div
key={preset.slug}
onClick={() => setSelectedPresetSlug(preset.slug)}
className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 ${
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedPresetSlug(preset.slug)
}
}}
aria-pressed={selectedPresetSlug === preset.slug}
className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 outline-none focus-visible:bg-gray-800 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${
selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'
}`}
>
@@ -1092,6 +1109,7 @@ export default function CrowdSecConfig() {
value={selectedPath ?? ''}
onChange={(e) => handleReadFile(e.target.value)}
data-testid="crowdsec-file-select"
aria-label={t('crowdsecConfig.files.selectFile')}
>
<option value="">{t('crowdsecConfig.files.selectFile')}</option>
{listMutation.data?.files?.map((f) => (
@@ -1100,7 +1118,13 @@ export default function CrowdSecConfig() {
</select>
<Button variant="secondary" onClick={() => listMutation.refetch()}>{t('crowdsecConfig.files.refresh')}</Button>
</div>
<textarea value={fileContent ?? ''} onChange={(e) => setFileContent(e.target.value)} rows={12} className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white" />
<textarea
value={fileContent ?? ''}
onChange={(e) => setFileContent(e.target.value)}
rows={12}
className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white"
aria-label={t('crowdsecConfig.files.content') || "File content"}
/>
<div className="flex gap-2">
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>{t('common.save')}</Button>
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>{t('common.close')}</Button>
@@ -1150,7 +1174,11 @@ export default function CrowdSecConfig() {
</thead>
<tbody>
{decisionsQuery.data.decisions.map((decision) => (
<tr key={decision.id} className="border-b border-gray-800 hover:bg-gray-800/50">
<tr
key={decision.id}
className="border-b border-gray-800 hover:bg-gray-800/50 focus:outline-none focus-visible:bg-gray-800 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
tabIndex={0}
>
<td className="py-2 px-3 font-mono text-white">{decision.ip}</td>
<td className="py-2 px-3 text-gray-300">{decision.reason || '-'}</td>
<td className="py-2 px-3 text-gray-300">{decision.duration}</td>
@@ -1180,15 +1208,30 @@ export default function CrowdSecConfig() {
{/* Ban IP Modal */}
{showBanModal && (
<>
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="ban-modal-title"
>
{/* Layer 1: Background overlay (z-40) */}
<div className="fixed inset-0 bg-black/60 z-40" onClick={() => setShowBanModal(false)} />
{/* Layer 2: Form container (z-50, pointer-events-none) */}
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowBanModal(false)}
role="button"
tabIndex={-1}
aria-label={t('common.close')}
/>
{/* Layer 3: Form content (pointer-events-auto) */}
<div className="bg-dark-card rounded-lg p-6 w-[480px] max-w-full pointer-events-auto">
<h3 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
{/* Layer 3: Form content (pointer-events-auto) */}
<div
className="relative bg-dark-card rounded-lg p-6 w-[480px] max-w-full shadow-xl"
onKeyDown={(e) => {
if (e.key === 'Escape') setShowBanModal(false)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) banMutation.mutate()
}}
>
<h3 id="ban-modal-title" className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<ShieldOff className="h-5 w-5 text-red-400" />
{t('crowdsecConfig.banModal.title')}
</h3>
@@ -1199,14 +1242,22 @@ export default function CrowdSecConfig() {
placeholder="192.168.1.100"
value={banForm.ip}
onChange={(e) => setBanForm({ ...banForm, ip: e.target.value })}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (banForm.ip.trim()) banMutation.mutate()
}
}}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5" htmlFor="ban-duration">{t('crowdsecConfig.banModal.durationLabel')}</label>
<select
id="ban-duration"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white"
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"
value={banForm.duration}
onChange={(e) => setBanForm({ ...banForm, duration: e.target.value })}
aria-label={t('crowdsecConfig.banModal.durationLabel')}
>
<option value="1h">{t('crowdsecConfig.banModal.duration1h')}</option>
<option value="4h">{t('crowdsecConfig.banModal.duration4h')}</option>
@@ -1225,6 +1276,13 @@ export default function CrowdSecConfig() {
rows={3}
value={banForm.reason}
onChange={(e) => setBanForm({ ...banForm, reason: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (banForm.ip.trim()) banMutation.mutate()
}
}}
aria-label={t('crowdsecConfig.banModal.reasonLabel')}
/>
</div>
</div>
@@ -1243,20 +1301,40 @@ export default function CrowdSecConfig() {
</div>
</div>
</div>
</>
)}
{/* Unban Confirmation Modal */}
{confirmUnban && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmUnban(null)} />
<div className="relative bg-dark-card rounded-lg p-6 w-[400px] max-w-full">
<h3 className="text-xl font-semibold text-white mb-4">{t('crowdsecConfig.unbanModal.title')}</h3>
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="unban-modal-title"
>
<div
className="absolute inset-0 bg-black/60"
onClick={() => setConfirmUnban(null)}
role="button"
tabIndex={-1}
aria-label={t('common.close')}
/>
<div
className="relative bg-dark-card rounded-lg p-6 w-[400px] max-w-full shadow-xl"
onKeyDown={(e) => {
if (e.key === 'Escape') setConfirmUnban(null)
if (e.key === 'Enter') unbanMutation.mutate(confirmUnban.ip)
}}
>
<h3 id="unban-modal-title" className="text-xl font-semibold text-white mb-4">{t('crowdsecConfig.unbanModal.title')}</h3>
<p className="text-gray-300 mb-6">
{t('crowdsecConfig.unbanModal.confirm')} <span className="font-mono text-white">{confirmUnban.ip}</span>?
</p>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={() => setConfirmUnban(null)}>
<Button
variant="secondary"
onClick={() => setConfirmUnban(null)}
autoFocus
>
{t('common.cancel')}
</Button>
<Button
+30 -9
View File
@@ -647,9 +647,16 @@ export default function ProxyHosts() {
<DialogHeader>
<DialogTitle>{t('proxyHosts.deleteConfirmTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.deleteConfirmMessage', { name: hostToDelete?.name || hostToDelete?.domain_names }).split('<strong>').map((part, i) =>
i === 0 ? part : <><strong key={i}>{part.split('</strong>')[0]}</strong>{part.split('</strong>')[1]}</>
)}
{t('proxyHosts.deleteConfirmMessage', { name: hostToDelete?.name || hostToDelete?.domain_names }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong>{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -675,9 +682,16 @@ export default function ProxyHosts() {
<DialogHeader>
<DialogTitle>{t('proxyHosts.bulkApplyTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.bulkApplyDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) =>
i === 0 ? part : <><strong key={i} className="text-brand-400">{part.split('</strong>')[0]}</strong>{part.split('</strong>')[1]}</>
)}
{t('proxyHosts.bulkApplyDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong className="text-brand-400">{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
@@ -879,9 +893,16 @@ export default function ProxyHosts() {
<DialogHeader>
<DialogTitle>{t('proxyHosts.applyACLTitle')}</DialogTitle>
<DialogDescription>
{t('proxyHosts.applyACLDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) =>
i === 0 ? part : <><strong key={i} className="text-brand-400">{part.split('</strong>')[0]}</strong>{part.split('</strong>')[1]}</>
)}
{t('proxyHosts.applyACLDescription', { count: selectedHosts.size }).split('<strong>').map((part, i) => {
if (i === 0) return part
const [strongText, rest] = part.split('</strong>')
return (
<span key={i}>
<strong className="text-brand-400">{strongText}</strong>
{rest}
</span>
)
})}
</DialogDescription>
</DialogHeader>
@@ -0,0 +1,332 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'
import AccessLists from '../AccessLists'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import type { AccessList, CreateAccessListRequest, TestIPResponse } from '../../api/accessLists'
import type { AccessListFormData } from '../../components/AccessListForm'
import { createBackup } from '../../api/backups'
import {
useAccessLists,
useCreateAccessList,
useDeleteAccessList,
useTestIP,
useUpdateAccessList,
} from '../../hooks/useAccessLists'
const translations: Record<string, string> = {
'accessLists.noAccessLists': 'No Access Lists',
'accessLists.noAccessListsDescription': 'No access list description',
'accessLists.createAccessList': 'Create Access List',
'accessLists.deleteAccessList': 'Delete Access List',
'accessLists.deleteSelectedAccessLists': 'Delete Selected Access Lists',
'accessLists.deleteItems': 'Delete ({{count}})',
'accessLists.testIp': 'Test IP',
'accessLists.testIpAddress': 'Test IP Address',
'accessLists.ipAddress': 'IP Address',
'accessLists.accessList': 'Access List',
'accessLists.deleteConfirmation': 'Delete {{name}}?',
'accessLists.bulkDeleteConfirmation': 'Delete {{count}}?',
'common.delete': 'Delete',
'common.cancel': 'Cancel',
'common.deleting': 'Deleting',
'common.test': 'Test',
'common.close': 'Close',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
}),
}))
interface MockAccessListFormProps {
onSubmit: (data: AccessListFormData) => void
onCancel: () => void
onDelete?: () => void
isLoading?: boolean
isDeleting?: boolean
initialData?: AccessList
}
const defaultAccessList: AccessList = {
id: 1,
uuid: 'acl-1',
name: 'Office Access',
description: 'Office CIDR',
type: 'whitelist',
ip_rules: '[{"cidr":"10.0.0.0/8"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2026-02-01T10:00:00Z',
updated_at: '2026-02-01T10:00:00Z',
}
const createAccessList = (overrides: Partial<AccessList> = {}): AccessList => ({
...defaultAccessList,
...overrides,
})
const createMutationResult = <TData, TVariables>(
overrides: Partial<UseMutationResult<TData, Error, TVariables, unknown>> = {},
mutateImpl?: (
variables: TVariables,
options?: {
onSuccess?: (result: TData) => void
onError?: (error: Error) => void
onSettled?: () => void
}
) => void
): UseMutationResult<TData, Error, TVariables, unknown> => {
const mutate = vi.fn((variables: TVariables, options?: { onSuccess?: (result: TData) => void; onError?: (error: Error) => void; onSettled?: () => void }) => {
mutateImpl?.(variables, options)
}) as UseMutationResult<TData, Error, TVariables, unknown>['mutate']
const mutateAsync = vi.fn() as UseMutationResult<TData, Error, TVariables, unknown>['mutateAsync']
return {
mutate,
mutateAsync,
reset: vi.fn(),
status: 'idle',
data: undefined,
error: null,
variables: undefined as unknown as TVariables,
context: undefined,
failureCount: 0,
failureReason: null,
isPaused: false,
isError: false,
isIdle: true,
isPending: false,
isSuccess: false,
submittedAt: 0,
...overrides,
} as UseMutationResult<TData, Error, TVariables, unknown>
}
const createQueryResult = <TData,>(data: TData): UseQueryResult<TData, Error> => ({
data,
error: null,
isError: false,
isLoading: false,
isPending: false,
isLoadingError: false,
isRefetchError: false,
isSuccess: true,
status: 'success',
fetchStatus: 'idle',
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPaused: false,
isPlaceholderData: false,
isRefetching: false,
isStale: false,
refetch: vi.fn(),
} as unknown as UseQueryResult<TData, Error>)
vi.mock('../../hooks/useAccessLists', () => ({
useAccessLists: vi.fn(),
useCreateAccessList: vi.fn(),
useUpdateAccessList: vi.fn(),
useDeleteAccessList: vi.fn(),
useTestIP: vi.fn(),
}))
vi.mock('../../api/backups', () => ({
createBackup: vi.fn(),
}))
vi.mock('react-hot-toast', () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../components/AccessListForm', () => ({
AccessListForm: ({ onSubmit, onCancel, onDelete, isLoading, isDeleting }: MockAccessListFormProps) => (
<div>
<div>AccessListForm</div>
<button
type="button"
onClick={() =>
onSubmit({
name: 'New List',
description: '',
type: 'whitelist',
ip_rules: '',
country_codes: '',
local_network_only: false,
enabled: true,
})
}
disabled={isLoading}
>
Submit
</button>
<button type="button" onClick={onCancel}>
Cancel
</button>
{onDelete ? (
<button type="button" onClick={onDelete} disabled={isDeleting}>
Delete
</button>
) : null}
</div>
),
}))
describe('AccessLists', () => {
const createMutationMock = (): ReturnType<typeof useCreateAccessList> =>
createMutationResult<AccessList, CreateAccessListRequest>()
const updateMutationMock = (): ReturnType<typeof useUpdateAccessList> =>
createMutationResult<AccessList, { id: number; data: Partial<CreateAccessListRequest> }>()
const deleteMutationMock = (): ReturnType<typeof useDeleteAccessList> =>
createMutationResult<void, number>({}, (_id, options) => options?.onSuccess?.(undefined))
const testIPMutationMock = (): ReturnType<typeof useTestIP> =>
createMutationResult<TestIPResponse, { id: number; ipAddress: string }>({}, (_payload, options) =>
options?.onSuccess?.({ allowed: true, reason: 'Allowed by rule' })
)
beforeEach(() => {
vi.clearAllMocks()
if (!vi.isMockFunction(window.open)) {
vi.spyOn(window, 'open').mockImplementation(() => null)
}
vi.mocked(useCreateAccessList).mockReturnValue(createMutationMock())
vi.mocked(useUpdateAccessList).mockReturnValue(updateMutationMock())
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutationMock())
vi.mocked(useTestIP).mockReturnValue(testIPMutationMock())
})
it('renders empty state and opens create form', async () => {
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
expect(await screen.findByText(t('accessLists.noAccessLists'))).toBeInTheDocument()
const emptyStateHeading = await screen.findByRole('heading', { name: t('accessLists.noAccessLists') })
const emptyStateContainer = emptyStateHeading.closest('div')
expect(emptyStateContainer).not.toBeNull()
await user.click(within(emptyStateContainer as HTMLElement).getByRole('button', { name: t('accessLists.createAccessList') }))
expect(screen.getByText('AccessListForm')).toBeInTheDocument()
})
it('shows CGNAT warning and allows dismiss', async () => {
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const alert = await screen.findByRole('alert')
expect(alert).toBeInTheDocument()
await user.click(within(alert).getByRole('button'))
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
it('deletes access list with backup', async () => {
const deleteMutation = deleteMutationMock()
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutation)
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const row = (await screen.findByText('Office Access')).closest('tr')
expect(row).not.toBeNull()
await user.click(within(row as HTMLElement).getByTitle(t('common.delete')))
const dialog = await screen.findByRole('dialog', { name: t('accessLists.deleteAccessList') })
await user.click(within(dialog).getByRole('button', { name: t('common.delete') }))
await waitFor(() => {
expect(createBackup).toHaveBeenCalled()
expect(deleteMutation.mutate).toHaveBeenCalledWith(1, expect.any(Object))
})
})
it('bulk deletes selected access lists', async () => {
const deleteMutation = deleteMutationMock()
vi.mocked(useDeleteAccessList).mockReturnValue(deleteMutation)
vi.mocked(useAccessLists).mockReturnValue(
createQueryResult([createAccessList({ id: 1 }), createAccessList({ id: 2, uuid: 'acl-2', name: 'Branch Office' })])
)
vi.mocked(createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
await user.click(await screen.findByRole('checkbox', { name: 'Select row 1' }))
const bulkDeleteButton = await screen.findByRole('button', { name: `${t('common.delete')} (1)` })
await user.click(bulkDeleteButton)
const dialog = await screen.findByRole('dialog', { name: t('accessLists.deleteSelectedAccessLists') })
await user.click(within(dialog).getByRole('button', { name: t('accessLists.deleteItems', { count: 1 }) }))
await waitFor(() => {
expect(createBackup).toHaveBeenCalled()
expect(deleteMutation.mutate).toHaveBeenCalledTimes(1)
})
})
it('tests IP against access list', async () => {
const testIPMutation = testIPMutationMock()
vi.mocked(useTestIP).mockReturnValue(testIPMutation)
vi.mocked(useAccessLists).mockReturnValue(createQueryResult([createAccessList()]))
const user = userEvent.setup()
renderWithQueryClient(<AccessLists />)
const row = (await screen.findByText('Office Access')).closest('tr')
expect(row).not.toBeNull()
await user.click(within(row as HTMLElement).getByTitle(t('accessLists.testIp')))
const dialog = await screen.findByRole('dialog', { name: t('accessLists.testIpAddress') })
const input = within(dialog).getByRole('textbox')
await user.type(input, '192.168.1.5')
await user.click(within(dialog).getByRole('button', { name: t('common.test') }))
await waitFor(() => {
expect(testIPMutation.mutate).toHaveBeenCalledWith(
{ id: 1, ipAddress: '192.168.1.5' },
expect.any(Object)
)
})
})
})
@@ -0,0 +1,147 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Certificates from '../Certificates'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import type { Certificate } from '../../api/certificates'
import { uploadCertificate } from '../../api/certificates'
import { toast } from '../../utils/toast'
const translations: Record<string, string> = {
'certificates.addCertificate': 'Add Certificate',
'certificates.uploadCertificate': 'Upload Certificate',
'certificates.friendlyName': 'Friendly Name',
'certificates.certificatePem': 'Certificate (PEM)',
'certificates.privateKeyPem': 'Private Key (PEM)',
'certificates.uploadSuccess': 'Certificate uploaded successfully',
'certificates.uploadFailed': 'Failed to upload certificate',
'common.upload': 'Upload',
'common.cancel': 'Cancel',
}
const t = (key: string, options?: Record<string, unknown>) => {
const template = translations[key] ?? key
if (!options) return template
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
return acc.replace(`{{${optionKey}}}`, String(optionValue))
}, template)
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t,
}),
}))
vi.mock('../../components/CertificateList', () => ({
default: () => <div>CertificateList</div>,
}))
vi.mock('../../api/certificates', () => ({
uploadCertificate: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
describe('Certificates', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uploads certificate and closes dialog on success', async () => {
const certificate: Certificate = {
domain: 'example.com',
issuer: 'Test CA',
expires_at: '2026-03-01T00:00:00Z',
status: 'valid',
provider: 'custom',
}
vi.mocked(uploadCertificate).mockResolvedValue(certificate)
const user = userEvent.setup()
const { queryClient } = renderWithQueryClient(<Certificates />)
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
})
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
})
})
it('surfaces upload errors', async () => {
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
const user = userEvent.setup()
renderWithQueryClient(<Certificates />)
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
await user.type(nameInput, 'My Cert')
await waitFor(() => {
expect(nameInput.value).toBe('My Cert')
})
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
await user.upload(certInput, certFile)
await user.upload(keyInput, keyFile)
await waitFor(() => {
expect(certInput.files?.[0]).toBe(certFile)
expect(keyInput.files?.[0]).toBe(keyFile)
})
const form = dialog.querySelector('form') as HTMLFormElement
fireEvent.submit(form)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
})
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
@@ -157,16 +157,13 @@ describe('CrowdSecConfig', () => {
await user.click(screen.getByTestId('ban-ip-trigger'))
// Modal opens
await waitFor(() => screen.getByText('Ban IP Address'))
const dialog = await screen.findByRole('dialog', { name: 'Ban IP Address' })
// Fill form
await user.type(screen.getByLabelText(/IP Address/i), '5.6.7.8')
await user.type(screen.getByPlaceholderText(/Reason for ban/i), 'manual ban')
await user.type(within(dialog).getByLabelText(/IP Address/i), '5.6.7.8')
await user.type(within(dialog).getByPlaceholderText(/Reason for ban/i), 'manual ban')
// Submit - Target the last button with name "Ban IP" (modal button)
const buttons = screen.getAllByRole('button', { name: 'Ban IP' })
const submitBtn = buttons[buttons.length - 1]
await user.click(submitBtn)
await user.click(within(dialog).getByRole('button', { name: 'Ban IP' }))
await waitFor(() => {
expect(crowdsecApi.banIP).toHaveBeenCalledWith('5.6.7.8', '24h', 'manual ban')
@@ -6,6 +6,17 @@ import * as smtpApi from '../../api/smtp'
import { toast } from '../../utils/toast'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
const translations: Record<string, string> = {
'smtp.configured': 'SMTP Configured',
'smtp.notConfigured': 'SMTP Not Configured',
'smtp.saveSettings': 'Save Settings',
'smtp.testConnection': 'Test Connection',
'smtp.sendTestEmail': 'Send Test Email',
'smtp.sendTest': 'Send Test',
}
const t = (key: string) => translations[key] ?? key
// Mock API
vi.mock('../../api/smtp', () => ({
getSMTPConfig: vi.fn(),
@@ -62,7 +73,7 @@ describe('SMTPSettings', () => {
const portInput = screen.getByPlaceholderText('587') as HTMLInputElement
expect(portInput.value).toBe('587')
expect(screen.getByText('SMTP Configured')).toBeTruthy()
expect(screen.getByText(t('smtp.configured'))).toBeTruthy()
})
it('shows not configured state when SMTP is not set up', async () => {
@@ -79,7 +90,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('SMTP Not Configured')).toBeTruthy()
expect(screen.getByText(t('smtp.notConfigured'))).toBeTruthy()
})
})
@@ -110,7 +121,7 @@ describe('SMTPSettings', () => {
'test@example.com'
)
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await user.click(screen.getByRole('button', { name: t('smtp.saveSettings') }))
await waitFor(() => {
expect(smtpApi.updateSMTPConfig).toHaveBeenCalled()
@@ -134,12 +145,11 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Test Connection')).toBeTruthy()
})
const testButton = await screen.findByRole('button', { name: t('smtp.testConnection') })
await waitFor(() => expect(testButton).toBeEnabled())
const user = userEvent.setup()
await user.click(screen.getByText('Test Connection'))
await user.click(testButton)
await waitFor(() => {
expect(smtpApi.testSMTPConnection).toHaveBeenCalled()
@@ -160,7 +170,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
})
expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy()
@@ -184,7 +194,7 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
await waitFor(() => {
expect(screen.getByText('Send Test Email')).toBeTruthy()
expect(screen.getByText(t('smtp.sendTestEmail'))).toBeTruthy()
})
const user = userEvent.setup()
@@ -192,7 +202,7 @@ describe('SMTPSettings', () => {
screen.getByPlaceholderText('recipient@example.com'),
'test@test.com'
)
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await user.click(screen.getByRole('button', { name: t('smtp.sendTest') }))
await waitFor(() => {
expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' })
@@ -218,7 +228,7 @@ describe('SMTPSettings', () => {
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'bad.host')
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'ops@example.com')
await user.click(screen.getByRole('button', { name: 'Save Settings' }))
await user.click(screen.getByRole('button', { name: t('smtp.saveSettings') }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('invalid host')
@@ -240,15 +250,17 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Test Connection')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(t('smtp.testConnection'))).toBeInTheDocument())
// Button should start disabled until host and from address are provided
expect(screen.getByRole('button', { name: 'Test Connection' })).toBeDisabled()
const testButton = screen.getByRole('button', { name: t('smtp.testConnection') })
expect(testButton).toBeDisabled()
await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.acme.local')
await user.type(screen.getByPlaceholderText('Charon <no-reply@example.com>'), 'from@acme.local')
await user.click(screen.getByRole('button', { name: 'Test Connection' }))
await waitFor(() => expect(testButton).toBeEnabled())
await user.click(testButton)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('cannot connect')
@@ -270,11 +282,11 @@ describe('SMTPSettings', () => {
renderWithQueryClient(<SMTPSettings />)
const user = userEvent.setup()
await waitFor(() => expect(screen.getByText('Send Test Email')).toBeInTheDocument())
await waitFor(() => expect(screen.getByText(t('smtp.sendTestEmail'))).toBeInTheDocument())
const input = screen.getByPlaceholderText('recipient@example.com') as HTMLInputElement
await user.type(input, 'keepme@example.com')
await user.click(screen.getByRole('button', { name: /Send Test/i }))
await user.click(screen.getByRole('button', { name: t('smtp.sendTest') }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('smtp unreachable')
@@ -26,6 +26,33 @@ vi.mock('../../api/logs', () => ({
connectLiveLogs: vi.fn(() => vi.fn()),
connectSecurityLogs: vi.fn(() => vi.fn()),
}))
vi.mock('../../components/LiveLogViewer', () => ({
LiveLogViewer: () => <div data-testid="live-log-viewer" />,
}))
vi.mock('../../components/SecurityNotificationSettingsModal', () => ({
SecurityNotificationSettingsModal: () => <div data-testid="security-notification-modal" />,
}))
vi.mock('../../components/CrowdSecKeyWarning', () => ({
CrowdSecKeyWarning: () => null,
}))
vi.mock('../../hooks/useNotifications', () => ({
useSecurityNotificationSettings: () => ({
data: {
enabled: false,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: true,
webhook_url: '',
email_recipients: '',
},
isLoading: false,
}),
useUpdateSecurityNotificationSettings: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
const defaultFeatureFlags = {
'feature.cerberus.enabled': true,
@@ -84,6 +111,7 @@ describe('Security page', () => {
// Mock WebSocket connections for LiveLogViewer
vi.mocked(logsApi.connectLiveLogs).mockReturnValue(vi.fn())
vi.mocked(logsApi.connectSecurityLogs).mockReturnValue(vi.fn())
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
vi.mocked(crowdsecApi.getCrowdsecKeyStatus).mockResolvedValue({
env_key_rejected: false,
key_source: 'auto-generated',
@@ -4,7 +4,11 @@ import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import SecurityHeaders from '../../pages/SecurityHeaders';
import { securityHeadersApi, SecurityHeaderProfile } from '../../api/securityHeaders';
import {
securityHeadersApi,
SecurityHeaderProfile,
type ScoreBreakdown,
} from '../../api/securityHeaders';
import { createBackup } from '../../api/backups';
vi.mock('../../api/securityHeaders');
@@ -26,6 +30,48 @@ const createWrapper = () => {
);
};
const createProfile = (
overrides: Partial<SecurityHeaderProfile> = {}
): SecurityHeaderProfile => ({
id: 1,
uuid: 'profile-uuid-1',
name: 'Profile',
hsts_enabled: false,
hsts_max_age: 0,
hsts_include_subdomains: false,
hsts_preload: false,
csp_enabled: false,
csp_directives: '',
csp_report_only: false,
csp_report_uri: '',
x_frame_options: '',
x_content_type_options: false,
referrer_policy: '',
permissions_policy: '',
cross_origin_opener_policy: '',
cross_origin_resource_policy: '',
cross_origin_embedder_policy: '',
xss_protection: false,
cache_control_no_store: false,
security_score: 0,
is_preset: false,
preset_type: '',
description: '',
created_at: '2025-12-18T00:00:00Z',
updated_at: '2025-12-18T00:00:00Z',
...overrides,
});
const createScoreBreakdown = (
overrides: Partial<ScoreBreakdown> = {}
): ScoreBreakdown => ({
score: 50,
max_score: 100,
breakdown: {},
suggestions: [],
...overrides,
});
describe('SecurityHeaders', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -678,7 +724,15 @@ describe('SecurityHeaders', () => {
it('should close create dialog on success', async () => {
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue([]);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue({ id: 1, name: 'New Profile', security_score: 50, created_at: '', updated_at: '' } as any);
vi.mocked(securityHeadersApi.createProfile).mockResolvedValue(
createProfile({
id: 1,
name: 'New Profile',
security_score: 50,
created_at: '',
updated_at: '',
})
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -704,11 +758,19 @@ describe('SecurityHeaders', () => {
});
it('should close edit dialog on success', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Edit Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50, max_score: 100, breakdown: {}, suggestions: [] } as any);
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0] as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(createScoreBreakdown());
vi.mocked(securityHeadersApi.updateProfile).mockResolvedValue(mockProfiles[0]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -726,8 +788,16 @@ describe('SecurityHeaders', () => {
});
it('should handle delete failure', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockResolvedValue({ filename: 'test-backup.tar.gz' });
vi.mocked(securityHeadersApi.deleteProfile).mockRejectedValue(new Error('Delete failed'));
@@ -750,8 +820,16 @@ describe('SecurityHeaders', () => {
});
it('should handle backup failure during delete', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(createBackup).mockRejectedValue(new Error('Backup failed'));
@@ -773,8 +851,17 @@ describe('SecurityHeaders', () => {
});
it('should handle unknown preset types', async () => {
const mockProfiles = [{ id: 1, name: 'Weird Preset', is_preset: true, preset_type: 'unknown_type', security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Weird Preset',
is_preset: true,
preset_type: 'unknown_type',
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -784,10 +871,20 @@ describe('SecurityHeaders', () => {
});
it('should handle cancel in edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Edit Me', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Edit Me',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(
createScoreBreakdown({ score: 50, max_score: 50 })
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -805,10 +902,20 @@ describe('SecurityHeaders', () => {
});
it('should handle delete from edit dialog', async () => {
const mockProfiles = [{ id: 1, name: 'Delete Me from Edit', is_preset: false, security_score: 50, updated_at: '2023-01-01' }];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
const mockProfiles = [
createProfile({
id: 1,
name: 'Delete Me from Edit',
is_preset: false,
security_score: 50,
updated_at: '2023-01-01',
}),
];
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles);
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({ score: 50 } as any);
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue(
createScoreBreakdown({ score: 50, max_score: 50 })
);
render(<SecurityHeaders />, { wrapper: createWrapper() });
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import '@testing-library/jest-dom/vitest'
import Settings from '../Settings'
const translations: Record<string, string> = {
'settings.title': 'Settings',
'settings.description': 'Configure your Charon instance',
'settings.system': 'System',
'navigation.notifications': 'Notifications',
'settings.smtp': 'Email (SMTP)',
'settings.account': 'Account',
}
const t = (key: string) => translations[key] ?? key
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t }),
}))
const renderWithRoute = (route: string) =>
render(
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/settings" element={<Settings />}>
<Route path="system" element={<div>System Page</div>} />
<Route path="notifications" element={<div>Notifications Page</div>} />
<Route path="smtp" element={<div>SMTP Page</div>} />
<Route path="account" element={<div>Account Page</div>} />
</Route>
</Routes>
</MemoryRouter>
)
describe('Settings page', () => {
it('highlights the active nav item for the current route', () => {
renderWithRoute('/settings/system')
const activeLink = screen.getByRole('link', { name: 'System' })
const inactiveLink = screen.getByRole('link', { name: 'Notifications' })
expect(activeLink).toHaveClass('bg-surface-elevated')
expect(activeLink).toHaveClass('text-content-primary')
expect(inactiveLink).toHaveClass('text-content-secondary')
expect(screen.getByText('System Page')).toBeInTheDocument()
})
it('keeps navigation order consistent', () => {
renderWithRoute('/settings/notifications')
const links = screen.getAllByRole('link')
const labels = links.map(link => link.textContent)
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Account'])
})
})
+21
View File
@@ -96,6 +96,27 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = function() {}
}
// Prevent jsdom navigation errors for anchor clicks during tests
const anchorPrototype = HTMLAnchorElement.prototype as unknown as {
__testNoNavClick?: boolean
__originalClick?: typeof HTMLAnchorElement.prototype.click
}
if (!anchorPrototype.__testNoNavClick) {
const originalClick = HTMLAnchorElement.prototype.click
Object.defineProperty(HTMLAnchorElement.prototype, '__testNoNavClick', {
value: true,
configurable: false,
writable: false,
})
HTMLAnchorElement.prototype.click = function() {
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
this.dispatchEvent(event)
return undefined
}
anchorPrototype.__originalClick = originalClick
}
// Filter noisy React act environment warnings that can appear in some environments
const _origConsoleError = console.error
console.error = (...args: unknown[]) => {