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

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

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">

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>

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

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

View File

@@ -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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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 () => {

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

View File

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