feat: enhance security dashboard with layered protection summaries and order validation in tests
This commit is contained in:
@@ -241,7 +241,9 @@ describe('LoadingStates - Security Audit', () => {
|
||||
it('handles null message', () => {
|
||||
// @ts-expect-error - Testing null
|
||||
render(<ConfigReloadOverlay message={null} />)
|
||||
expect(screen.getByText('null')).toBeInTheDocument()
|
||||
// Null message renders as empty paragraph - component gracefully handles null
|
||||
const textContainer = screen.getByText(/Charon is crossing the Styx/i).closest('div')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty string message', () => {
|
||||
|
||||
@@ -212,8 +212,9 @@ export default function Security() {
|
||||
|
||||
<Outlet />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* CrowdSec */}
|
||||
{/* CrowdSec - Layer 1: IP Reputation (first line of defense) */}
|
||||
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="text-xs text-gray-400 mb-2">🛡️ Layer 1: IP Reputation</div>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -221,7 +222,6 @@ export default function Security() {
|
||||
checked={status.crowdsec.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => {
|
||||
console.log('crowdsec onChange', e.target.checked)
|
||||
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
|
||||
}}
|
||||
data-testid="toggle-crowdsec"
|
||||
@@ -235,7 +235,7 @@ export default function Security() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{status.crowdsec.enabled
|
||||
? `Mode: ${status.crowdsec.mode}`
|
||||
? `Protects against: Known attackers, botnets, brute-force`
|
||||
: 'Intrusion Prevention System'}
|
||||
</p>
|
||||
{crowdsecStatus && (
|
||||
@@ -309,8 +309,51 @@ export default function Security() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* WAF */}
|
||||
{/* ACL - Layer 2: Access Control (IP/Geo filtering) */}
|
||||
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="text-xs text-gray-400 mb-2">🔒 Layer 2: Access Control</div>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Access Control</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.acl.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-acl"
|
||||
/>
|
||||
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
{status.acl.enabled ? 'Active' : 'Disabled'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Protects against: Unauthorized IPs, geo-based attacks, insider threats
|
||||
</p>
|
||||
{status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/security/access-lists')}
|
||||
>
|
||||
Manage Lists
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/security/access-lists')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* WAF - Layer 3: Request Inspection */}
|
||||
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="text-xs text-gray-400 mb-2">🛡️ Layer 3: Request Inspection</div>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -329,7 +372,7 @@ export default function Security() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{status.waf.enabled
|
||||
? `Mode: ${securityConfig?.config?.waf_mode === 'monitor' ? 'Monitor (log only)' : 'Block'}`
|
||||
? `Protects against: SQL injection, XSS, RCE, zero-day exploits*`
|
||||
: 'Web Application Firewall'}
|
||||
</p>
|
||||
{status.waf.enabled && (
|
||||
@@ -382,49 +425,9 @@ export default function Security() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ACL */}
|
||||
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Access Control</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.acl.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-acl"
|
||||
/>
|
||||
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
{status.acl.enabled ? 'Active' : 'Disabled'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
IP-based Allow/Deny Lists
|
||||
</p>
|
||||
{status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/security/access-lists')}
|
||||
>
|
||||
Manage Lists
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/security/access-lists')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Rate Limiting */}
|
||||
{/* Rate Limiting - Layer 4: Volume Control */}
|
||||
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="text-xs text-gray-400 mb-2">⚡ Layer 4: Volume Control</div>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -442,7 +445,7 @@ export default function Security() {
|
||||
{status.rate_limit.enabled ? 'Active' : 'Disabled'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
DDoS Protection
|
||||
Protects against: DDoS attacks, credential stuffing, API abuse
|
||||
</p>
|
||||
{status.rate_limit.enabled && (
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -6,13 +6,12 @@ import { MemoryRouter } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import client from '../../api/client'
|
||||
import * as setupApi from '../../api/setup'
|
||||
|
||||
// Mock modules
|
||||
vi.mock('../../api/client')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(() => Promise.resolve({ setupRequired: false })),
|
||||
}))
|
||||
vi.mock('../../api/setup')
|
||||
|
||||
const mockLogin = vi.fn()
|
||||
vi.mocked(authHook.useAuth).mockReturnValue({
|
||||
@@ -41,6 +40,8 @@ const renderWithProviders = (ui: React.ReactElement) => {
|
||||
describe('Login - Coin Overlay Security Audit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock setup status to resolve immediately with no setup required
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false })
|
||||
})
|
||||
|
||||
it('shows coin-themed overlay during login', async () => {
|
||||
@@ -50,8 +51,9 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
@@ -62,9 +64,9 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
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')
|
||||
// Verify coin theme (gold/amber) - use querySelector to find actual overlay container
|
||||
const overlay = document.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
@@ -85,8 +87,9 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
@@ -111,14 +114,18 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
})
|
||||
|
||||
it('clears overlay on login error', async () => {
|
||||
vi.mocked(client.post).mockRejectedValue({
|
||||
response: { data: { error: 'Invalid credentials' } }
|
||||
})
|
||||
// Use delayed rejection so overlay has time to appear
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise((_, reject) => {
|
||||
setTimeout(() => reject({ response: { data: { error: 'Invalid credentials' } } }), 100)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'wrong@example.com')
|
||||
@@ -131,7 +138,7 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
// Overlay clears after error
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
}, { timeout: 300 })
|
||||
|
||||
// Form should be re-enabled
|
||||
expect(emailInput).not.toBeDisabled()
|
||||
@@ -139,15 +146,20 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
})
|
||||
|
||||
it('ATTACK: XSS in login credentials does not break overlay', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} })
|
||||
// Use delayed promise so we can catch the overlay
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, '<script>alert(1)</script>@example.com')
|
||||
// Use valid email format with XSS-like characters in password
|
||||
await userEvent.type(emailInput, 'test@example.com')
|
||||
await userEvent.type(passwordInput, '<img src=x onerror=alert(1)>')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
@@ -156,7 +168,7 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
|
||||
}, { timeout: 200 })
|
||||
}, { timeout: 300 })
|
||||
})
|
||||
|
||||
it('ATTACK: network timeout does not leave overlay stuck', async () => {
|
||||
@@ -168,8 +180,9 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
@@ -184,20 +197,21 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
}, { timeout: 200 })
|
||||
})
|
||||
|
||||
it('overlay has correct z-index hierarchy', () => {
|
||||
it('overlay has correct z-index hierarchy', async () => {
|
||||
vi.mocked(client.post).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
)
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
userEvent.type(emailInput, 'admin@example.com')
|
||||
userEvent.type(passwordInput, 'password123')
|
||||
userEvent.click(submitButton)
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
await userEvent.type(passwordInput, 'password123')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
// Overlay should be z-50
|
||||
const overlay = document.querySelector('.z-50')
|
||||
@@ -211,8 +225,9 @@ describe('Login - Coin Overlay Security Audit', () => {
|
||||
|
||||
renderWithProviders(<Login />)
|
||||
|
||||
const emailInput = screen.getByLabelText('Email')
|
||||
const passwordInput = screen.getByLabelText('Password')
|
||||
// Wait for setup check to complete and form to render
|
||||
const emailInput = await screen.findByPlaceholderText('admin@example.com')
|
||||
const passwordInput = screen.getByPlaceholderText('••••••••')
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await userEvent.type(emailInput, 'admin@example.com')
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('Security page', () => {
|
||||
expect(screen.getByText('No rule sets configured. Add one below.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays correct WAF mode in status text', async () => {
|
||||
it('displays correct WAF threat protection summary when enabled', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
@@ -308,7 +308,8 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Mode: Monitor (log only)')).toBeInTheDocument())
|
||||
// WAF now shows threat protection summary instead of mode text
|
||||
await waitFor(() => expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('does not show WAF controls when WAF is disabled', async () => {
|
||||
|
||||
@@ -290,6 +290,48 @@ describe('Security', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Card Order (Pipeline Sequence)', () => {
|
||||
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
|
||||
// Get all card headings
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
|
||||
// Verify pipeline order: CrowdSec (Layer 1) → ACL (Layer 2) → WAF (Layer 3) → Rate Limiting (Layer 4)
|
||||
expect(cardNames).toEqual(['CrowdSec', 'Access Control', 'WAF (Coraza)', 'Rate Limiting'])
|
||||
})
|
||||
|
||||
it('should display layer indicators on each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
|
||||
// Verify each layer indicator is present
|
||||
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Layer 2: Access Control/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Layer 3: Request Inspection/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Layer 4: Volume Control/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display threat protection summaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Unauthorized IPs, geo-based attacks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SQL injection, XSS, RCE/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/DDoS attacks, credential stuffing/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading Overlay', () => {
|
||||
it('should show Cerberus overlay when Cerberus is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
Reference in New Issue
Block a user