262 lines
8.6 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|
|
})
|