Files
Charon/frontend/src/api/logs.test.ts
GitHub Actions 4b49ec5f2b feat: Enhance LiveLogViewer with Security Mode and related tests
- Updated LiveLogViewer to support a new security mode, allowing for the display of security logs.
- Implemented mock functions for connecting to security logs in tests.
- Added tests for rendering, filtering, and displaying security log entries, including blocked requests and source filtering.
- Modified Security page to utilize the new security mode in LiveLogViewer.
- Updated Security page tests to reflect changes in log viewer and ensure proper rendering of security-related components.
- Introduced a new script for CrowdSec startup testing, ensuring proper configuration and parser installation.
- Added pre-flight checks in the CrowdSec integration script to verify successful startup and configuration.
2025-12-12 22:18:28 +00:00

340 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import client from './client'
import { getLogs, getLogContent, downloadLog, connectLiveLogs, connectSecurityLogs } from './logs'
import type { LiveLogEntry, SecurityLogEntry } from './logs'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
},
}))
const mockedClient = client as unknown as {
get: ReturnType<typeof vi.fn>
}
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 3
static instances: MockWebSocket[] = []
url: string
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onerror: ((event: Event) => void) | null = null
onclose: ((event: CloseEvent) => void) | null = null
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
open() {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
}
sendMessage(data: string) {
this.onmessage?.({ data })
}
triggerError(event: Event) {
this.onerror?.(event)
}
close() {
this.readyState = MockWebSocket.CLOSED
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent)
}
}
const originalWebSocket = globalThis.WebSocket
const originalLocation = { ...window.location }
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = MockWebSocket as unknown as typeof WebSocket
Object.defineProperty(window, 'location', {
value: { ...originalLocation, protocol: 'http:', host: 'localhost', href: '' },
writable: true,
})
})
afterEach(() => {
;(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = originalWebSocket
Object.defineProperty(window, 'location', { value: originalLocation })
MockWebSocket.instances.length = 0
})
describe('logs api', () => {
it('lists log files', async () => {
mockedClient.get.mockResolvedValue({ data: [{ name: 'access.log', size: 10, mod_time: 'now' }] })
const logs = await getLogs()
expect(mockedClient.get).toHaveBeenCalledWith('/logs')
expect(logs[0].name).toBe('access.log')
})
it('fetches log content with filters applied', async () => {
mockedClient.get.mockResolvedValue({ data: { filename: 'access.log', logs: [], total: 0, limit: 50, offset: 0 } })
await getLogContent('access.log', {
search: 'error',
host: 'example.com',
status: '500',
level: 'error',
limit: 50,
offset: 10,
sort: 'asc',
})
expect(mockedClient.get).toHaveBeenCalledWith(
'/logs/access.log?search=error&host=example.com&status=500&level=error&limit=50&offset=10&sort=asc'
)
})
it('sets window location when downloading logs', () => {
downloadLog('access.log')
expect(window.location.href).toBe('/api/v1/logs/access.log/download')
})
it('connects to live logs websocket and handles lifecycle events', () => {
const received: LiveLogEntry[] = []
const onOpen = vi.fn()
const onError = vi.fn()
const onClose = vi.fn()
const disconnect = connectLiveLogs({ level: 'error', source: 'cerberus' }, (log) => received.push(log), onOpen, onError, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
expect(socket.url).toContain('source=cerberus')
socket.open()
expect(onOpen).toHaveBeenCalled()
socket.sendMessage(JSON.stringify({ level: 'info', timestamp: 'now', message: 'hello' }))
expect(received).toHaveLength(1)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
socket.sendMessage('not-json')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
socket.close()
expect(onClose).toHaveBeenCalled()
disconnect()
})
})
describe('connectSecurityLogs', () => {
it('connects to cerberus logs websocket endpoint', () => {
const received: SecurityLogEntry[] = []
const onOpen = vi.fn()
connectSecurityLogs({}, (log) => received.push(log), onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('/api/v1/cerberus/logs/ws')
})
it('passes source filter to websocket url', () => {
connectSecurityLogs({ source: 'waf' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
})
it('passes level filter to websocket url', () => {
connectSecurityLogs({ level: 'error' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('level=error')
})
it('passes ip filter to websocket url', () => {
connectSecurityLogs({ ip: '192.168' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('ip=192.168')
})
it('passes host filter to websocket url', () => {
connectSecurityLogs({ host: 'example.com' }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('host=example.com')
})
it('passes blocked_only filter to websocket url', () => {
connectSecurityLogs({ blocked_only: true }, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('blocked_only=true')
})
it('receives and parses security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const securityLogEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/api/test',
status: 200,
duration: 0.05,
size: 1024,
user_agent: 'TestAgent/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
}
socket.sendMessage(JSON.stringify(securityLogEntry))
expect(received).toHaveLength(1)
expect(received[0].client_ip).toBe('192.168.1.100')
expect(received[0].source).toBe('normal')
expect(received[0].blocked).toBe(false)
})
it('receives blocked security log entries', () => {
const received: SecurityLogEntry[] = []
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
const blockedEntry: SecurityLogEntry = {
timestamp: '2025-12-12T10:30:00Z',
level: 'warn',
logger: 'http.handlers.waf',
client_ip: '10.0.0.1',
method: 'POST',
uri: '/admin',
status: 403,
duration: 0.001,
size: 0,
user_agent: 'Attack/1.0',
host: 'example.com',
source: 'waf',
blocked: true,
block_reason: 'SQL injection detected',
}
socket.sendMessage(JSON.stringify(blockedEntry))
expect(received).toHaveLength(1)
expect(received[0].blocked).toBe(true)
expect(received[0].block_reason).toBe('SQL injection detected')
expect(received[0].source).toBe('waf')
})
it('handles onOpen callback', () => {
const onOpen = vi.fn()
connectSecurityLogs({}, () => {}, onOpen)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(onOpen).toHaveBeenCalled()
})
it('handles onError callback', () => {
const onError = vi.fn()
connectSecurityLogs({}, () => {}, undefined, onError)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
const errorEvent = new Event('error')
socket.triggerError(errorEvent)
expect(onError).toHaveBeenCalledWith(errorEvent)
})
it('handles onClose callback', () => {
const onClose = vi.fn()
connectSecurityLogs({}, () => {}, undefined, undefined, onClose)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.close()
expect(onClose).toHaveBeenCalled()
})
it('returns disconnect function that closes websocket', () => {
const disconnect = connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
expect(socket.readyState).toBe(MockWebSocket.OPEN)
disconnect()
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
})
it('handles JSON parse errors gracefully', () => {
const received: SecurityLogEntry[] = []
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
connectSecurityLogs({}, (log) => received.push(log))
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
socket.open()
socket.sendMessage('invalid-json')
expect(received).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('uses wss protocol when on https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'secure.example.com', href: '' },
writable: true,
})
connectSecurityLogs({}, () => {})
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('wss://')
expect(socket.url).toContain('secure.example.com')
})
it('combines multiple filters in websocket url', () => {
connectSecurityLogs(
{
source: 'waf',
level: 'warn',
ip: '10.0.0',
host: 'example.com',
blocked_only: true,
},
() => {}
)
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
expect(socket.url).toContain('source=waf')
expect(socket.url).toContain('level=warn')
expect(socket.url).toContain('ip=10.0.0')
expect(socket.url).toContain('host=example.com')
expect(socket.url).toContain('blocked_only=true')
})
})