letsbe-hub/src/__tests__/unit/lib/services/credential-service.test.ts

262 lines
8.6 KiB
TypeScript

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)
}
})
})
})