Files
zclaw_openfang/desktop/tests/lib/security.test.ts
iven 3ff08faa56 release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements
## Major Features

### Streaming Response System
- Implement LlmDriver trait with `stream()` method returning async Stream
- Add SSE parsing for Anthropic and OpenAI API streaming
- Integrate Tauri event system for frontend streaming (`stream:chunk` events)
- Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error

### MCP Protocol Implementation
- Add MCP JSON-RPC 2.0 types (mcp_types.rs)
- Implement stdio-based MCP transport (mcp_transport.rs)
- Support tool discovery, execution, and resource operations

### Browser Hand Implementation
- Complete browser automation with Playwright-style actions
- Support Navigate, Click, Type, Scrape, Screenshot, Wait actions
- Add educational Hands: Whiteboard, Slideshow, Speech, Quiz

### Security Enhancements
- Implement command whitelist/blacklist for shell_exec tool
- Add SSRF protection with private IP blocking
- Create security.toml configuration file

## Test Improvements
- Fix test import paths (security-utils, setup)
- Fix vi.mock hoisting issues with vi.hoisted()
- Update test expectations for validateUrl and sanitizeFilename
- Add getUnsupportedLocalGatewayStatus mock

## Documentation Updates
- Update architecture documentation
- Improve configuration reference
- Add quick-start guide updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 03:24:24 +08:00

503 lines
17 KiB
TypeScript

/**
* 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('../../src/lib/security-utils');
beforeEach(async () => {
securityUtils = await import('../../src/lib/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';
const result = securityUtils.validateUrl(url, { allowLocalhost: true });
// URL.toString() may add trailing slash
expect(result).not.toBeNull();
expect(result?.startsWith('http://localhost:3000')).toBe(true);
});
});
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', () => {
// Path separators are replaced with _, and leading dots are trimmed to prevent hidden files
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('../../src/lib/security-audit');
beforeEach(async () => {
securityAudit = await import('../../src/lib/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);
});
});
});