import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import { credentialService } from '@/lib/services/credential-service' describe('CredentialService', () => { // Store original env values const originalEnv = { ...process.env } beforeAll(() => { // Set up test encryption keys process.env.CREDENTIAL_ENCRYPTION_KEY = 'test-credential-encryption-key!!' process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars-long' }) afterAll(() => { // Restore original env process.env = originalEnv }) describe('encrypt', () => { it('should encrypt plaintext to iv:ciphertext format', () => { const plaintext = 'my-secret-password' const encrypted = credentialService.encrypt(plaintext) // Should have iv:ciphertext format expect(encrypted).toContain(':') const [iv, ciphertext] = encrypted.split(':') // IV should be 32 hex chars (16 bytes) expect(iv).toHaveLength(32) expect(iv).toMatch(/^[0-9a-f]+$/) // Ciphertext should be hex encoded expect(ciphertext).toMatch(/^[0-9a-f]+$/) }) it('should produce different ciphertexts for same plaintext (random IV)', () => { const plaintext = 'same-password' const encrypted1 = credentialService.encrypt(plaintext) const encrypted2 = credentialService.encrypt(plaintext) // Different IVs mean different ciphertexts expect(encrypted1).not.toBe(encrypted2) }) it('should handle empty strings', () => { const encrypted = credentialService.encrypt('') expect(encrypted).toContain(':') // Empty string still produces ciphertext (padding) const [, ciphertext] = encrypted.split(':') expect(ciphertext.length).toBeGreaterThan(0) }) it('should handle special characters', () => { const plaintext = 'p@$$w0rd!#$%^&*()_+-=[]{}|;:,.<>?' const encrypted = credentialService.encrypt(plaintext) const decrypted = credentialService.decrypt(encrypted) expect(decrypted).toBe(plaintext) }) it('should handle unicode characters', () => { const plaintext = '密码123🔐' const encrypted = credentialService.encrypt(plaintext) const decrypted = credentialService.decrypt(encrypted) expect(decrypted).toBe(plaintext) }) it('should handle very long strings', () => { const plaintext = 'a'.repeat(10000) const encrypted = credentialService.encrypt(plaintext) const decrypted = credentialService.decrypt(encrypted) expect(decrypted).toBe(plaintext) }) }) describe('decrypt', () => { it('should decrypt ciphertext to original plaintext', () => { const plaintext = 'my-secret-password' const encrypted = credentialService.encrypt(plaintext) const decrypted = credentialService.decrypt(encrypted) expect(decrypted).toBe(plaintext) }) it('should throw on invalid format (no colon)', () => { expect(() => credentialService.decrypt('invalid-no-colon')).toThrow( 'Invalid ciphertext format' ) }) it('should throw on invalid format (empty parts)', () => { expect(() => credentialService.decrypt(':encrypted')).toThrow( 'Invalid ciphertext format' ) expect(() => credentialService.decrypt('iv:')).toThrow( 'Invalid ciphertext format' ) }) it('should throw on invalid hex in IV', () => { expect(() => credentialService.decrypt('not-hex:abcdef')).toThrow() }) it('should throw on tampered ciphertext', () => { const encrypted = credentialService.encrypt('secret') // Tamper with the ciphertext const [iv, ciphertext] = encrypted.split(':') const tampered = `${iv}:ff${ciphertext.slice(2)}` expect(() => credentialService.decrypt(tampered)).toThrow() }) it('should throw on wrong key', () => { // Encrypt with current key const encrypted = credentialService.encrypt('secret') // Change the key const originalKey = process.env.CREDENTIAL_ENCRYPTION_KEY process.env.CREDENTIAL_ENCRYPTION_KEY = 'different-key-32-characters!!!!!' // Try to decrypt - should fail due to key mismatch expect(() => credentialService.decrypt(encrypted)).toThrow() // Restore process.env.CREDENTIAL_ENCRYPTION_KEY = originalKey }) }) describe('isConfigured', () => { it('should return true when CREDENTIAL_ENCRYPTION_KEY is set', () => { process.env.CREDENTIAL_ENCRYPTION_KEY = 'some-key' expect(credentialService.isConfigured()).toBe(true) }) it('should return false when CREDENTIAL_ENCRYPTION_KEY is not set', () => { const original = process.env.CREDENTIAL_ENCRYPTION_KEY delete process.env.CREDENTIAL_ENCRYPTION_KEY expect(credentialService.isConfigured()).toBe(false) process.env.CREDENTIAL_ENCRYPTION_KEY = original }) }) describe('decryptLegacy', () => { it('should decrypt values encrypted with legacy ENCRYPTION_KEY', () => { // Create a value encrypted with legacy format // Legacy uses ENCRYPTION_KEY with 'salt' as the scrypt salt const crypto = require('crypto') const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32) const iv = crypto.randomBytes(16) const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv) let encrypted = cipher.update('legacy-secret', 'utf8', 'hex') encrypted += cipher.final('hex') const legacyCiphertext = `${iv.toString('hex')}:${encrypted}` const decrypted = credentialService.decryptLegacy(legacyCiphertext) expect(decrypted).toBe('legacy-secret') }) it('should throw on invalid format', () => { expect(() => credentialService.decryptLegacy('invalid')).toThrow( 'Invalid ciphertext format' ) }) }) describe('migrateFromLegacy', () => { it('should re-encrypt from legacy format to new format', () => { // Create legacy encrypted value const crypto = require('crypto') const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32) const iv = crypto.randomBytes(16) const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv) let encrypted = cipher.update('migrate-me', 'utf8', 'hex') encrypted += cipher.final('hex') const legacyCiphertext = `${iv.toString('hex')}:${encrypted}` // Migrate to new format const newCiphertext = credentialService.migrateFromLegacy(legacyCiphertext) // Should be decryptable with new format const decrypted = credentialService.decrypt(newCiphertext) expect(decrypted).toBe('migrate-me') // Should NOT be decryptable with legacy format (different key derivation) expect(newCiphertext).not.toBe(legacyCiphertext) }) }) describe('isLegacyConfigured', () => { it('should return true when ENCRYPTION_KEY is set', () => { process.env.ENCRYPTION_KEY = 'some-legacy-key' expect(credentialService.isLegacyConfigured()).toBe(true) }) it('should return false when ENCRYPTION_KEY is not set', () => { const original = process.env.ENCRYPTION_KEY delete process.env.ENCRYPTION_KEY expect(credentialService.isLegacyConfigured()).toBe(false) process.env.ENCRYPTION_KEY = original }) }) describe('error handling without keys', () => { it('should throw when encrypting without CREDENTIAL_ENCRYPTION_KEY', () => { const original = process.env.CREDENTIAL_ENCRYPTION_KEY delete process.env.CREDENTIAL_ENCRYPTION_KEY expect(() => credentialService.encrypt('test')).toThrow( 'CREDENTIAL_ENCRYPTION_KEY environment variable is required' ) process.env.CREDENTIAL_ENCRYPTION_KEY = original }) it('should throw when decrypting legacy without ENCRYPTION_KEY', () => { const original = process.env.ENCRYPTION_KEY delete process.env.ENCRYPTION_KEY expect(() => credentialService.decryptLegacy('abc:def')).toThrow( 'ENCRYPTION_KEY environment variable is required for legacy decryption' ) process.env.ENCRYPTION_KEY = original }) }) describe('round-trip encryption', () => { it('should successfully round-trip various data types', () => { const testCases = [ 'simple-password', 'with spaces and tabs\t', 'multi\nline\nstring', JSON.stringify({ user: 'admin', pass: 'secret123' }), '12345', '!@#$%^&*()', ] for (const plaintext of testCases) { const encrypted = credentialService.encrypt(plaintext) const decrypted = credentialService.decrypt(encrypted) expect(decrypted).toBe(plaintext) } }) }) })