feat: production readiness improvements

## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -4,7 +4,7 @@
* 基于实际 API 端点: http://127.0.0.1:50051
*/
import { Page } from '@playwright/test';
import { Page, WebSocketRoute } from '@playwright/test';
/**
* Mock 响应数据模板 - 基于实际 API 响应格式
@@ -440,7 +440,7 @@ export async function setupMockGateway(
});
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
await page.route('**/api/hands/*/runs/*', async (route) => {
await page.route('**/api/hands/*/runs/**', async (route) => {
if (simulateDelay) await delay(delayMs);
const method = route.request().method();
const url = route.request().url();
@@ -468,6 +468,13 @@ export async function setupMockGateway(
completedAt: new Date().toISOString(),
}),
});
} else {
// Fallback for any other requests
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
});
}
});
@@ -481,6 +488,26 @@ export async function setupMockGateway(
});
});
// Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve
await page.route('**/api/hands/*/runs/*/approve', async (route) => {
if (simulateDelay) await delay(delayMs);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'approved' }),
});
});
// Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel
await page.route('**/api/hands/*/runs/*/cancel', async (route) => {
if (simulateDelay) await delay(delayMs);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'cancelled' }),
});
});
// ========================================
// Workflow 端点
// ========================================
@@ -777,6 +804,153 @@ export async function mockTimeout(page: Page, path: string): Promise<void> {
});
}
/**
* WebSocket Mock 配置
*/
export interface MockWebSocketConfig {
/** 模拟响应内容 */
responseContent?: string;
/** 是否模拟流式响应 */
streaming?: boolean;
/** 流式响应的块延迟 (ms) */
chunkDelay?: number;
/** 是否模拟错误 */
simulateError?: boolean;
/** 错误消息 */
errorMessage?: string;
}
/**
* 存储 WebSocket Mock 配置
*/
let wsConfig: MockWebSocketConfig = {
responseContent: 'This is a mock streaming response from the WebSocket server.',
streaming: true,
chunkDelay: 50,
};
/**
* 设置 WebSocket Mock 配置
*/
export function setWebSocketConfig(config: Partial<MockWebSocketConfig>): void {
wsConfig = { ...wsConfig, ...config };
}
/**
* Mock Agent WebSocket 流式响应
* 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接
*/
export async function mockAgentWebSocket(
page: Page,
config: Partial<MockWebSocketConfig> = {}
): Promise<void> {
const finalConfig = { ...wsConfig, ...config };
await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => {
// Handle incoming messages from the page
ws.onMessage(async (message) => {
try {
const data = JSON.parse(message);
// Handle chat message
if (data.type === 'message' || data.content) {
// Send connected event first
ws.send(JSON.stringify({
type: 'connected',
agent_id: 'default-agent',
}));
// Simulate error if configured
if (finalConfig.simulateError) {
ws.send(JSON.stringify({
type: 'error',
message: finalConfig.errorMessage || 'Mock WebSocket error',
}));
ws.close({ code: 1011, reason: 'Error' });
return;
}
const responseText = finalConfig.responseContent || 'Mock response';
if (finalConfig.streaming) {
// Send typing indicator
ws.send(JSON.stringify({
type: 'typing',
state: 'start',
}));
// Stream response in chunks
const words = responseText.split(' ');
let current = '';
for (let i = 0; i < words.length; i++) {
current += (current ? ' ' : '') + words[i];
// Send text delta every few words
if (current.length >= 10 || i === words.length - 1) {
await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50));
ws.send(JSON.stringify({
type: 'text_delta',
content: current,
}));
current = '';
}
}
// Send typing stop
ws.send(JSON.stringify({
type: 'typing',
state: 'stop',
}));
// Send phase done
ws.send(JSON.stringify({
type: 'phase',
phase: 'done',
}));
} else {
// Non-streaming response
ws.send(JSON.stringify({
type: 'response',
content: responseText,
input_tokens: 100,
output_tokens: responseText.split(' ').length,
}));
}
// Close connection after response
ws.close({ code: 1000, reason: 'Stream complete' });
}
} catch (err) {
console.error('WebSocket mock error:', err);
ws.send(JSON.stringify({
type: 'error',
message: 'Failed to parse message',
}));
}
});
// Handle connection close from page
ws.onClose(() => {
// Clean up
});
});
}
/**
* 设置完整的 Gateway Mock (包括 WebSocket)
*/
export async function setupMockGatewayWithWebSocket(
page: Page,
config: MockGatewayConfig & { wsConfig?: Partial<MockWebSocketConfig> } = {}
): Promise<void> {
// Setup HTTP mocks
await setupMockGateway(page, config);
// Setup WebSocket mock
await mockAgentWebSocket(page, config.wsConfig || {});
}
// 辅助函数
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -68,8 +68,23 @@ export const storeInspectors = {
/**
* 获取持久化的 Chat Store 状态
* 优先从运行时获取,如果不可用则从 localStorage 获取
*/
async getChatState<T = unknown>(page: Page): Promise<T | null> {
// First try to get runtime state (more reliable for E2E tests)
const runtimeState = await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores && stores.chat) {
return stores.chat.getState() as T;
}
return null;
});
if (runtimeState) {
return runtimeState;
}
// Fallback to localStorage
return page.evaluate((key) => {
const stored = localStorage.getItem(key);
if (!stored) return null;

View File

@@ -15,6 +15,7 @@ import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorRes
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
import { networkHelpers } from '../utils/network-helpers';
import { setupMockGatewayWithWebSocket, setWebSocketConfig } from '../fixtures/mock-gateway';
// Test configuration
test.setTimeout(120000);
@@ -168,16 +169,15 @@ test.describe('Chat Message Tests', () => {
test.describe.configure({ mode: 'parallel' }); // Parallel for isolation
test('CHAT-MSG-01: Send message and receive response', async ({ page }) => {
// Setup mock gateway
await setupMockGateway(page);
// Setup mock gateway with WebSocket support
const mockResponse = 'This is a mock AI response for testing purposes.';
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: mockResponse, streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Mock agent message response
const mockResponse = 'This is a mock AI response for testing purposes.';
await mockAgentMessageResponse(page, mockResponse);
// Find chat input
const chatInput = page.locator('textarea').first();
await expect(chatInput).toBeVisible({ timeout: 10000 });
@@ -209,12 +209,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Store state test response', streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Store state test response');
// Clear any existing messages first
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
@@ -243,12 +244,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Streaming test response with longer content', streaming: true, chunkDelay: 100 }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Streaming test response with longer content');
const chatInput = page.locator('textarea').first();
await chatInput.fill('Write a short poem');
@@ -276,8 +278,10 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => {
// Setup fresh page with error mock
await mockErrorResponse(page, 'health', 500, 'Internal Server Error');
// Setup fresh page with error mock - WebSocket will simulate error
await setupMockGatewayWithWebSocket(page, {
wsConfig: { simulateError: true, errorMessage: 'WebSocket connection failed' }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
@@ -294,12 +298,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Response to sequential message', streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Response to sequential message');
// Clear existing messages
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);

View File

@@ -0,0 +1,500 @@
/**
* Security Module Tests
*
* Unit tests for crypto utilities, security utils, and audit logging.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// ============================================================================
// Crypto Utils Tests
// ============================================================================
describe('Crypto Utils', () => {
// Import dynamically to handle Web Crypto API
let cryptoUtils: typeof import('../../src/lib/crypto-utils');
beforeEach(async () => {
cryptoUtils = await import('../../src/lib/crypto-utils');
});
describe('arrayToBase64 / base64ToArray', () => {
it('should convert Uint8Array to base64 and back', () => {
const original = new Uint8Array([72, 101, 108, 108, 111]);
const base64 = cryptoUtils.arrayToBase64(original);
const decoded = cryptoUtils.base64ToArray(base64);
expect(decoded).toEqual(original);
});
it('should handle empty array', () => {
const empty = new Uint8Array([]);
expect(cryptoUtils.arrayToBase64(empty)).toBe('');
expect(cryptoUtils.base64ToArray('')).toEqual(empty);
});
});
describe('deriveKey', () => {
it('should derive a CryptoKey from a master key', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
expect(key).toBeDefined();
expect(key.type).toBe('secret');
});
it('should derive the same key for the same input', async () => {
const key1 = await cryptoUtils.deriveKey('test-master-key');
const key2 = await cryptoUtils.deriveKey('test-master-key');
// Both should be valid CryptoKey objects
expect(key1).toBeDefined();
expect(key2).toBeDefined();
});
it('should use custom salt', async () => {
const customSalt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const key = await cryptoUtils.deriveKey('test-master-key', customSalt);
expect(key).toBeDefined();
});
});
describe('encrypt / decrypt', () => {
it('should encrypt and decrypt a string', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
const plaintext = 'Hello, World!';
const encrypted = await cryptoUtils.encrypt(plaintext, key);
expect(encrypted.iv).toBeDefined();
expect(encrypted.data).toBeDefined();
expect(encrypted.version).toBe(1);
const decrypted = await cryptoUtils.decrypt(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('should produce different IVs for same plaintext', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
const plaintext = 'Same text';
const encrypted1 = await cryptoUtils.encrypt(plaintext, key);
const encrypted2 = await cryptoUtils.encrypt(plaintext, key);
expect(encrypted1.iv).not.toBe(encrypted2.iv);
});
it('should handle empty string', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
const plaintext = '';
const encrypted = await cryptoUtils.encrypt(plaintext, key);
const decrypted = await cryptoUtils.decrypt(encrypted, key);
expect(decrypted).toBe('');
});
it('should handle unicode characters', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
const plaintext = '中文测试 日本語 한국어 🎉';
const encrypted = await cryptoUtils.encrypt(plaintext, key);
const decrypted = await cryptoUtils.decrypt(encrypted, key);
expect(decrypted).toBe(plaintext);
});
});
describe('encryptObject / decryptObject', () => {
it('should encrypt and decrypt an object', async () => {
const key = await cryptoUtils.deriveKey('test-master-key');
const obj = { name: 'test', count: 42, nested: { a: 1 } };
const encrypted = await cryptoUtils.encryptObject(obj, key);
const decrypted = await cryptoUtils.decryptObject<typeof obj>(encrypted, key);
expect(decrypted).toEqual(obj);
});
});
describe('generateMasterKey', () => {
it('should generate a base64 string', () => {
const key = cryptoUtils.generateMasterKey();
expect(typeof key).toBe('string');
expect(key.length).toBeGreaterThan(0);
});
it('should generate unique keys', () => {
const key1 = cryptoUtils.generateMasterKey();
const key2 = cryptoUtils.generateMasterKey();
expect(key1).not.toBe(key2);
});
});
describe('hashSha256', () => {
it('should produce consistent hash', async () => {
const input = 'test-input';
const hash1 = await cryptoUtils.hashSha256(input);
const hash2 = await cryptoUtils.hashSha256(input);
expect(hash1).toBe(hash2);
expect(hash1.length).toBe(64); // SHA-256 produces 64 hex chars
});
});
describe('constantTimeEqual', () => {
it('should return true for equal arrays', () => {
const a = new Uint8Array([1, 2, 3, 4]);
const b = new Uint8Array([1, 2, 3, 4]);
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(true);
});
it('should return false for different arrays', () => {
const a = new Uint8Array([1, 2, 3, 4]);
const b = new Uint8Array([1, 2, 3, 5]);
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
});
it('should return false for different length arrays', () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 3, 4]);
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
});
});
describe('isValidEncryptedData', () => {
it('should validate correct structure', () => {
const valid = { iv: 'abc', data: 'xyz' };
expect(cryptoUtils.isValidEncryptedData(valid)).toBe(true);
});
it('should reject invalid structures', () => {
expect(cryptoUtils.isValidEncryptedData(null)).toBe(false);
expect(cryptoUtils.isValidEncryptedData({})).toBe(false);
expect(cryptoUtils.isValidEncryptedData({ iv: '' })).toBe(false);
expect(cryptoUtils.isValidEncryptedData({ data: '' })).toBe(false);
});
});
});
// ============================================================================
// Security Utils Tests
// ============================================================================
describe('Security Utils', () => {
let securityUtils: typeof import('../security-utils');
beforeEach(async () => {
securityUtils = await import('../security-utils');
});
describe('escapeHtml', () => {
it('should escape HTML special characters', () => {
const input = '<script>alert("xss")</script>';
const escaped = securityUtils.escapeHtml(input);
expect(escaped).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt;');
});
it('should handle plain text', () => {
const input = 'Hello, World!';
expect(securityUtils.escapeHtml(input)).toBe(input);
});
it('should handle ampersand', () => {
expect(securityUtils.escapeHtml('a & b')).toBe('a &amp; b');
});
});
describe('sanitizeHtml', () => {
it('should remove script tags', () => {
const html = '<p>Hello</p><script>alert("xss")</script>';
const sanitized = securityUtils.sanitizeHtml(html);
expect(sanitized).not.toContain('<script>');
expect(sanitized).toContain('<p>');
});
it('should remove event handlers', () => {
const html = '<div onclick="alert(1)">Click me</div>';
const sanitized = securityUtils.sanitizeHtml(html);
expect(sanitized).not.toContain('onclick');
});
it('should remove javascript: URLs', () => {
const html = '<a href="javascript:alert(1)">Link</a>';
const sanitized = securityUtils.sanitizeHtml(html);
expect(sanitized).not.toContain('javascript:');
});
it('should preserve allowed tags', () => {
const html = '<p><strong>Bold</strong> and <em>italic</em></p>';
const sanitized = securityUtils.sanitizeHtml(html);
expect(sanitized).toContain('<p>');
expect(sanitized).toContain('<strong>');
expect(sanitized).toContain('<em>');
});
});
describe('validateUrl', () => {
it('should validate http URLs', () => {
const url = 'https://example.com/path';
expect(securityUtils.validateUrl(url)).toBe(url);
});
it('should reject javascript: URLs', () => {
expect(securityUtils.validateUrl('javascript:alert(1)')).toBeNull();
});
it('should reject data: URLs by default', () => {
expect(securityUtils.validateUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
});
it('should reject localhost when not allowed', () => {
expect(
securityUtils.validateUrl('http://localhost:3000', { allowLocalhost: false })
).toBeNull();
});
it('should allow localhost when allowed', () => {
const url = 'http://localhost:3000';
expect(
securityUtils.validateUrl(url, { allowLocalhost: true })
).toBe(url);
});
});
describe('validatePath', () => {
it('should reject path traversal', () => {
expect(securityUtils.validatePath('../../../etc/passwd')).toBeNull();
});
it('should reject null bytes', () => {
expect(securityUtils.validatePath('file\0.txt')).toBeNull();
});
it('should reject absolute paths when not allowed', () => {
expect(
securityUtils.validatePath('/etc/passwd', { allowAbsolute: false })
).toBeNull();
});
it('should validate relative paths', () => {
const result = securityUtils.validatePath('folder/file.txt');
expect(result).toBe('folder/file.txt');
});
});
describe('isValidEmail', () => {
it('should validate correct emails', () => {
expect(securityUtils.isValidEmail('test@example.com')).toBe(true);
expect(securityUtils.isValidEmail('user.name@subdomain.example.com')).toBe(true);
});
it('should reject invalid emails', () => {
expect(securityUtils.isValidEmail('not-an-email')).toBe(false);
expect(securityUtils.isValidEmail('@example.com')).toBe(false);
expect(securityUtils.isValidEmail('test@')).toBe(false);
});
});
describe('validatePasswordStrength', () => {
it('should accept strong passwords', () => {
const result = securityUtils.validatePasswordStrength('Str0ng!Pass');
expect(result.valid).toBe(true);
expect(result.score).toBeGreaterThan(50);
});
it('should reject short passwords', () => {
const result = securityUtils.validatePasswordStrength('short');
expect(result.valid).toBe(false);
expect(result.issues).toContain('Password must be at least 8 characters');
});
it('should detect weak patterns', () => {
const result = securityUtils.validatePasswordStrength('password123');
expect(result.issues).toContain('Password contains a common pattern');
});
});
describe('sanitizeFilename', () => {
it('should remove path separators', () => {
expect(securityUtils.sanitizeFilename('../test.txt')).toBe('.._test.txt');
});
it('should remove dangerous characters', () => {
const sanitized = securityUtils.sanitizeFilename('file<>:"|?*.txt');
expect(sanitized).not.toMatch(/[<>:"|?*]/);
});
it('should handle normal filenames', () => {
expect(securityUtils.sanitizeFilename('document.pdf')).toBe('document.pdf');
});
});
describe('sanitizeJson', () => {
it('should parse valid JSON', () => {
const result = securityUtils.sanitizeJson('{"name":"test"}');
expect(result).toEqual({ name: 'test' });
});
it('should remove prototype pollution keys', () => {
const result = securityUtils.sanitizeJson('{"__proto__":{"admin":true},"name":"test"}');
expect(result).not.toHaveProperty('__proto__');
});
it('should return null for invalid JSON', () => {
expect(securityUtils.sanitizeJson('not json')).toBeNull();
});
});
describe('isRateLimited', () => {
beforeEach(() => {
// Clear rate limit store
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should allow first request', () => {
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
});
it('should block after limit reached', () => {
for (let i = 0; i < 5; i++) {
securityUtils.isRateLimited('test-key', 5, 60000);
}
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
});
it('should reset after window expires', () => {
for (let i = 0; i < 5; i++) {
securityUtils.isRateLimited('test-key', 5, 60000);
}
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
// Advance time past window
vi.advanceTimersByTime(61000);
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
});
});
describe('generateSecureToken', () => {
it('should generate hex string of correct length', () => {
const token = securityUtils.generateSecureToken(16);
expect(token.length).toBe(32); // 16 bytes = 32 hex chars
});
it('should generate unique tokens', () => {
const token1 = securityUtils.generateSecureToken();
const token2 = securityUtils.generateSecureToken();
expect(token1).not.toBe(token2);
});
});
describe('generateSecureId', () => {
it('should generate ID with prefix', () => {
const id = securityUtils.generateSecureId('user');
expect(id.startsWith('user_')).toBe(true);
});
it('should generate ID without prefix', () => {
const id = securityUtils.generateSecureId();
expect(id).toMatch(/^\w+_\w+$/);
});
});
});
// ============================================================================
// Security Audit Tests
// ============================================================================
describe('Security Audit', () => {
let securityAudit: typeof import('../security-audit');
beforeEach(async () => {
securityAudit = await import('../security-audit');
localStorage.clear();
});
afterEach(() => {
securityAudit.clearSecurityAuditLog();
});
describe('logSecurityEvent', () => {
it('should log security events', () => {
securityAudit.logSecurityEvent('auth_login', 'User logged in', { userId: '123' });
const events = securityAudit.getSecurityEvents();
expect(events.length).toBe(1);
expect(events[0].type).toBe('auth_login');
expect(events[0].message).toBe('User logged in');
});
it('should determine severity automatically', () => {
securityAudit.logSecurityEvent('security_violation', 'Test violation');
securityAudit.logSecurityEvent('auth_login', 'Test login');
const events = securityAudit.getSecurityEvents();
expect(events[0].severity).toBe('critical');
expect(events[1].severity).toBe('info');
});
});
describe('getSecurityEventsByType', () => {
it('should filter events by type', () => {
securityAudit.logSecurityEvent('auth_login', 'Login 1');
securityAudit.logSecurityEvent('auth_logout', 'Logout');
securityAudit.logSecurityEvent('auth_login', 'Login 2');
const loginEvents = securityAudit.getSecurityEventsByType('auth_login');
expect(loginEvents.length).toBe(2);
});
});
describe('getSecurityEventsBySeverity', () => {
it('should filter events by severity', () => {
securityAudit.logSecurityEvent('auth_login', 'Login', {}, 'info');
securityAudit.logSecurityEvent('auth_failed', 'Failed', {}, 'warning');
const infoEvents = securityAudit.getSecurityEventsBySeverity('info');
expect(infoEvents.length).toBe(1);
});
});
describe('generateSecurityAuditReport', () => {
it('should generate a report', () => {
securityAudit.logSecurityEvent('auth_login', 'Login');
securityAudit.logSecurityEvent('auth_failed', 'Failed');
securityAudit.logSecurityEvent('security_violation', 'Violation');
const report = securityAudit.generateSecurityAuditReport();
expect(report.totalEvents).toBe(3);
expect(report.eventsByType.auth_login).toBe(1);
expect(report.eventsBySeverity.critical).toBe(1);
});
});
describe('setAuditEnabled', () => {
it('should disable logging when set to false', () => {
securityAudit.setAuditEnabled(false);
securityAudit.logSecurityEvent('auth_login', 'Should not log');
securityAudit.setAuditEnabled(true);
const events = securityAudit.getSecurityEvents();
// Only the config_changed event should be logged
const loginEvents = events.filter(e => e.type === 'auth_login');
expect(loginEvents.length).toBe(0);
});
});
});