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:
@@ -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 })
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user