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.
This commit is contained in:
@@ -1,78 +1,258 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { connectLiveLogs, LiveLogEntry, LiveLogFilter } from '../api/logs';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
connectLiveLogs,
|
||||
connectSecurityLogs,
|
||||
LiveLogEntry,
|
||||
LiveLogFilter,
|
||||
SecurityLogEntry,
|
||||
SecurityLogFilter,
|
||||
} from '../api/logs';
|
||||
import { Button } from './ui/Button';
|
||||
import { Pause, Play, Trash2, Filter } from 'lucide-react';
|
||||
import { Pause, Play, Trash2, Filter, Shield, Globe } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Log viewing mode: application logs vs security access logs
|
||||
*/
|
||||
export type LogMode = 'application' | 'security';
|
||||
|
||||
interface LiveLogViewerProps {
|
||||
/** Filters for application log mode */
|
||||
filters?: LiveLogFilter;
|
||||
/** Filters for security log mode */
|
||||
securityFilters?: SecurityLogFilter;
|
||||
/** Initial log viewing mode */
|
||||
mode?: LogMode;
|
||||
/** Maximum number of log entries to retain */
|
||||
maxLogs?: number;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: LiveLogViewerProps) {
|
||||
const [logs, setLogs] = useState<LiveLogEntry[]>([]);
|
||||
/**
|
||||
* Unified display entry for both application and security logs
|
||||
*/
|
||||
interface DisplayLogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
blocked?: boolean;
|
||||
blockReason?: string;
|
||||
clientIP?: string;
|
||||
method?: string;
|
||||
host?: string;
|
||||
uri?: string;
|
||||
status?: number;
|
||||
duration?: number;
|
||||
userAgent?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a LiveLogEntry to unified display format
|
||||
*/
|
||||
const toDisplayFromLive = (entry: LiveLogEntry): DisplayLogEntry => ({
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level,
|
||||
source: entry.source || 'app',
|
||||
message: entry.message,
|
||||
details: entry.data,
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert a SecurityLogEntry to unified display format
|
||||
*/
|
||||
const toDisplayFromSecurity = (entry: SecurityLogEntry): DisplayLogEntry => ({
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level,
|
||||
source: entry.source,
|
||||
message: entry.blocked
|
||||
? `🚫 BLOCKED: ${entry.block_reason || 'Access denied'}`
|
||||
: `${entry.method} ${entry.uri} → ${entry.status}`,
|
||||
blocked: entry.blocked,
|
||||
blockReason: entry.block_reason,
|
||||
clientIP: entry.client_ip,
|
||||
method: entry.method,
|
||||
host: entry.host,
|
||||
uri: entry.uri,
|
||||
status: entry.status,
|
||||
duration: entry.duration,
|
||||
userAgent: entry.user_agent,
|
||||
details: entry.details,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get background/text styling based on log entry properties
|
||||
*/
|
||||
const getEntryStyle = (log: DisplayLogEntry): string => {
|
||||
if (log.blocked) {
|
||||
return 'bg-red-900/30 border-l-2 border-red-500';
|
||||
}
|
||||
const level = log.level.toLowerCase();
|
||||
if (level.includes('error') || level.includes('fatal')) return 'text-red-400';
|
||||
if (level.includes('warn')) return 'text-yellow-400';
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get badge color for security source
|
||||
*/
|
||||
const getSourceBadgeColor = (source: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
waf: 'bg-orange-600',
|
||||
crowdsec: 'bg-purple-600',
|
||||
ratelimit: 'bg-blue-600',
|
||||
acl: 'bg-green-600',
|
||||
normal: 'bg-gray-600',
|
||||
cerberus: 'bg-indigo-600',
|
||||
app: 'bg-gray-500',
|
||||
};
|
||||
return colors[source.toLowerCase()] || 'bg-gray-500';
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get level color for application logs
|
||||
*/
|
||||
const getLevelColor = (level: string): string => {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
|
||||
if (normalized.includes('warn')) return 'text-yellow-400';
|
||||
if (normalized.includes('info')) return 'text-blue-400';
|
||||
if (normalized.includes('debug')) return 'text-gray-400';
|
||||
return 'text-gray-300';
|
||||
};
|
||||
|
||||
export function LiveLogViewer({
|
||||
filters = {},
|
||||
securityFilters = {},
|
||||
mode = 'application',
|
||||
maxLogs = 500,
|
||||
className = '',
|
||||
}: LiveLogViewerProps) {
|
||||
const [logs, setLogs] = useState<DisplayLogEntry[]>([]);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [currentMode, setCurrentMode] = useState<LogMode>(mode);
|
||||
const [textFilter, setTextFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
const [showBlockedOnly, setShowBlockedOnly] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const closeConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Auto-scroll when new logs arrive (only if not paused and user hasn't scrolled up)
|
||||
const shouldAutoScroll = useRef(true);
|
||||
|
||||
// Handle mode change - clear logs and update filters
|
||||
const handleModeChange = useCallback((newMode: LogMode) => {
|
||||
setCurrentMode(newMode);
|
||||
setLogs([]);
|
||||
setTextFilter('');
|
||||
setLevelFilter('');
|
||||
setSourceFilter('');
|
||||
setShowBlockedOnly(false);
|
||||
}, []);
|
||||
|
||||
// Connection effect - reconnects when mode or external filters change
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
const closeConnection = connectLiveLogs(
|
||||
filters,
|
||||
(log: LiveLogEntry) => {
|
||||
if (!isPaused) {
|
||||
setLogs((prev) => {
|
||||
const updated = [...prev, log];
|
||||
// Keep only last maxLogs entries
|
||||
if (updated.length > maxLogs) {
|
||||
return updated.slice(updated.length - maxLogs);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// onOpen callback - connection established
|
||||
console.log('Live log viewer connected');
|
||||
setIsConnected(true);
|
||||
},
|
||||
(error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
},
|
||||
() => {
|
||||
console.log('Live log viewer disconnected');
|
||||
setIsConnected(false);
|
||||
}
|
||||
);
|
||||
// Close existing connection
|
||||
if (closeConnectionRef.current) {
|
||||
closeConnectionRef.current();
|
||||
closeConnectionRef.current = null;
|
||||
}
|
||||
|
||||
closeConnectionRef.current = closeConnection;
|
||||
// Don't set isConnected here - wait for onOpen callback
|
||||
const handleOpen = () => {
|
||||
console.log(`${currentMode} log viewer connected`);
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
return () => {
|
||||
closeConnection();
|
||||
const handleError = (error: Event) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [filters, isPaused, maxLogs]);
|
||||
|
||||
// Handle auto-scroll
|
||||
const handleClose = () => {
|
||||
console.log(`${currentMode} log viewer disconnected`);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
if (currentMode === 'security') {
|
||||
// Connect to security logs endpoint
|
||||
const handleSecurityMessage = (entry: SecurityLogEntry) => {
|
||||
if (!isPaused) {
|
||||
const displayEntry = toDisplayFromSecurity(entry);
|
||||
setLogs((prev) => {
|
||||
const updated = [...prev, displayEntry];
|
||||
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build filters including blocked_only if selected
|
||||
const effectiveFilters: SecurityLogFilter = {
|
||||
...securityFilters,
|
||||
blocked_only: showBlockedOnly || securityFilters.blocked_only,
|
||||
};
|
||||
|
||||
closeConnectionRef.current = connectSecurityLogs(
|
||||
effectiveFilters,
|
||||
handleSecurityMessage,
|
||||
handleOpen,
|
||||
handleError,
|
||||
handleClose
|
||||
);
|
||||
} else {
|
||||
// Connect to application logs endpoint
|
||||
const handleLiveMessage = (entry: LiveLogEntry) => {
|
||||
if (!isPaused) {
|
||||
const displayEntry = toDisplayFromLive(entry);
|
||||
setLogs((prev) => {
|
||||
const updated = [...prev, displayEntry];
|
||||
return updated.length > maxLogs ? updated.slice(-maxLogs) : updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
closeConnectionRef.current = connectLiveLogs(
|
||||
filters,
|
||||
handleLiveMessage,
|
||||
handleOpen,
|
||||
handleError,
|
||||
handleClose
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (closeConnectionRef.current) {
|
||||
closeConnectionRef.current();
|
||||
closeConnectionRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [currentMode, filters, securityFilters, isPaused, maxLogs, showBlockedOnly]);
|
||||
|
||||
// Auto-scroll effect
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll.current && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
// Track if user has manually scrolled
|
||||
// Track manual scrolling
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
// If scrolled to bottom (within 50px), enable auto-scroll
|
||||
// Enable auto-scroll if scrolled to bottom (within 50px threshold)
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50;
|
||||
}
|
||||
};
|
||||
@@ -85,42 +265,45 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
// Filter logs based on text and level
|
||||
// Client-side filtering
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
if (textFilter && !log.message.toLowerCase().includes(textFilter.toLowerCase())) {
|
||||
return false;
|
||||
// Text filter - search in message, URI, host, IP
|
||||
if (textFilter) {
|
||||
const searchText = textFilter.toLowerCase();
|
||||
const matchFields = [
|
||||
log.message,
|
||||
log.uri,
|
||||
log.host,
|
||||
log.clientIP,
|
||||
log.blockReason,
|
||||
].filter(Boolean).map(s => s!.toLowerCase());
|
||||
|
||||
if (!matchFields.some(field => field.includes(searchText))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Level filter
|
||||
if (levelFilter && log.level.toLowerCase() !== levelFilter.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Source filter (security mode only)
|
||||
if (sourceFilter && log.source.toLowerCase() !== sourceFilter.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Color coding based on log level
|
||||
const getLevelColor = (level: string) => {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
|
||||
if (normalized.includes('warn')) return 'text-yellow-400';
|
||||
if (normalized.includes('info')) return 'text-blue-400';
|
||||
if (normalized.includes('debug')) return 'text-gray-400';
|
||||
return 'text-gray-300';
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-900 rounded-lg border border-gray-700 ${className}`}>
|
||||
{/* Header with controls */}
|
||||
{/* Header with mode toggle and controls */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">Live Security Logs</h3>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{currentMode === 'security' ? 'Security Access Logs' : 'Live Security Logs'}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
isConnected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
|
||||
@@ -130,6 +313,30 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex bg-gray-800 rounded-md p-0.5">
|
||||
<button
|
||||
onClick={() => handleModeChange('application')}
|
||||
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
|
||||
currentMode === 'application' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Application logs"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">App</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('security')}
|
||||
className={`px-2 py-1 text-xs rounded flex items-center gap-1 transition-colors ${
|
||||
currentMode === 'security' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Security access logs"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Security</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Pause/Resume */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -139,6 +346,7 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
>
|
||||
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
{/* Clear */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -152,14 +360,14 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by text..."
|
||||
value={textFilter}
|
||||
onChange={(e) => setTextFilter(e.target.value)}
|
||||
className="flex-1 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
|
||||
className="flex-1 min-w-32 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={levelFilter}
|
||||
@@ -173,6 +381,32 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
<option value="error">Error</option>
|
||||
<option value="fatal">Fatal</option>
|
||||
</select>
|
||||
{/* Security mode specific filters */}
|
||||
{currentMode === 'security' && (
|
||||
<>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => setSourceFilter(e.target.value)}
|
||||
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="waf">WAF</option>
|
||||
<option value="crowdsec">CrowdSec</option>
|
||||
<option value="ratelimit">Rate Limit</option>
|
||||
<option value="acl">ACL</option>
|
||||
<option value="normal">Normal</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBlockedOnly}
|
||||
onChange={(e) => setShowBlockedOnly(e.target.checked)}
|
||||
className="rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Blocked only
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log display */}
|
||||
@@ -188,14 +422,62 @@ export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: L
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.map((log, index) => (
|
||||
<div key={index} className="mb-1 hover:bg-gray-900 px-1 -mx-1 rounded">
|
||||
<div
|
||||
key={index}
|
||||
className={`mb-1 hover:bg-gray-900 px-1 -mx-1 rounded ${getEntryStyle(log)}`}
|
||||
>
|
||||
<span className="text-gray-500">{formatTimestamp(log.timestamp)}</span>
|
||||
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>{log.level.toUpperCase()}</span>
|
||||
{log.source && <span className="ml-2 text-purple-400">[{log.source}]</span>}
|
||||
|
||||
{/* Source badge for security mode */}
|
||||
{currentMode === 'security' && (
|
||||
<span className={`ml-2 px-1 rounded text-xs text-white ${getSourceBadgeColor(log.source)}`}>
|
||||
{log.source.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Level badge for application mode */}
|
||||
{currentMode === 'application' && (
|
||||
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>
|
||||
{log.level.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Client IP for security logs */}
|
||||
{currentMode === 'security' && log.clientIP && (
|
||||
<span className="ml-2 text-cyan-400">{log.clientIP}</span>
|
||||
)}
|
||||
|
||||
{/* Source tag for application logs */}
|
||||
{currentMode === 'application' && log.source && log.source !== 'app' && (
|
||||
<span className="ml-2 text-purple-400">[{log.source}]</span>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<span className="ml-2 text-gray-200">{log.message}</span>
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
|
||||
{/* Block reason badge */}
|
||||
{log.blocked && log.blockReason && (
|
||||
<span className="ml-2 text-red-400 text-xs">[{log.blockReason}]</span>
|
||||
)}
|
||||
|
||||
{/* Status code for security logs */}
|
||||
{currentMode === 'security' && log.status && !log.blocked && (
|
||||
<span className={`ml-2 ${log.status >= 400 ? 'text-red-400' : log.status >= 300 ? 'text-yellow-400' : 'text-green-400'}`}>
|
||||
[{log.status}]
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duration for security logs */}
|
||||
{currentMode === 'security' && log.duration !== undefined && (
|
||||
<span className="ml-1 text-gray-500">
|
||||
{log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Additional data */}
|
||||
{log.details && Object.keys(log.details).length > 0 && (
|
||||
<div className="ml-8 text-gray-400 text-xs">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
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 function
|
||||
// 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) => {
|
||||
@@ -32,6 +35,16 @@ describe('LiveLogViewer', () => {
|
||||
}
|
||||
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(() => {
|
||||
@@ -116,7 +129,7 @@ describe('LiveLogViewer', () => {
|
||||
});
|
||||
|
||||
// Apply level filter
|
||||
const levelSelect = screen.getByRole('combobox');
|
||||
const levelSelect = screen.getAllByRole('combobox')[0];
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -312,4 +325,319 @@ describe('LiveLogViewer', () => {
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
expect(screen.getByText('10.0.0.1')).toBeTruthy();
|
||||
expect(screen.getByText(/BLOCKED: SQL injection detected/)).toBeTruthy();
|
||||
// Block reason is shown in brackets - check for the text content
|
||||
expect(screen.getByText(/\[SQL injection detected\]/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user