feat: add loading overlays and animations across various pages
- Implemented new CSS animations for UI elements including bobbing, pulsing, rotating, and spinning effects. - Integrated loading overlays in CrowdSecConfig, Login, ProxyHosts, Security, and WafConfig pages to enhance user experience during asynchronous operations. - Added contextual messages for loading states to inform users about ongoing processes. - Created tests for Login and Security pages to ensure overlays function correctly during login attempts and security operations.
This commit is contained in:
@@ -7,6 +7,7 @@ import { createBackup } from '../api/backups'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
@@ -82,10 +83,41 @@ export default function CrowdSecConfig() {
|
||||
toast.success('CrowdSec mode saved (restart may be required)')
|
||||
}
|
||||
|
||||
// Determine if any operation is in progress
|
||||
const isApplyingConfig =
|
||||
importMutation.isPending ||
|
||||
writeMutation.isPending ||
|
||||
updateModeMutation.isPending ||
|
||||
backupMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (importMutation.isPending) {
|
||||
return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' }
|
||||
}
|
||||
if (writeMutation.isPending) {
|
||||
return { message: 'Guardian inscribes...', submessage: 'Saving configuration file' }
|
||||
}
|
||||
if (updateModeMutation.isPending) {
|
||||
return { message: 'Three heads turn...', submessage: 'CrowdSec mode updating' }
|
||||
}
|
||||
return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
if (!status) return <div className="p-8 text-center">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">CrowdSec Configuration</h1>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -141,6 +173,7 @@ export default function CrowdSecConfig() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { getSetupStatus } from '../api/setup'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
@@ -57,59 +58,71 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
<>
|
||||
{loading && (
|
||||
<ConfigReloadOverlay
|
||||
message="Paying the ferryman..."
|
||||
submessage="Your obol grants passage"
|
||||
type="coin"
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
|
||||
|
||||
|
||||
</div>
|
||||
<Card className="w-full" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetInfo(!showResetInfo)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResetInfo && (
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
|
||||
<p className="mb-2 font-medium">To reset your password:</p>
|
||||
<p className="mb-2">Run this command on your server:</p>
|
||||
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
|
||||
docker exec -it caddy-proxy-manager /app/backend reset-password <email> <new-password>
|
||||
</code>
|
||||
<Card className="w-full" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetInfo(!showResetInfo)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
disabled={loading}
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
{showResetInfo && (
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
|
||||
<p className="mb-2 font-medium">To reset your password:</p>
|
||||
<p className="mb-2">Run this command on your server:</p>
|
||||
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
|
||||
docker exec -it caddy-proxy-manager /app/backend reset-password <email> <new-password>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import ProxyHostForm from '../components/ProxyHostForm'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
// Helper functions extracted for unit testing and reuse
|
||||
// Helpers moved to ../utils/proxyHostsHelpers to keep component files component-only for fast refresh
|
||||
@@ -22,7 +23,7 @@ type SortColumn = 'name' | 'domain' | 'forward'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export default function ProxyHosts() {
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, isBulkUpdating } = useProxyHosts()
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, isBulkUpdating, isCreating, isUpdating, isDeleting } = useProxyHosts()
|
||||
const { certificates } = useCertificates()
|
||||
const { data: accessLists } = useAccessLists()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -53,6 +54,20 @@ export default function ProxyHosts() {
|
||||
|
||||
const linkBehavior = settings?.['ui.domain_link_behavior'] || 'new_tab'
|
||||
|
||||
// Determine if any mutation is in progress
|
||||
const isApplyingConfig = isCreating || isUpdating || isDeleting || isBulkUpdating
|
||||
|
||||
// Determine contextual message based on operation
|
||||
const getMessage = () => {
|
||||
if (isCreating) return { message: 'Ferrying new host...', submessage: 'Charon is crossing the Styx' }
|
||||
if (isUpdating) return { message: 'Guiding changes across...', submessage: 'Configuration in transit' }
|
||||
if (isDeleting) return { message: 'Returning to shore...', submessage: 'Host departure in progress' }
|
||||
if (isBulkUpdating) return { message: `Ferrying ${selectedHosts.size} souls...`, submessage: 'Bulk operation crossing the river' }
|
||||
return { message: 'Ferrying configuration...', submessage: 'Charon is crossing the Styx' }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
// Create a map of domain -> certificate status for quick lookup
|
||||
// Handles both single domains and comma-separated multi-domain certs
|
||||
const certStatusByDomain = useMemo(() => {
|
||||
@@ -227,8 +242,16 @@ export default function ProxyHosts() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
@@ -885,6 +908,7 @@ export default function ProxyHosts() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Switch } from '../components/ui/Switch'
|
||||
import { toast } from '../utils/toast'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
export default function Security() {
|
||||
const navigate = useNavigate()
|
||||
@@ -103,6 +104,34 @@ export default function Security() {
|
||||
const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
|
||||
// Determine if any security operation is in progress
|
||||
const isApplyingConfig =
|
||||
toggleCerberusMutation.isPending ||
|
||||
toggleServiceMutation.isPending ||
|
||||
updateSecurityConfigMutation.isPending ||
|
||||
generateBreakGlassMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (toggleCerberusMutation.isPending) {
|
||||
return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
|
||||
}
|
||||
if (toggleServiceMutation.isPending) {
|
||||
return { message: 'Three heads turn...', submessage: 'Security configuration updating' }
|
||||
}
|
||||
if (startMutation.isPending) {
|
||||
return { message: 'Summoning the guardian...', submessage: 'Intrusion prevention rising' }
|
||||
}
|
||||
if (stopMutation.isPending) {
|
||||
return { message: 'Guardian rests...', submessage: 'Intrusion prevention pausing' }
|
||||
}
|
||||
return { message: 'Strengthening the guard...', submessage: 'Protective wards activating' }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading security status...</div>
|
||||
}
|
||||
@@ -138,9 +167,17 @@ export default function Security() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{headerBanner}
|
||||
<div className="flex items-center justify-between">
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{headerBanner}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-green-500" />
|
||||
Security Dashboard
|
||||
@@ -422,6 +459,7 @@ export default function Security() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { useRuleSets, useUpsertRuleSet, useDeleteRuleSet } from '../hooks/useSecurity'
|
||||
import type { SecurityRuleSet, UpsertRuleSetPayload } from '../api/security'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
/**
|
||||
* Confirmation dialog for destructive actions
|
||||
@@ -187,6 +188,24 @@ export default function WafConfig() {
|
||||
const [editingRuleSet, setEditingRuleSet] = useState<SecurityRuleSet | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<SecurityRuleSet | null>(null)
|
||||
|
||||
// Determine if any security operation is in progress
|
||||
const isApplyingConfig = upsertMutation.isPending || deleteMutation.isPending
|
||||
|
||||
// Determine contextual message based on operation
|
||||
const getMessage = () => {
|
||||
if (upsertMutation.isPending) {
|
||||
return editingRuleSet
|
||||
? { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
|
||||
: { message: 'Forging new defenses...', submessage: 'Security rules inscribing' }
|
||||
}
|
||||
if (deleteMutation.isPending) {
|
||||
return { message: 'Lowering a barrier...', submessage: 'Defense layer removed' }
|
||||
}
|
||||
return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
|
||||
}
|
||||
|
||||
const { message, submessage } = getMessage()
|
||||
|
||||
const handleCreate = (data: UpsertRuleSetPayload) => {
|
||||
upsertMutation.mutate(data, {
|
||||
onSuccess: () => setShowCreateForm(false),
|
||||
@@ -228,7 +247,15 @@ export default function WafConfig() {
|
||||
const ruleSetList = ruleSets?.rulesets || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
{isApplyingConfig && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="cerberus"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -430,6 +457,7 @@ export default function WafConfig() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
225
frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
Normal file
225
frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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 { MemoryRouter } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import client from '../../api/client'
|
||||
|
||||
// Mock modules
|
||||
vi.mock('../../api/client')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(() => Promise.resolve({ setupRequired: false })),
|
||||
}))
|
||||
|
||||
const mockLogin = vi.fn()
|
||||
vi.mocked(authHook.useAuth).mockReturnValue({
|
||||
user: null,
|
||||
login: mockLogin,
|
||||
logout: vi.fn(),
|
||||
loading: false,
|
||||
} as unknown as ReturnType<typeof authHook.useAuth>)
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Login - Coin Overlay Security Audit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows coin-themed overlay during login', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Coin-themed overlay should appear
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
|
||||
// Verify coin theme (gold/amber)
|
||||
const overlay = screen.getByText('Paying the ferryman...').closest('div')
|
||||
expect(overlay).toHaveClass('bg-amber-950/90')
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('ATTACK: rapid fire login attempts are blocked by overlay', async () => {
|
||||
let resolveCount = 0
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolveCount++
|
||||
resolve({ data: {} })
|
||||
}, 200)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
|
||||
// Click multiple times rapidly
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should block subsequent clicks (form is disabled)
|
||||
expect(emailInput).toBeDisabled()
|
||||
expect(passwordInput).toBeDisabled()
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Should only execute once
|
||||
expect(resolveCount).toBe(1)
|
||||
})
|
||||
|
||||
it('clears overlay on login error', async () => {
|
||||
vi.mocked(client.post).mockRejectedValue({
|
||||
response: { data: { error: 'Invalid credentials' } }
|
||||
})
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'wrong@example.com')
|
||||
await userEvent.type(passwordInput, 'wrong')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay appears
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay clears after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
|
||||
// Form should be re-enabled
|
||||
expect(emailInput).not.toBeDisabled()
|
||||
expect(passwordInput).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('ATTACK: XSS in login credentials does not break overlay', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} })
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, '<script>alert(1)</script>@example.com')
|
||||
await userEvent.type(passwordInput, '<img src=x onerror=alert(1)>')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should still work
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('ATTACK: network timeout does not leave overlay stuck', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Network timeout')), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
|
||||
// Overlay should clear after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('overlay has correct z-index hierarchy', () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
userEvent.type(emailInput, 'admin@example.com')
|
||||
userEvent.type(passwordInput, 'password123')
|
||||
userEvent.click(submitButton)
|
||||
|
||||
// Overlay should be z-50
|
||||
const overlay = document.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('overlay renders CharonCoinLoader component', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// CharonCoinLoader has aria-label="Authenticating"
|
||||
expect(screen.getByLabelText('Authenticating')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
352
frontend/src/pages/__tests__/Security.test.tsx
Normal file
352
frontend/src/pages/__tests__/Security.test.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
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 { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../hooks/useSecurity', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../hooks/useSecurity')>()
|
||||
return {
|
||||
...actual,
|
||||
useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '10.0.0.0/8' } } })),
|
||||
useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useRuleSets: vi.fn(() => ({
|
||||
data: {
|
||||
rulesets: [
|
||||
{ id: 1, uuid: 'abc', name: 'OWASP CRS', source_url: 'https://example.com', mode: 'blocking', last_updated: '2025-12-04', content: 'rules' }
|
||||
]
|
||||
}
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Security', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true }
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading state initially', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
render(<Security />, { wrapper })
|
||||
expect(screen.getByText(/Loading security status/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error if security status fails to load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
|
||||
render(<Security />, { wrapper })
|
||||
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should render Security Dashboard when status loads', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show banner when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
render(<Security />, { wrapper })
|
||||
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cerberus Toggle', () => {
|
||||
it('should toggle Cerberus on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle Cerberus off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'false', 'security', 'bool'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Service Toggles', () => {
|
||||
it('should toggle CrowdSec on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle WAF on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle ACL on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
const toggle = screen.getByTestId('toggle-acl')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle Rate Limiting on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
const toggle = screen.getByTestId('toggle-rate-limit')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.rate_limit.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Whitelist', () => {
|
||||
it('should load admin whitelist from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update admin whitelist on save', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockMutate = vi.fn()
|
||||
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as any)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /Save/i })
|
||||
await user.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledWith({ name: 'default', admin_whitelist: '10.0.0.0/8' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CrowdSec Controls', () => {
|
||||
it('should start CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await user.click(startButton)
|
||||
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('should stop CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-stop'))
|
||||
const stopButton = screen.getByTestId('crowdsec-stop')
|
||||
await user.click(stopButton)
|
||||
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('should export CrowdSec config', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue('config data' as any)
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
await user.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WAF Controls', () => {
|
||||
it('should change WAF mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as any)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('waf-mode-select'))
|
||||
const select = screen.getByTestId('waf-mode-select')
|
||||
await user.selectOptions(select, 'monitor')
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({ name: 'default', waf_mode: 'monitor' }))
|
||||
})
|
||||
|
||||
it('should change WAF ruleset', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { useUpdateSecurityConfig } = await import('../../hooks/useSecurity')
|
||||
const mockMutate = vi.fn()
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as any)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('waf-ruleset-select'))
|
||||
const select = screen.getByTestId('waf-ruleset-select')
|
||||
await user.selectOptions(select, 'OWASP CRS')
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({ name: 'default', waf_rules_source: 'OWASP CRS' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading Overlay', () => {
|
||||
it('should show Cerberus overlay when Cerberus is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when service is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await user.click(startButton)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when stopping CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-stop'))
|
||||
const stopButton = screen.getByTestId('crowdsec-stop')
|
||||
await user.click(stopButton)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user