@@ -442,7 +445,7 @@ export default function Security() {
{status.rate_limit.enabled ? 'Active' : 'Disabled'}
- DDoS Protection
+ Protects against: DDoS attacks, credential stuffing, API abuse
diff --git a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
index 3684cf18..d78e116b 100644
--- a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
+++ b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
@@ -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(
)
- 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(
)
- 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(
)
- 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(
)
- 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, '@example.com')
+ // Use valid email format with XSS-like characters in password
+ await userEvent.type(emailInput, 'test@example.com')
await userEvent.type(passwordInput, '

')
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(
)
- 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(
)
- 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(
)
- 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')
diff --git a/frontend/src/pages/__tests__/Security.spec.tsx b/frontend/src/pages/__tests__/Security.spec.tsx
index c13877ce..48dd3c1c 100644
--- a/frontend/src/pages/__tests__/Security.spec.tsx
+++ b/frontend/src/pages/__tests__/Security.spec.tsx
@@ -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(
)
- 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 () => {
diff --git a/frontend/src/pages/__tests__/Security.test.tsx b/frontend/src/pages/__tests__/Security.test.tsx
index ea380640..2aaf391d 100644
--- a/frontend/src/pages/__tests__/Security.test.tsx
+++ b/frontend/src/pages/__tests__/Security.test.tsx
@@ -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(
, { 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(
, { 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(
, { 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()