Files
Charon/frontend/src/components/__tests__/LiveLogViewer.test.tsx

649 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LiveLogViewer } from '../LiveLogViewer';
import * as logsApi from '../../api/logs';
// Mock the connectLiveLogs and connectSecurityLogs functions
vi.mock('../../api/logs', async () => {
const actual = await vi.importActual('../../api/logs');
return {
...actual,
connectLiveLogs: vi.fn(),
connectSecurityLogs: vi.fn(),
};
});
describe('LiveLogViewer', () => {
let mockCloseConnection: ReturnType<typeof vi.fn>;
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
let mockOnSecurityMessage: ((log: logsApi.SecurityLogEntry) => void) | null;
let mockOnClose: (() => void) | null;
beforeEach(() => {
mockCloseConnection = vi.fn();
mockOnMessage = null;
mockOnSecurityMessage = null;
mockOnClose = null;
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
mockOnMessage = onMessage;
mockOnClose = onClose ?? null;
// Simulate connection success
if (onOpen) {
setTimeout(() => onOpen(), 0);
}
return mockCloseConnection as () => void;
});
vi.mocked(logsApi.connectSecurityLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
mockOnSecurityMessage = onMessage;
mockOnClose = onClose ?? null;
// Simulate connection success
if (onOpen) {
setTimeout(() => onOpen(), 0);
}
return mockCloseConnection as () => void;
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders the component with initial state', async () => {
render(<LiveLogViewer />);
expect(screen.getByText('Live Security Logs')).toBeTruthy();
// Initially disconnected until WebSocket opens
expect(screen.getByText('Disconnected')).toBeTruthy();
// Wait for onOpen callback to be called
await waitFor(() => {
expect(screen.getByText('Connected')).toBeTruthy();
});
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
it('displays incoming log messages', async () => {
render(<LiveLogViewer />);
// Simulate receiving a log
const logEntry: logsApi.LiveLogEntry = {
level: 'info',
timestamp: '2025-12-09T10:30:00Z',
message: 'Test log message',
source: 'test',
};
if (mockOnMessage) {
mockOnMessage(logEntry);
}
await waitFor(() => {
expect(screen.getByText('Test log message')).toBeTruthy();
expect(screen.getByText('INFO')).toBeTruthy();
expect(screen.getByText('[test]')).toBeTruthy();
});
});
it('filters logs by text', async () => {
const user = userEvent.setup();
render(<LiveLogViewer />);
// Add multiple logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'First message' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Second message' });
}
await waitFor(() => {
expect(screen.getByText('First message')).toBeTruthy();
expect(screen.getByText('Second message')).toBeTruthy();
});
// Apply text filter
const filterInput = screen.getByPlaceholderText('Filter by text...');
await user.type(filterInput, 'First');
await waitFor(() => {
expect(screen.getByText('First message')).toBeTruthy();
expect(screen.queryByText('Second message')).toBeFalsy();
});
});
it('filters logs by level', async () => {
const user = userEvent.setup();
render(<LiveLogViewer />);
// Add multiple logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Info message' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Error message' });
}
await waitFor(() => {
expect(screen.getByText('Info message')).toBeTruthy();
expect(screen.getByText('Error message')).toBeTruthy();
});
// Apply level filter
const levelSelect = screen.getAllByRole('combobox')[0];
await user.selectOptions(levelSelect, 'error');
await waitFor(() => {
expect(screen.queryByText('Info message')).toBeFalsy();
expect(screen.getByText('Error message')).toBeTruthy();
});
});
it('pauses and resumes log streaming', async () => {
const user = userEvent.setup();
render(<LiveLogViewer />);
// Add initial log
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Before pause' });
}
await waitFor(() => {
expect(screen.getByText('Before pause')).toBeTruthy();
});
// Click pause button
const pauseButton = screen.getByTitle('Pause');
await user.click(pauseButton);
// Verify paused state
await waitFor(() => {
expect(screen.getByText('⏸ Paused')).toBeTruthy();
});
// Try to add log while paused (should not appear)
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'During pause' });
}
// Log should not appear
expect(screen.queryByText('During pause')).toBeFalsy();
// Resume
const resumeButton = screen.getByTitle('Resume');
await user.click(resumeButton);
// Add log after resume
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'After resume' });
}
await waitFor(() => {
expect(screen.getByText('After resume')).toBeTruthy();
});
});
it('clears all logs', async () => {
const user = userEvent.setup();
render(<LiveLogViewer />);
// Add logs
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
}
await waitFor(() => {
expect(screen.getByText('Log 1')).toBeTruthy();
expect(screen.getByText('Log 2')).toBeTruthy();
});
// Click clear button
const clearButton = screen.getByTitle('Clear logs');
await user.click(clearButton);
await waitFor(() => {
expect(screen.queryByText('Log 1')).toBeFalsy();
expect(screen.queryByText('Log 2')).toBeFalsy();
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
});
it('limits the number of stored logs', async () => {
render(<LiveLogViewer maxLogs={2} />);
// Add 3 logs (exceeding maxLogs)
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'Log 3' });
}
await waitFor(() => {
// First log should be removed, only last 2 should remain
expect(screen.queryByText('Log 1')).toBeFalsy();
expect(screen.getByText('Log 2')).toBeTruthy();
expect(screen.getByText('Log 3')).toBeTruthy();
});
});
it('displays log data when available', async () => {
render(<LiveLogViewer />);
const logWithData: logsApi.LiveLogEntry = {
level: 'error',
timestamp: '2025-12-09T10:30:00Z',
message: 'Error occurred',
data: { error_code: 500, details: 'Internal server error' },
};
if (mockOnMessage) {
mockOnMessage(logWithData);
}
await waitFor(() => {
expect(screen.getByText('Error occurred')).toBeTruthy();
// Check that data is rendered as JSON
expect(screen.getByText(/"error_code"/)).toBeTruthy();
});
});
it('closes WebSocket connection on unmount', () => {
const { unmount } = render(<LiveLogViewer />);
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
unmount();
expect(mockCloseConnection).toHaveBeenCalled();
});
it('applies custom className', () => {
const { container } = render(<LiveLogViewer className="custom-class" />);
const element = container.querySelector('.custom-class');
expect(element).toBeTruthy();
});
it('shows correct connection status', async () => {
let mockOnOpen: (() => void) | undefined;
let mockOnError: ((error: Event) => void) | undefined;
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, _onMessage, onOpen, onError) => {
mockOnOpen = onOpen;
mockOnError = onError;
return mockCloseConnection as () => void;
});
render(<LiveLogViewer />);
// Initially disconnected until onOpen is called
expect(screen.getByText('Disconnected')).toBeTruthy();
// Simulate connection opened
if (mockOnOpen) {
mockOnOpen();
}
await waitFor(() => {
expect(screen.getByText('Connected')).toBeTruthy();
});
// Simulate connection error
if (mockOnError) {
mockOnError(new Event('error'));
}
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeTruthy();
});
});
it('shows no-match message when filters exclude all logs', async () => {
const user = userEvent.setup();
render(<LiveLogViewer />);
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Visible' });
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Hidden' });
}
await waitFor(() => expect(screen.getByText('Visible')).toBeTruthy());
await user.type(screen.getByPlaceholderText('Filter by text...'), 'nomatch');
await waitFor(() => {
expect(screen.getByText('No logs match the current filters.')).toBeTruthy();
});
});
it('marks connection as disconnected when WebSocket closes', async () => {
render(<LiveLogViewer />);
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
act(() => {
mockOnClose?.();
});
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
});
// ============================================================
// Security Mode Tests
// ============================================================
describe('Security Mode', () => {
it('renders in security mode when mode="security"', async () => {
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Security Access Logs')).toBeTruthy();
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
});
it('displays security log entries with source badges', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const securityLog: logsApi.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,
};
if (mockOnSecurityMessage) {
mockOnSecurityMessage(securityLog);
}
await waitFor(() => {
expect(screen.getByText('NORMAL')).toBeTruthy();
expect(screen.getByText('192.168.1.100')).toBeTruthy();
expect(screen.getByText(/GET \/api\/test → 200/)).toBeTruthy();
});
});
it('displays blocked requests with special styling', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const blockedLog: logsApi.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',
};
// Send message inside act to properly handle state updates
await act(async () => {
if (mockOnSecurityMessage) {
mockOnSecurityMessage(blockedLog);
}
});
// Use findBy queries (built-in waiting) instead of single waitFor with multiple assertions
// This avoids race conditions where one failing assertion causes the entire block to retry
await screen.findByText('10.0.0.1');
await screen.findByText(/🚫 BLOCKED: SQL injection detected/);
await screen.findByText(/\[SQL injection detected\]/);
// For getAllByText, keep in waitFor but separate from other assertions
await waitFor(() => {
// Use getAllByText since 'WAF' appears both in dropdown option and source badge
const wafElements = screen.getAllByText('WAF');
expect(wafElements.length).toBeGreaterThanOrEqual(2); // Option + badge
});
}, 15000); // 15 second timeout as safeguard
it('shows source filter dropdown in security mode', async () => {
render(<LiveLogViewer mode="security" />);
// Should have source filter options
expect(screen.getByText('All Sources')).toBeTruthy();
expect(screen.getByRole('option', { name: 'WAF' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'CrowdSec' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'Rate Limit' })).toBeTruthy();
expect(screen.getByRole('option', { name: 'ACL' })).toBeTruthy();
});
it('filters by source in security mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
// Wait for connection
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
// Add logs from different sources
if (mockOnSecurityMessage) {
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.1',
method: 'GET',
uri: '/normal-request',
status: 200,
duration: 0.01,
size: 100,
user_agent: 'Test/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
});
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:01Z',
level: 'warn',
logger: 'http.handlers.waf',
client_ip: '10.0.0.1',
method: 'POST',
uri: '/waf-blocked',
status: 403,
duration: 0.001,
size: 0,
user_agent: 'Attack/1.0',
host: 'example.com',
source: 'waf',
blocked: true,
block_reason: 'WAF block',
});
}
// Wait for logs to appear - normal shows URI, blocked shows block message
await waitFor(() => {
expect(screen.getByText(/GET \/normal-request/)).toBeTruthy();
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
});
// Filter by WAF using the source dropdown (second combobox after level)
const sourceSelects = screen.getAllByRole('combobox');
const sourceFilterSelect = sourceSelects[1]; // Second combobox is source filter
await user.selectOptions(sourceFilterSelect, 'waf');
await waitFor(() => {
expect(screen.queryByText(/GET \/normal-request/)).toBeFalsy();
expect(screen.getByText(/BLOCKED: WAF block/)).toBeTruthy();
});
});
it('shows blocked only checkbox in security mode', async () => {
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Blocked only')).toBeTruthy();
expect(screen.getByRole('checkbox')).toBeTruthy();
});
it('toggles blocked only filter', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
// Verify checkbox is checked
expect(checkbox).toBeChecked();
});
it('displays duration for security logs', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
const securityLog: logsApi.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.123,
size: 1024,
user_agent: 'TestAgent/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
};
if (mockOnSecurityMessage) {
mockOnSecurityMessage(securityLog);
}
await waitFor(() => {
expect(screen.getByText('123.0ms')).toBeTruthy();
});
});
it('displays status code with appropriate color for security logs', async () => {
render(<LiveLogViewer mode="security" />);
// Wait for connection to establish
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
if (mockOnSecurityMessage) {
mockOnSecurityMessage({
timestamp: '2025-12-12T10:30:00Z',
level: 'info',
logger: 'http.log.access',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/ok',
status: 200,
duration: 0.01,
size: 100,
user_agent: 'Test/1.0',
host: 'example.com',
source: 'normal',
blocked: false,
});
}
await waitFor(() => {
expect(screen.getByText('[200]')).toBeTruthy();
});
});
});
// ============================================================
// Mode Toggle Tests
// ============================================================
describe('Mode Toggle', () => {
it('switches from application to security mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
expect(screen.getByText('Live Security Logs')).toBeTruthy();
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
// Click security mode button
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
expect(screen.getByText('Security Access Logs')).toBeTruthy();
expect(logsApi.connectSecurityLogs).toHaveBeenCalled();
});
});
it('switches from security to application mode', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="security" />);
expect(screen.getByText('Security Access Logs')).toBeTruthy();
// Click application mode button
const appButton = screen.getByTitle('Application logs');
await user.click(appButton);
await waitFor(() => {
expect(screen.getByText('Live Security Logs')).toBeTruthy();
});
});
it('clears logs when switching modes', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
// Add a log in application mode
if (mockOnMessage) {
mockOnMessage({ level: 'info', timestamp: '2025-12-12T10:30:00Z', message: 'App log' });
}
await waitFor(() => {
expect(screen.getByText('App log')).toBeTruthy();
});
// Switch to security mode
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
expect(screen.queryByText('App log')).toBeFalsy();
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
});
});
it('resets filters when switching modes', async () => {
const user = userEvent.setup();
render(<LiveLogViewer mode="application" />);
// Set a filter
const filterInput = screen.getByPlaceholderText('Filter by text...');
await user.type(filterInput, 'test');
// Switch to security mode
const securityButton = screen.getByTitle('Security access logs');
await user.click(securityButton);
await waitFor(() => {
// Filter should be cleared
expect(screen.getByPlaceholderText('Filter by text...')).toHaveValue('');
});
});
});
});