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:
130
frontend/src/api/__tests__/crowdsec.test.ts
Normal file
130
frontend/src/api/__tests__/crowdsec.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as crowdsec from '../crowdsec'
|
||||
import client from '../client'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('crowdsec API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('startCrowdsec', () => {
|
||||
it('should call POST /admin/crowdsec/start', async () => {
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.startCrowdsec()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/start')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopCrowdsec', () => {
|
||||
it('should call POST /admin/crowdsec/stop', async () => {
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.stopCrowdsec()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/stop')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusCrowdsec', () => {
|
||||
it('should call GET /admin/crowdsec/status', async () => {
|
||||
const mockData = { running: true, pid: 1234 }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.statusCrowdsec()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/status')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('importCrowdsecConfig', () => {
|
||||
it('should call POST /admin/crowdsec/import with FormData', async () => {
|
||||
const mockFile = new File(['content'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.importCrowdsecConfig(mockFile)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith(
|
||||
'/admin/crowdsec/import',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCrowdsecConfig', () => {
|
||||
it('should call GET /admin/crowdsec/export with blob responseType', async () => {
|
||||
const mockBlob = new Blob(['data'], { type: 'application/gzip' })
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockBlob })
|
||||
|
||||
const result = await crowdsec.exportCrowdsecConfig()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/export', { responseType: 'blob' })
|
||||
expect(result).toEqual(mockBlob)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listCrowdsecFiles', () => {
|
||||
it('should call GET /admin/crowdsec/files', async () => {
|
||||
const mockData = { files: ['file1.yaml', 'file2.yaml'] }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.listCrowdsecFiles()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/files')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCrowdsecFile', () => {
|
||||
it('should call GET /admin/crowdsec/file with encoded path', async () => {
|
||||
const mockData = { content: 'file content' }
|
||||
const path = '/etc/crowdsec/file.yaml'
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.readCrowdsecFile(path)
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith(
|
||||
`/admin/crowdsec/file?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeCrowdsecFile', () => {
|
||||
it('should call POST /admin/crowdsec/file with path and content', async () => {
|
||||
const mockData = { success: true }
|
||||
const path = '/etc/crowdsec/file.yaml'
|
||||
const content = 'new content'
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await crowdsec.writeCrowdsecFile(path, content)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/file', { path, content })
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default export', () => {
|
||||
it('should export all functions', () => {
|
||||
expect(crowdsec.default).toHaveProperty('startCrowdsec')
|
||||
expect(crowdsec.default).toHaveProperty('stopCrowdsec')
|
||||
expect(crowdsec.default).toHaveProperty('statusCrowdsec')
|
||||
expect(crowdsec.default).toHaveProperty('importCrowdsecConfig')
|
||||
expect(crowdsec.default).toHaveProperty('exportCrowdsecConfig')
|
||||
expect(crowdsec.default).toHaveProperty('listCrowdsecFiles')
|
||||
expect(crowdsec.default).toHaveProperty('readCrowdsecFile')
|
||||
expect(crowdsec.default).toHaveProperty('writeCrowdsecFile')
|
||||
})
|
||||
})
|
||||
})
|
||||
244
frontend/src/api/__tests__/security.test.ts
Normal file
244
frontend/src/api/__tests__/security.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as security from '../security'
|
||||
import client from '../client'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('security API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getSecurityStatus', () => {
|
||||
it('should call GET /security/status', async () => {
|
||||
const mockData: security.SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost:8080', enabled: true },
|
||||
waf: { mode: 'enabled', enabled: true },
|
||||
rate_limit: { mode: 'enabled', enabled: true },
|
||||
acl: { enabled: true }
|
||||
}
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.getSecurityStatus()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/security/status')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSecurityConfig', () => {
|
||||
it('should call GET /security/config', async () => {
|
||||
const mockData = { config: { admin_whitelist: '10.0.0.0/8' } }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.getSecurityConfig()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/security/config')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSecurityConfig', () => {
|
||||
it('should call POST /security/config with payload', async () => {
|
||||
const payload: security.SecurityConfigPayload = {
|
||||
name: 'test',
|
||||
enabled: true,
|
||||
admin_whitelist: '10.0.0.0/8'
|
||||
}
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.updateSecurityConfig(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should handle all payload fields', async () => {
|
||||
const payload: security.SecurityConfigPayload = {
|
||||
name: 'test',
|
||||
enabled: true,
|
||||
admin_whitelist: '10.0.0.0/8',
|
||||
crowdsec_mode: 'local',
|
||||
crowdsec_api_url: 'http://localhost:8080',
|
||||
waf_mode: 'enabled',
|
||||
waf_rules_source: 'coreruleset',
|
||||
waf_learning: true,
|
||||
rate_limit_enable: true,
|
||||
rate_limit_burst: 10,
|
||||
rate_limit_requests: 100,
|
||||
rate_limit_window_sec: 60
|
||||
}
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.updateSecurityConfig(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/config', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateBreakGlassToken', () => {
|
||||
it('should call POST /security/breakglass/generate', async () => {
|
||||
const mockData = { token: 'abc123' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.generateBreakGlassToken()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/breakglass/generate')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableCerberus', () => {
|
||||
it('should call POST /security/enable with payload', async () => {
|
||||
const payload = { mode: 'full' }
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.enableCerberus(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/enable', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call POST /security/enable with empty object when no payload', async () => {
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.enableCerberus()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/enable', {})
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disableCerberus', () => {
|
||||
it('should call POST /security/disable with payload', async () => {
|
||||
const payload = { reason: 'maintenance' }
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.disableCerberus(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/disable', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call POST /security/disable with empty object when no payload', async () => {
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.disableCerberus()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/disable', {})
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDecisions', () => {
|
||||
it('should call GET /security/decisions with default limit', async () => {
|
||||
const mockData = { decisions: [] }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.getDecisions()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=50')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call GET /security/decisions with custom limit', async () => {
|
||||
const mockData = { decisions: [] }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.getDecisions(100)
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/security/decisions?limit=100')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDecision', () => {
|
||||
it('should call POST /security/decisions with payload', async () => {
|
||||
const payload = { ip: '1.2.3.4', duration: '4h', type: 'ban' }
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.createDecision(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/decisions', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRuleSets', () => {
|
||||
it('should call GET /security/rulesets', async () => {
|
||||
const mockData: security.RuleSetsResponse = {
|
||||
rulesets: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'abc-123',
|
||||
name: 'OWASP CRS',
|
||||
source_url: 'https://example.com/rules',
|
||||
mode: 'blocking',
|
||||
last_updated: '2025-12-04T00:00:00Z',
|
||||
content: 'rule content'
|
||||
}
|
||||
]
|
||||
}
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.getRuleSets()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/security/rulesets')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertRuleSet', () => {
|
||||
it('should call POST /security/rulesets with create payload', async () => {
|
||||
const payload: security.UpsertRuleSetPayload = {
|
||||
name: 'Custom Rules',
|
||||
content: 'rule content',
|
||||
mode: 'blocking'
|
||||
}
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.upsertRuleSet(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call POST /security/rulesets with update payload', async () => {
|
||||
const payload: security.UpsertRuleSetPayload = {
|
||||
id: 1,
|
||||
name: 'Updated Rules',
|
||||
source_url: 'https://example.com/rules',
|
||||
mode: 'detection'
|
||||
}
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.upsertRuleSet(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/security/rulesets', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteRuleSet', () => {
|
||||
it('should call DELETE /security/rulesets/:id', async () => {
|
||||
const mockData = { success: true }
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await security.deleteRuleSet(1)
|
||||
|
||||
expect(client.delete).toHaveBeenCalledWith('/security/rulesets/1')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
})
|
||||
67
frontend/src/api/__tests__/settings.test.ts
Normal file
67
frontend/src/api/__tests__/settings.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as settings from '../settings'
|
||||
import client from '../client'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('settings API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('should call GET /settings', async () => {
|
||||
const mockData: settings.SettingsMap = {
|
||||
'ui.theme': 'dark',
|
||||
'security.cerberus.enabled': 'true'
|
||||
}
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await settings.getSettings()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/settings')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSetting', () => {
|
||||
it('should call POST /settings with key and value only', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} })
|
||||
|
||||
await settings.updateSetting('ui.theme', 'light')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/settings', {
|
||||
key: 'ui.theme',
|
||||
value: 'light',
|
||||
category: undefined,
|
||||
type: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should call POST /settings with all parameters', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} })
|
||||
|
||||
await settings.updateSetting('security.cerberus.enabled', 'true', 'security', 'bool')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/settings', {
|
||||
key: 'security.cerberus.enabled',
|
||||
value: 'true',
|
||||
category: 'security',
|
||||
type: 'bool'
|
||||
})
|
||||
})
|
||||
|
||||
it('should call POST /settings with category but no type', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} })
|
||||
|
||||
await settings.updateSetting('ui.theme', 'dark', 'ui')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/settings', {
|
||||
key: 'ui.theme',
|
||||
value: 'dark',
|
||||
category: 'ui',
|
||||
type: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
135
frontend/src/api/__tests__/uptime.test.ts
Normal file
135
frontend/src/api/__tests__/uptime.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as uptime from '../uptime'
|
||||
import client from '../client'
|
||||
import type { UptimeMonitor, UptimeHeartbeat } from '../uptime'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('uptime API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getMonitors', () => {
|
||||
it('should call GET /uptime/monitors', async () => {
|
||||
const mockData: UptimeMonitor[] = [
|
||||
{
|
||||
id: 'mon-1',
|
||||
name: 'Test Monitor',
|
||||
type: 'http',
|
||||
url: 'https://example.com',
|
||||
interval: 60,
|
||||
enabled: true,
|
||||
status: 'up',
|
||||
latency: 100,
|
||||
max_retries: 3
|
||||
}
|
||||
]
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.getMonitors()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/uptime/monitors')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMonitorHistory', () => {
|
||||
it('should call GET /uptime/monitors/:id/history with default limit', async () => {
|
||||
const mockData: UptimeHeartbeat[] = [
|
||||
{
|
||||
id: 1,
|
||||
monitor_id: 'mon-1',
|
||||
status: 'up',
|
||||
latency: 100,
|
||||
message: 'OK',
|
||||
created_at: '2025-12-04T00:00:00Z'
|
||||
}
|
||||
]
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.getMonitorHistory('mon-1')
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=50')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call GET /uptime/monitors/:id/history with custom limit', async () => {
|
||||
const mockData: UptimeHeartbeat[] = []
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.getMonitorHistory('mon-1', 100)
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/uptime/monitors/mon-1/history?limit=100')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMonitor', () => {
|
||||
it('should call PUT /uptime/monitors/:id', async () => {
|
||||
const mockMonitor: UptimeMonitor = {
|
||||
id: 'mon-1',
|
||||
name: 'Updated Monitor',
|
||||
type: 'http',
|
||||
url: 'https://example.com',
|
||||
interval: 120,
|
||||
enabled: false,
|
||||
status: 'down',
|
||||
latency: 0,
|
||||
max_retries: 5
|
||||
}
|
||||
vi.mocked(client.put).mockResolvedValue({ data: mockMonitor })
|
||||
|
||||
const result = await uptime.updateMonitor('mon-1', { enabled: false, interval: 120 })
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/uptime/monitors/mon-1', { enabled: false, interval: 120 })
|
||||
expect(result).toEqual(mockMonitor)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteMonitor', () => {
|
||||
it('should call DELETE /uptime/monitors/:id', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: undefined })
|
||||
|
||||
const result = await uptime.deleteMonitor('mon-1')
|
||||
|
||||
expect(client.delete).toHaveBeenCalledWith('/uptime/monitors/mon-1')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncMonitors', () => {
|
||||
it('should call POST /uptime/sync with empty body when no params', async () => {
|
||||
const mockData = { synced: 5 }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.syncMonitors()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/uptime/sync', {})
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should call POST /uptime/sync with provided parameters', async () => {
|
||||
const mockData = { synced: 5 }
|
||||
const body = { interval: 120, max_retries: 5 }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.syncMonitors(body)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/uptime/sync', body)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkMonitor', () => {
|
||||
it('should call POST /uptime/monitors/:id/check', async () => {
|
||||
const mockData = { message: 'Check initiated' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await uptime.checkMonitor('mon-1')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/uptime/monitors/mon-1/check')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { useCertificates } from '../hooks/useCertificates'
|
||||
import { deleteCertificate } from '../api/certificates'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { LoadingSpinner } from './LoadingStates'
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
type SortColumn = 'name' | 'expires'
|
||||
@@ -75,7 +75,15 @@ export default function CertificateList() {
|
||||
if (error) return <div className="text-red-500">Failed to load certificates</div>
|
||||
|
||||
return (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<>
|
||||
{deleteMutation.isPending && (
|
||||
<ConfigReloadOverlay
|
||||
message="Returning to shore..."
|
||||
submessage="Certificate departure in progress"
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
@@ -174,7 +182,8 @@ export default function CertificateList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,277 @@ export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CharonLoader - Boat on Waves animation (Charon ferrying across the Styx)
|
||||
* Used for general proxy/configuration operations
|
||||
*/
|
||||
export function CharonLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Loading">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Water waves */}
|
||||
<path
|
||||
d="M0,60 Q10,55 20,60 T40,60 T60,60 T80,60 T100,60"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
<path
|
||||
d="M0,65 Q10,60 20,65 T40,65 T60,65 T80,65 T100,65"
|
||||
fill="none"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
<path
|
||||
d="M0,70 Q10,65 20,70 T40,70 T60,70 T80,70 T100,70"
|
||||
fill="none"
|
||||
stroke="#93c5fd"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.6s' }}
|
||||
/>
|
||||
|
||||
{/* Boat (bobbing animation) */}
|
||||
<g className="animate-bob-boat" style={{ transformOrigin: '50% 50%' }}>
|
||||
{/* Hull */}
|
||||
<path
|
||||
d="M30,45 L30,50 Q35,55 50,55 T70,50 L70,45 Z"
|
||||
fill="#1e293b"
|
||||
stroke="#334155"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Deck */}
|
||||
<rect x="32" y="42" width="36" height="3" fill="#475569" />
|
||||
{/* Mast */}
|
||||
<line x1="50" y1="42" x2="50" y2="25" stroke="#94a3b8" strokeWidth="2" />
|
||||
{/* Sail */}
|
||||
<path
|
||||
d="M50,25 L65,30 L50,40 Z"
|
||||
fill="#e0e7ff"
|
||||
stroke="#818cf8"
|
||||
strokeWidth="1"
|
||||
className="animate-pulse-glow"
|
||||
/>
|
||||
{/* Charon silhouette */}
|
||||
<circle cx="45" cy="38" r="3" fill="#334155" />
|
||||
<rect x="44" y="41" width="2" height="4" fill="#334155" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CharonCoinLoader - Spinning Obol Coin animation (Payment to the Ferryman)
|
||||
* Used for authentication/login operations
|
||||
*/
|
||||
export function CharonCoinLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Authenticating">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Outer glow */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1"
|
||||
opacity="0.4"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
|
||||
{/* Spinning coin */}
|
||||
<g className="animate-spin-y" style={{ transformOrigin: '50% 50%' }}>
|
||||
{/* Coin face */}
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="50"
|
||||
rx="30"
|
||||
ry="30"
|
||||
fill="url(#goldGradient)"
|
||||
stroke="#d97706"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Inner circle */}
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="50"
|
||||
rx="24"
|
||||
ry="24"
|
||||
fill="none"
|
||||
stroke="#92400e"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Charon's boat symbol (simplified) */}
|
||||
<path
|
||||
d="M35,50 L40,45 L60,45 L65,50 L60,52 L40,52 Z"
|
||||
fill="#78350f"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<line x1="50" y1="45" x2="50" y2="38" stroke="#78350f" strokeWidth="2" />
|
||||
<path d="M50,38 L58,42 L50,46 Z" fill="#78350f" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
{/* Gradient definition */}
|
||||
<defs>
|
||||
<radialGradient id="goldGradient">
|
||||
<stop offset="0%" stopColor="#fcd34d" />
|
||||
<stop offset="50%" stopColor="#f59e0b" />
|
||||
<stop offset="100%" stopColor="#d97706" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CerberusLoader - Three-Headed Guardian animation
|
||||
* Used for security operations (WAF, CrowdSec, ACL, Rate Limiting)
|
||||
*/
|
||||
export function CerberusLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Security Loading">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Shield background */}
|
||||
<path
|
||||
d="M50,10 L80,25 L80,50 Q80,75 50,90 Q20,75 20,50 L20,25 Z"
|
||||
fill="#7f1d1d"
|
||||
stroke="#991b1b"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
|
||||
{/* Inner shield detail */}
|
||||
<path
|
||||
d="M50,15 L75,27 L75,50 Q75,72 50,85 Q25,72 25,50 L25,27 Z"
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
{/* Three heads (simplified circles with animation) */}
|
||||
{/* Left head */}
|
||||
<g className="animate-rotate-head" style={{ transformOrigin: '35% 45%' }}>
|
||||
<circle cx="35" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="33" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="37" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<path d="M32,48 Q35,50 38,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Center head (larger) */}
|
||||
<g className="animate-pulse-glow">
|
||||
<circle cx="50" cy="42" r="10" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="47" cy="40" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="53" cy="40" r="1.5" fill="#fca5a5" />
|
||||
<path d="M46,47 Q50,50 54,47" stroke="#b91c1c" strokeWidth="1.5" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Right head */}
|
||||
<g className="animate-rotate-head" style={{ transformOrigin: '65% 45%', animationDelay: '0.5s' }}>
|
||||
<circle cx="65" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="63" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="67" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<path d="M62,48 Q65,50 68,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Body */}
|
||||
<ellipse cx="50" cy="65" rx="18" ry="12" fill="#7f1d1d" stroke="#991b1b" strokeWidth="1.5" />
|
||||
|
||||
{/* Paws */}
|
||||
<circle cx="40" cy="72" r="4" fill="#991b1b" />
|
||||
<circle cx="50" cy="72" r="4" fill="#991b1b" />
|
||||
<circle cx="60" cy="72" r="4" fill="#991b1b" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigReloadOverlay - Full-screen blocking overlay for Caddy configuration reloads
|
||||
*
|
||||
* Displays thematic loading animation based on operation type:
|
||||
* - 'charon' (blue): Proxy hosts, certificates, general config operations
|
||||
* - 'coin' (gold): Authentication/login operations
|
||||
* - 'cerberus' (red): Security operations (WAF, CrowdSec, ACL, Rate Limiting)
|
||||
*
|
||||
* @param message - Primary message (e.g., "Ferrying new host...")
|
||||
* @param submessage - Secondary context (e.g., "Charon is crossing the Styx")
|
||||
* @param type - Theme variant: 'charon', 'coin', or 'cerberus'
|
||||
*/
|
||||
export function ConfigReloadOverlay({
|
||||
message = 'Ferrying configuration...',
|
||||
submessage = 'Charon is crossing the Styx',
|
||||
type = 'charon',
|
||||
}: {
|
||||
message?: string
|
||||
submessage?: string
|
||||
type?: 'charon' | 'coin' | 'cerberus'
|
||||
}) {
|
||||
const Loader =
|
||||
type === 'cerberus' ? CerberusLoader :
|
||||
type === 'coin' ? CharonCoinLoader :
|
||||
CharonLoader
|
||||
|
||||
const bgColor =
|
||||
type === 'cerberus' ? 'bg-red-950/90' :
|
||||
type === 'coin' ? 'bg-amber-950/90' :
|
||||
'bg-blue-950/90'
|
||||
|
||||
const borderColor =
|
||||
type === 'cerberus' ? 'border-red-900/50' :
|
||||
type === 'coin' ? 'border-amber-900/50' :
|
||||
'border-blue-900/50'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className={`${bgColor} ${borderColor} border-2 rounded-lg p-8 flex flex-col items-center gap-4 shadow-2xl max-w-md mx-4`}>
|
||||
<Loader size="lg" />
|
||||
<div className="text-center">
|
||||
<p className="text-slate-100 text-lg font-semibold mb-1">{message}</p>
|
||||
<p className="text-slate-300 text-sm">{submessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CharonLoader, CharonCoinLoader, CerberusLoader, ConfigReloadOverlay } from '../LoadingStates'
|
||||
|
||||
describe('CharonLoader', () => {
|
||||
it('renders boat animation with accessibility label', () => {
|
||||
render(<CharonLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders coin animation with accessibility label', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders guardian animation with accessibility label', () => {
|
||||
render(<CerberusLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay', () => {
|
||||
it('renders with Charon theme (default)', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Coin theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Paying the ferryman..."
|
||||
submessage="Your obol grants passage"
|
||||
type="coin"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Cerberus theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Cerberus awakens..."
|
||||
submessage="Guardian of the gates stands watch"
|
||||
type="cerberus"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cerberus awakens...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Guardian of the gates stands watch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom messages', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Custom message"
|
||||
submessage="Custom submessage"
|
||||
type="charon"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom submessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct theme colors', () => {
|
||||
const { container, rerender } = render(<ConfigReloadOverlay type="charon" />)
|
||||
let overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="coin" />)
|
||||
overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="cerberus" />)
|
||||
overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as full-screen overlay with high z-index', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.fixed.inset-0.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CharonLoader,
|
||||
CharonCoinLoader,
|
||||
CerberusLoader,
|
||||
ConfigReloadOverlay,
|
||||
} from '../LoadingStates'
|
||||
|
||||
describe('LoadingStates - Security Audit', () => {
|
||||
describe('CharonLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles all size variants', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label', () => {
|
||||
render(<CharonLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for authentication', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders gradient definition', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const gradient = container.querySelector('#goldGradient')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonCoinLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for security', () => {
|
||||
render(<CerberusLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders three heads (three circles for heads)', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const circles = container.querySelectorAll('circle')
|
||||
// At least 3 head circles should exist (plus paws and eyes)
|
||||
expect(circles.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CerberusLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay - XSS Protection', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in message prop', () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>'
|
||||
render(<ConfigReloadOverlay message={xssPayload} />)
|
||||
|
||||
// React should escape this automatically
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in submessage prop', () => {
|
||||
const xssPayload = '<img src=x onerror="alert(1)">'
|
||||
render(<ConfigReloadOverlay submessage={xssPayload} />)
|
||||
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('img[onerror]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles extremely long messages', () => {
|
||||
const longMessage = 'A'.repeat(10000)
|
||||
const { container } = render(<ConfigReloadOverlay message={longMessage} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles special characters', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message={specialChars}
|
||||
submessage={specialChars}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText(specialChars)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ATTACK: handles unicode and emoji', () => {
|
||||
const unicode = '🔥💀🐕🦺 λ µ π Σ 中文 العربية עברית'
|
||||
render(<ConfigReloadOverlay message={unicode} />)
|
||||
|
||||
expect(screen.getByText(unicode)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - charon (blue)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="charon" />)
|
||||
const overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - coin (gold)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="coin" />)
|
||||
const overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - cerberus (red)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="cerberus" />)
|
||||
const overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct z-index (z-50)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies backdrop blur', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const backdrop = container.querySelector('.backdrop-blur-sm')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: type prop injection attempt', () => {
|
||||
// @ts-expect-error - Testing invalid type
|
||||
const { container } = render(<ConfigReloadOverlay type="<script>alert(1)</script>" />)
|
||||
|
||||
// Should default to charon theme
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Integration Tests', () => {
|
||||
it('CharonLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="charon" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('CharonCoinLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="coin" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('CerberusLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="cerberus" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Animation Requirements', () => {
|
||||
it('CharonLoader uses animate-bob-boat class', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
const animated = container.querySelector('.animate-bob-boat')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CharonCoinLoader uses animate-spin-y class', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const animated = container.querySelector('.animate-spin-y')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CerberusLoader uses animate-rotate-head class', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const animated = container.querySelector('.animate-rotate-head')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined size prop gracefully', () => {
|
||||
const { container } = render(<CharonLoader size={undefined} />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20') // defaults to md
|
||||
})
|
||||
|
||||
it('handles null message', () => {
|
||||
// @ts-expect-error - Testing null
|
||||
render(<ConfigReloadOverlay message={null} />)
|
||||
expect(screen.getByText('null')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty string message', () => {
|
||||
render(<ConfigReloadOverlay message="" submessage="" />)
|
||||
// Should render but be empty
|
||||
expect(screen.queryByText('Ferrying configuration...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles undefined type prop', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type={undefined} />)
|
||||
// Should default to charon
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility Requirements', () => {
|
||||
it('overlay is keyboard accessible', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.firstChild
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('all loaders have status role', () => {
|
||||
render(
|
||||
<>
|
||||
<CharonLoader />
|
||||
<CharonCoinLoader />
|
||||
<CerberusLoader />
|
||||
</>
|
||||
)
|
||||
const statuses = screen.getAllByRole('status')
|
||||
expect(statuses).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('all loaders have aria-label', () => {
|
||||
const { container: c1 } = render(<CharonLoader />)
|
||||
const { container: c2 } = render(<CharonCoinLoader />)
|
||||
const { container: c3 } = render(<CerberusLoader />)
|
||||
|
||||
expect(c1.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c2.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c3.firstChild).toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
it('renders CharonLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100) // Should render in <100ms
|
||||
})
|
||||
|
||||
it('renders CharonCoinLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonCoinLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders CerberusLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CerberusLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders ConfigReloadOverlay quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<ConfigReloadOverlay />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
298
frontend/src/hooks/__tests__/useSecurity.test.tsx
Normal file
298
frontend/src/hooks/__tests__/useSecurity.test.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
useSecurityStatus,
|
||||
useSecurityConfig,
|
||||
useUpdateSecurityConfig,
|
||||
useGenerateBreakGlassToken,
|
||||
useDecisions,
|
||||
useCreateDecision,
|
||||
useRuleSets,
|
||||
useUpsertRuleSet,
|
||||
useDeleteRuleSet,
|
||||
useEnableCerberus,
|
||||
useDisableCerberus,
|
||||
} from '../useSecurity'
|
||||
import * as securityApi from '../../api/security'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('react-hot-toast')
|
||||
|
||||
describe('useSecurity hooks', () => {
|
||||
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}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
describe('useSecurityStatus', () => {
|
||||
it('should fetch security status', async () => {
|
||||
const mockStatus = {
|
||||
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 }
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockStatus)
|
||||
|
||||
const { result } = renderHook(() => useSecurityStatus(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockStatus)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSecurityConfig', () => {
|
||||
it('should fetch security config', async () => {
|
||||
const mockConfig = { config: { admin_whitelist: '10.0.0.0/8' } }
|
||||
vi.mocked(securityApi.getSecurityConfig).mockResolvedValue(mockConfig)
|
||||
|
||||
const { result } = renderHook(() => useSecurityConfig(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateSecurityConfig', () => {
|
||||
it('should update security config and invalidate queries on success', async () => {
|
||||
const payload = { admin_whitelist: '192.168.0.0/16' }
|
||||
vi.mocked(securityApi.updateSecurityConfig).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useUpdateSecurityConfig(), { wrapper })
|
||||
|
||||
result.current.mutate(payload)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.updateSecurityConfig).toHaveBeenCalledWith(payload)
|
||||
expect(toast.success).toHaveBeenCalledWith('Security configuration updated')
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const error = new Error('Update failed')
|
||||
vi.mocked(securityApi.updateSecurityConfig).mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => useUpdateSecurityConfig(), { wrapper })
|
||||
|
||||
result.current.mutate({ admin_whitelist: 'invalid' })
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to update security settings: Update failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGenerateBreakGlassToken', () => {
|
||||
it('should generate break glass token', async () => {
|
||||
const mockToken = { token: 'abc123' }
|
||||
vi.mocked(securityApi.generateBreakGlassToken).mockResolvedValue(mockToken)
|
||||
|
||||
const { result } = renderHook(() => useGenerateBreakGlassToken(), { wrapper })
|
||||
|
||||
result.current.mutate(undefined)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockToken)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDecisions', () => {
|
||||
it('should fetch decisions with default limit', async () => {
|
||||
const mockDecisions = { decisions: [{ ip: '1.2.3.4', type: 'ban' }] }
|
||||
vi.mocked(securityApi.getDecisions).mockResolvedValue(mockDecisions)
|
||||
|
||||
const { result } = renderHook(() => useDecisions(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.getDecisions).toHaveBeenCalledWith(50)
|
||||
expect(result.current.data).toEqual(mockDecisions)
|
||||
})
|
||||
|
||||
it('should fetch decisions with custom limit', async () => {
|
||||
const mockDecisions = { decisions: [] }
|
||||
vi.mocked(securityApi.getDecisions).mockResolvedValue(mockDecisions)
|
||||
|
||||
const { result } = renderHook(() => useDecisions(100), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.getDecisions).toHaveBeenCalledWith(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateDecision', () => {
|
||||
it('should create decision and invalidate queries', async () => {
|
||||
const payload = { ip: '1.2.3.4', duration: '4h', type: 'ban' }
|
||||
vi.mocked(securityApi.createDecision).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useCreateDecision(), { wrapper })
|
||||
|
||||
result.current.mutate(payload)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.createDecision).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRuleSets', () => {
|
||||
it('should fetch rule sets', async () => {
|
||||
const mockRuleSets = {
|
||||
rulesets: [{
|
||||
id: 1,
|
||||
uuid: 'abc-123',
|
||||
name: 'OWASP CRS',
|
||||
source_url: 'https://example.com',
|
||||
mode: 'blocking',
|
||||
last_updated: '2025-12-04',
|
||||
content: 'rules'
|
||||
}]
|
||||
}
|
||||
vi.mocked(securityApi.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
const { result } = renderHook(() => useRuleSets(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockRuleSets)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpsertRuleSet', () => {
|
||||
it('should upsert rule set and show success toast', async () => {
|
||||
const payload = { name: 'Custom Rules', content: 'rule data', mode: 'blocking' as const }
|
||||
vi.mocked(securityApi.upsertRuleSet).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useUpsertRuleSet(), { wrapper })
|
||||
|
||||
result.current.mutate(payload)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.upsertRuleSet).toHaveBeenCalledWith(payload)
|
||||
expect(toast.success).toHaveBeenCalledWith('Rule set saved successfully')
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const error = new Error('Save failed')
|
||||
vi.mocked(securityApi.upsertRuleSet).mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => useUpsertRuleSet(), { wrapper })
|
||||
|
||||
result.current.mutate({ name: 'Test', content: 'data', mode: 'blocking' })
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save rule set: Save failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeleteRuleSet', () => {
|
||||
it('should delete rule set and show success toast', async () => {
|
||||
vi.mocked(securityApi.deleteRuleSet).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useDeleteRuleSet(), { wrapper })
|
||||
|
||||
result.current.mutate(1)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.deleteRuleSet).toHaveBeenCalledWith(1)
|
||||
expect(toast.success).toHaveBeenCalledWith('Rule set deleted')
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const error = new Error('Delete failed')
|
||||
vi.mocked(securityApi.deleteRuleSet).mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => useDeleteRuleSet(), { wrapper })
|
||||
|
||||
result.current.mutate(1)
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete rule set: Delete failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEnableCerberus', () => {
|
||||
it('should enable Cerberus and show success toast', async () => {
|
||||
vi.mocked(securityApi.enableCerberus).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useEnableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(undefined)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.enableCerberus).toHaveBeenCalledWith(undefined)
|
||||
expect(toast.success).toHaveBeenCalledWith('Cerberus enabled')
|
||||
})
|
||||
|
||||
it('should enable Cerberus with payload', async () => {
|
||||
const payload = { mode: 'full' }
|
||||
vi.mocked(securityApi.enableCerberus).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useEnableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(payload)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.enableCerberus).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const error = new Error('Enable failed')
|
||||
vi.mocked(securityApi.enableCerberus).mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => useEnableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(undefined)
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to enable Cerberus: Enable failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDisableCerberus', () => {
|
||||
it('should disable Cerberus and show success toast', async () => {
|
||||
vi.mocked(securityApi.disableCerberus).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useDisableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(undefined)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.disableCerberus).toHaveBeenCalledWith(undefined)
|
||||
expect(toast.success).toHaveBeenCalledWith('Cerberus disabled')
|
||||
})
|
||||
|
||||
it('should disable Cerberus with payload', async () => {
|
||||
const payload = { reason: 'maintenance' }
|
||||
vi.mocked(securityApi.disableCerberus).mockResolvedValue({ success: true })
|
||||
|
||||
const { result } = renderHook(() => useDisableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(payload)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(securityApi.disableCerberus).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const error = new Error('Disable failed')
|
||||
vi.mocked(securityApi.disableCerberus).mockRejectedValue(error)
|
||||
|
||||
const { result } = renderHook(() => useDisableCerberus(), { wrapper })
|
||||
|
||||
result.current.mutate(undefined)
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to disable Cerberus: Disable failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,60 @@
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes bob-boat {
|
||||
0%, 100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bob-boat {
|
||||
animation: bob-boat 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate-head {
|
||||
0%, 100% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-rotate-head {
|
||||
animation: rotate-head 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin-y {
|
||||
0% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-y {
|
||||
animation: spin-y 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -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