- 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.
340 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|