feat(tests): enhance test coverage and error handling across various components

- Added a test case in CrowdSecConfig to show improved error message when preset is not cached.
- Introduced a new test suite for the Dashboard component, verifying counts and health status.
- Updated SMTPSettings tests to utilize a shared render function and added tests for backend validation errors.
- Modified Security.audit tests to improve input handling and removed redundant export failure test.
- Refactored Security tests to remove export functionality and ensure correct rendering of components.
- Enhanced UsersPage tests with new scenarios for updating user permissions and manual invite link flow.
- Created a new utility for rendering components with a QueryClient and MemoryRouter for better test isolation.
- Updated go-test-coverage script to improve error handling and coverage reporting.
This commit is contained in:
GitHub Actions
2025-12-11 00:26:07 +00:00
parent ca4cfc4e65
commit e299aa6b52
81 changed files with 8960 additions and 450 deletions

View File

@@ -0,0 +1,214 @@
import { useEffect, useRef, useState } from 'react';
import { connectLiveLogs, LiveLogEntry, LiveLogFilter } from '../api/logs';
import { Button } from './ui/Button';
import { Pause, Play, Trash2, Filter } from 'lucide-react';
interface LiveLogViewerProps {
filters?: LiveLogFilter;
maxLogs?: number;
className?: string;
}
export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: LiveLogViewerProps) {
const [logs, setLogs] = useState<LiveLogEntry[]>([]);
const [isPaused, setIsPaused] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [textFilter, setTextFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
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);
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);
}
);
closeConnectionRef.current = closeConnection;
// Don't set isConnected here - wait for onOpen callback
return () => {
closeConnection();
setIsConnected(false);
};
}, [filters, isPaused, maxLogs]);
// Handle auto-scroll
useEffect(() => {
if (shouldAutoScroll.current && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
// Track if user has manually scrolled
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
// If scrolled to bottom (within 50px), enable auto-scroll
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50;
}
};
const handleClear = () => {
setLogs([]);
};
const handleTogglePause = () => {
setIsPaused(!isPaused);
};
// Filter logs based on text and level
const filteredLogs = logs.filter((log) => {
if (textFilter && !log.message.toLowerCase().includes(textFilter.toLowerCase())) {
return false;
}
if (levelFilter && log.level.toLowerCase() !== levelFilter.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 */}
<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>
<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'
}`}
>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleTogglePause}
className="flex items-center gap-1"
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="flex items-center gap-1"
title="Clear logs"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filters */}
<div className="flex 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"
/>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(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 Levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
</div>
{/* Log display */}
<div
ref={logContainerRef}
onScroll={handleScroll}
className="h-96 overflow-y-auto p-3 font-mono text-xs bg-black"
style={{ scrollBehavior: 'smooth' }}
>
{filteredLogs.length === 0 && (
<div className="text-gray-500 text-center py-8">
{logs.length === 0 ? 'No logs yet. Waiting for events...' : 'No logs match the current filters.'}
</div>
)}
{filteredLogs.map((log, index) => (
<div key={index} className="mb-1 hover:bg-gray-900 px-1 -mx-1 rounded">
<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>}
<span className="ml-2 text-gray-200">{log.message}</span>
{log.data && Object.keys(log.data).length > 0 && (
<div className="ml-8 text-gray-400 text-xs">
{JSON.stringify(log.data, null, 2)}
</div>
)}
</div>
))}
</div>
{/* Footer with log count */}
<div className="p-2 border-t border-gray-700 bg-gray-800 text-xs text-gray-400 flex items-center justify-between">
<span>
Showing {filteredLogs.length} of {logs.length} logs
</span>
{isPaused && <span className="text-yellow-400"> Paused</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from './ui/Button';
import { Switch } from './ui/Switch';
import {
useSecurityNotificationSettings,
useUpdateSecurityNotificationSettings,
} from '../hooks/useNotifications';
interface SecurityNotificationSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SecurityNotificationSettingsModal({
isOpen,
onClose,
}: SecurityNotificationSettingsModalProps) {
const { data: settings, isLoading } = useSecurityNotificationSettings();
const updateMutation = useUpdateSecurityNotificationSettings();
const [formData, setFormData] = useState({
enabled: false,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: true,
webhook_url: '',
email_recipients: '',
});
useEffect(() => {
if (settings) {
setFormData({
enabled: settings.enabled,
min_log_level: settings.min_log_level,
notify_waf_blocks: settings.notify_waf_blocks,
notify_acl_denials: settings.notify_acl_denials,
notify_rate_limit_hits: settings.notify_rate_limit_hits,
webhook_url: settings.webhook_url || '',
email_recipients: settings.email_recipients || '',
});
}
}, [settings]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(formData, {
onSuccess: () => {
onClose();
},
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold text-white">Security Notification Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{isLoading && (
<div className="text-center text-gray-400">Loading settings...</div>
)}
{!isLoading && (
<>
{/* Master Toggle */}
<div className="flex items-center justify-between">
<div>
<label htmlFor="enable-notifications" className="text-sm font-medium text-white">Enable Notifications</label>
<p className="text-xs text-gray-400 mt-1">
Receive alerts when security events occur
</p>
</div>
<Switch
id="enable-notifications"
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
/>
</div>
{/* Minimum Log Level */}
<div>
<label htmlFor="min-log-level" className="block text-sm font-medium text-white mb-2">
Minimum Log Level
</label>
<select
id="min-log-level"
value={formData.min_log_level}
onChange={(e) => setFormData({ ...formData, min_log_level: e.target.value })}
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
>
<option value="debug">Debug (All logs)</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal (Critical only)</option>
</select>
<p className="text-xs text-gray-400 mt-1">
Only logs at this level or higher will trigger notifications
</p>
</div>
{/* Event Type Filters */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-white">Notify On:</h3>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-waf" className="text-sm text-white">WAF Blocks</label>
<p className="text-xs text-gray-400">
When the Web Application Firewall blocks a request
</p>
</div>
<Switch
id="notify-waf"
checked={formData.notify_waf_blocks}
onChange={(e) =>
setFormData({ ...formData, notify_waf_blocks: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-acl" className="text-sm text-white">ACL Denials</label>
<p className="text-xs text-gray-400">
When an IP is denied by Access Control Lists
</p>
</div>
<Switch
id="notify-acl"
checked={formData.notify_acl_denials}
onChange={(e) =>
setFormData({ ...formData, notify_acl_denials: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label htmlFor="notify-rate-limit" className="text-sm text-white">Rate Limit Hits</label>
<p className="text-xs text-gray-400">
When a client exceeds rate limiting thresholds
</p>
</div>
<Switch
id="notify-rate-limit"
checked={formData.notify_rate_limit_hits}
onChange={(e) =>
setFormData({ ...formData, notify_rate_limit_hits: e.target.checked })
}
disabled={!formData.enabled}
/>
</div>
</div>
{/* Webhook URL (optional, for future use) */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Webhook URL (Optional)
</label>
<input
type="url"
value={formData.webhook_url}
onChange={(e) => setFormData({ ...formData, webhook_url: e.target.value })}
placeholder="https://your-webhook-endpoint.com/alert"
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<p className="text-xs text-gray-400 mt-1">
POST requests will be sent to this URL when events occur
</p>
</div>
{/* Email Recipients (optional, for future use) */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Email Recipients (Optional)
</label>
<input
type="text"
value={formData.email_recipients}
onChange={(e) => setFormData({ ...formData, email_recipients: e.target.value })}
placeholder="admin@example.com, security@example.com"
disabled={!formData.enabled}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<p className="text-xs text-gray-400 mt-1">
Comma-separated email addresses
</p>
</div>
</>
)}
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
<Button variant="secondary" onClick={onClose} type="button">
Cancel
</Button>
<Button
variant="primary"
type="submit"
isLoading={updateMutation.isPending}
disabled={isLoading}
>
Save Settings
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -55,6 +55,8 @@ const renderWithProviders = (children: ReactNode) => {
describe('Layout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
localStorage.setItem('sidebarCollapsed', 'false')
// Default: all features enabled
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.cerberus.enabled': true,
@@ -148,6 +150,31 @@ describe('Layout', () => {
expect(toggleButton).toBeInTheDocument()
})
it('persists collapse state to localStorage', async () => {
localStorage.clear()
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
const collapseBtn = await screen.findByTitle('Collapse sidebar')
await userEvent.click(collapseBtn)
expect(JSON.parse(localStorage.getItem('sidebarCollapsed') || 'false')).toBe(true)
})
it('restores collapsed state from localStorage on load', async () => {
localStorage.setItem('sidebarCollapsed', 'true')
renderWithProviders(
<Layout>
<div>Test Content</div>
</Layout>
)
expect(await screen.findByTitle('Expand sidebar')).toBeInTheDocument()
})
describe('Feature Flags - Conditional Sidebar Items', () => {
it('displays Cerberus nav item when Cerberus is enabled', async () => {
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
@@ -255,7 +282,7 @@ describe('Layout', () => {
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue(undefined as any)
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({} as any)
renderWithProviders(
<Layout>

View File

@@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } 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
vi.mock('../../api/logs', async () => {
const actual = await vi.importActual('../../api/logs');
return {
...actual,
connectLiveLogs: vi.fn(),
};
});
describe('LiveLogViewer', () => {
let mockCloseConnection: ReturnType<typeof vi.fn>;
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
let mockOnClose: (() => void) | null;
beforeEach(() => {
mockCloseConnection = vi.fn();
mockOnMessage = 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;
});
});
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.getByRole('combobox');
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());
mockOnClose?.();
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
});
});

View File

@@ -0,0 +1,299 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import { SecurityNotificationSettingsModal } from '../SecurityNotificationSettingsModal';
import { createTestQueryClient } from '../../test/createTestQueryClient';
import * as notificationsApi from '../../api/notifications';
// Mock the API
vi.mock('../../api/notifications', async () => {
const actual = await vi.importActual('../../api/notifications');
return {
...actual,
getSecurityNotificationSettings: vi.fn(),
updateSecurityNotificationSettings: vi.fn(),
};
});
// Mock toast
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe('SecurityNotificationSettingsModal', () => {
const mockSettings: notificationsApi.SecurityNotificationSettings = {
enabled: true,
min_log_level: 'warn',
notify_waf_blocks: true,
notify_acl_denials: true,
notify_rate_limit_hits: false,
webhook_url: 'https://example.com/webhook',
email_recipients: 'admin@example.com',
};
let queryClient: ReturnType<typeof createTestQueryClient>;
beforeEach(() => {
queryClient = createTestQueryClient();
vi.clearAllMocks();
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue(mockSettings);
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockResolvedValue(mockSettings);
});
const renderModal = (isOpen = true, onClose = vi.fn()) => {
return render(
<QueryClientProvider client={queryClient}>
<SecurityNotificationSettingsModal isOpen={isOpen} onClose={onClose} />
</QueryClientProvider>
);
};
it('does not render when isOpen is false', () => {
renderModal(false);
expect(screen.queryByText('Security Notification Settings')).toBeFalsy();
});
it('renders the modal when isOpen is true', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
});
it('loads and displays existing settings', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
// Check that settings are loaded
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
expect(levelSelect.value).toBe('warn');
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(webhookInput.value).toBe('https://example.com/webhook');
});
it('closes modal when close button is clicked', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
const closeButton = screen.getByLabelText('Close');
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('closes modal when clicking outside', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
const { container } = renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Click on the backdrop
const backdrop = container.querySelector('.fixed.inset-0');
if (backdrop) {
await user.click(backdrop);
expect(mockOnClose).toHaveBeenCalled();
}
});
it('submits updated settings', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
// Change minimum log level
const levelSelect = screen.getByLabelText(/minimum log level/i);
await user.selectOptions(levelSelect, 'error');
// Change webhook URL
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i);
await user.clear(webhookInput);
await user.type(webhookInput, 'https://new-webhook.com');
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
min_log_level: 'error',
webhook_url: 'https://new-webhook.com',
})
);
});
// Modal should close on success
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('toggles notification enable/disable', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
});
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(true);
// Disable notifications
await user.click(enableSwitch);
await waitFor(() => {
expect(enableSwitch.checked).toBe(false);
});
});
it('disables controls when notifications are disabled', async () => {
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue({
...mockSettings,
enabled: false,
});
renderModal();
// Wait for settings to be loaded and form to render
await waitFor(() => {
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
expect(enableSwitch.checked).toBe(false);
});
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
expect(levelSelect.disabled).toBe(true);
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
expect(webhookInput.disabled).toBe(true);
});
it('toggles event type filters', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByText('WAF Blocks')).toBeTruthy();
});
// Find and toggle WAF blocks switch
const wafSwitch = screen.getByLabelText('WAF Blocks') as HTMLInputElement;
expect(wafSwitch.checked).toBe(true);
await user.click(wafSwitch);
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
notify_waf_blocks: false,
})
);
});
});
it('handles API errors gracefully', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockRejectedValue(
new Error('API Error')
);
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Submit form
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalled();
});
// Modal should NOT close on error
expect(mockOnClose).not.toHaveBeenCalled();
});
it('shows loading state', () => {
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockReturnValue(
new Promise(() => {}) // Never resolves
);
renderModal();
expect(screen.getByText('Loading settings...')).toBeTruthy();
});
it('handles email recipients input', async () => {
const user = userEvent.setup();
renderModal();
await waitFor(() => {
expect(screen.getByPlaceholderText(/admin@example.com/i)).toBeTruthy();
});
const emailInput = screen.getByPlaceholderText(/admin@example.com/i);
await user.clear(emailInput);
await user.type(emailInput, 'user1@test.com, user2@test.com');
const saveButton = screen.getByRole('button', { name: /save settings/i });
await user.click(saveButton);
await waitFor(() => {
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
expect.objectContaining({
email_recipients: 'user1@test.com, user2@test.com',
})
);
});
});
it('prevents modal content clicks from closing modal', async () => {
const user = userEvent.setup();
const mockOnClose = vi.fn();
renderModal(true, mockOnClose);
await waitFor(() => {
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
});
// Click inside the modal content
const modalContent = screen.getByText('Security Notification Settings');
await user.click(modalContent);
// Modal should not close
expect(mockOnClose).not.toHaveBeenCalled();
});
});

View File

@@ -6,10 +6,11 @@ interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, onCheckedChange, onChange, ...props }, ref) => {
({ className, onCheckedChange, onChange, id, ...props }, ref) => {
return (
<label className={cn("relative inline-flex items-center cursor-pointer", className)}>
<label htmlFor={id} className={cn("relative inline-flex items-center cursor-pointer", className)}>
<input
id={id}
type="checkbox"
className="sr-only peer"
ref={ref}