/** * Unit tests for EncryptionService — AES-256-CBC column-level encryption. * * Tests: * 1. Encrypt/decrypt round-trip returns original plaintext * 2. isEncrypted: true for base64:base64 format, false for plaintext strings * 3. encryptColumn produces different ciphertext on each call (IV randomness) * 4. Singleton reset utility works for test isolation */ import { EncryptionService, getEncryptionService, _resetEncryptionServiceSingleton, } from '../../../src/services/EncryptionService'; import { VaultClient } from '../../../src/vault/VaultClient'; // ============================================================================ // Mock VaultClient // ============================================================================ /** A 32-byte (64-char hex) test encryption key. */ const TEST_KEY = 'a'.repeat(64); // 64 x 'a' = valid 32-byte hex key /** * Creates a mock VaultClient that returns TEST_KEY from readArbitrarySecret. */ function makeMockVaultClient(): VaultClient { const mock = { readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: TEST_KEY }), writeArbitrarySecret: jest.fn().mockResolvedValue(undefined), writeSecret: jest.fn(), readSecret: jest.fn(), verifySecret: jest.fn(), deleteSecret: jest.fn(), }; return mock as unknown as VaultClient; } // ============================================================================ // Tests // ============================================================================ describe('EncryptionService', () => { let service: EncryptionService; let mockVaultClient: VaultClient; beforeEach(() => { _resetEncryptionServiceSingleton(); mockVaultClient = makeMockVaultClient(); service = new EncryptionService(mockVaultClient); }); afterEach(() => { _resetEncryptionServiceSingleton(); }); // ── Round-trip ──────────────────────────────────────────────────────────── it('should encrypt and then decrypt back to the original plaintext', async () => { const plaintext = 'super-secret-credential-hash-value'; const encrypted = await service.encryptColumn(plaintext); expect(encrypted).not.toBe(plaintext); expect(encrypted).toContain(':'); const decrypted = await service.decryptColumn(encrypted); expect(decrypted).toBe(plaintext); }); it('should handle empty string round-trip', async () => { const plaintext = ''; const encrypted = await service.encryptColumn(plaintext); const decrypted = await service.decryptColumn(encrypted); expect(decrypted).toBe(plaintext); }); it('should handle unicode strings in round-trip', async () => { const plaintext = 'secret/data/agentidp/agents/über-agent/credentials/cred-123'; const encrypted = await service.encryptColumn(plaintext); const decrypted = await service.decryptColumn(encrypted); expect(decrypted).toBe(plaintext); }); // ── IV randomness ───────────────────────────────────────────────────────── it('should produce different ciphertext on each call (random IV)', async () => { const plaintext = 'same-plaintext-value'; const encrypted1 = await service.encryptColumn(plaintext); const encrypted2 = await service.encryptColumn(plaintext); // Same plaintext but different IV → different ciphertext expect(encrypted1).not.toBe(encrypted2); // Both must still decrypt to the same plaintext expect(await service.decryptColumn(encrypted1)).toBe(plaintext); expect(await service.decryptColumn(encrypted2)).toBe(plaintext); }); // ── isEncrypted ────────────────────────────────────────────────────────── it('should return true for a value in base64:base64 format', async () => { const encrypted = await service.encryptColumn('test-value'); expect(service.isEncrypted(encrypted)).toBe(true); }); it('should return false for a plaintext bcrypt hash', () => { const bcryptHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; expect(service.isEncrypted(bcryptHash)).toBe(false); }); it('should return false for a Vault path string', () => { expect(service.isEncrypted('secret/data/agentidp/agents/abc/credentials/xyz')).toBe(false); }); it('should return false for an empty string', () => { expect(service.isEncrypted('')).toBe(false); }); it('should return false for a plain UUID', () => { expect(service.isEncrypted('550e8400-e29b-41d4-a716-446655440000')).toBe(false); }); it('should return true for a manually constructed base64:base64 string', () => { const iv = Buffer.from('deadbeef12345678', 'hex').toString('base64'); const ct = Buffer.from('cafebabe00112233', 'hex').toString('base64'); expect(service.isEncrypted(`${iv}:${ct}`)).toBe(true); }); // ── Vault key fetching ──────────────────────────────────────────────────── it('should call Vault readArbitrarySecret once and cache the key', async () => { const plaintext = 'value1'; await service.encryptColumn(plaintext); await service.encryptColumn(plaintext); await service.encryptColumn(plaintext); // Key should be fetched only once expect( (mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls.length, ).toBe(1); }); it('should use the ENCRYPTION_KEY_VAULT_PATH env var for the Vault path', async () => { const originalPath = process.env['ENCRYPTION_KEY_VAULT_PATH']; process.env['ENCRYPTION_KEY_VAULT_PATH'] = 'secret/data/custom/path'; const freshService = new EncryptionService(mockVaultClient); await freshService.encryptColumn('test'); expect( (mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls[0][0], ).toBe('secret/data/custom/path'); // Restore env if (originalPath === undefined) { delete process.env['ENCRYPTION_KEY_VAULT_PATH']; } else { process.env['ENCRYPTION_KEY_VAULT_PATH'] = originalPath; } }); // ── Error handling ──────────────────────────────────────────────────────── it('should throw when ciphertext has no colon separator', async () => { await expect(service.decryptColumn('invalidformat')).rejects.toThrow( 'Invalid encrypted column format', ); }); it('should throw when Vault returns an invalid key', async () => { const badVault = { readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: 'tooshort' }), } as unknown as VaultClient; const badService = new EncryptionService(badVault); await expect(badService.encryptColumn('test')).rejects.toThrow( 'expected a 64-character hex string', ); }); // ── Singleton ───────────────────────────────────────────────────────────── it('getEncryptionService should return the same instance on repeated calls', () => { const instance1 = getEncryptionService(mockVaultClient); const instance2 = getEncryptionService(mockVaultClient); expect(instance1).toBe(instance2); }); });