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:
GitHub Actions
2025-12-04 15:10:02 +00:00
parent 33c31a32c6
commit 3e4323155f
29 changed files with 5575 additions and 1344 deletions

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

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