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:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
500
desktop/tests/lib/security.test.ts
Normal file
500
desktop/tests/lib/security.test.ts
Normal 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('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
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 & 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user