/** * 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(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 = ''; 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 = '

Hello

'; const sanitized = securityUtils.sanitizeHtml(html); expect(sanitized).not.toContain('')).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); }); }); });