godcrm/backend/utils/__tests__/encryption.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

213 lines
7.5 KiB
JavaScript

// RED Phase: Tests for Data Source Credential Encryption (ADR-064, Task 5)
// TDD: Write tests FIRST, then implement
// Set test env BEFORE imports
process.env.TEST_MODE = 'true';
process.env.NODE_ENV = 'test';
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
// We will test the encryption module at backend/utils/encryption.js
// Tests written BEFORE implementation (TDD Red Phase)
describe('Data Source Credential Encryption (ADR-064)', () => {
// Store original env
const originalEnv = { ...process.env };
afterEach(() => {
// Restore env
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
describe('encryptCredential', () => {
beforeEach(() => {
// Set a valid 32-byte hex key for AES-256
process.env.MASTER_ENCRYPTION_KEY = 'a]3Fj9$kL2mN7pQ4rS6tU8vW0xY1zA5b';
});
test('should encrypt password before storing in DB', async () => {
const { encryptCredential } = await import('../encryption.js');
const plainPassword = 'my_super_secret_db_password';
const encrypted = encryptCredential(plainPassword);
// Encrypted result should not equal the plain password
expect(encrypted).not.toBe(plainPassword);
// Encrypted result should be a non-empty string
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
// Should contain the iv:authTag:ciphertext format
expect(encrypted.split(':')).toHaveLength(3);
});
test('should decrypt password when establishing connection', async () => {
const { encryptCredential, decryptCredential } = await import('../encryption.js');
const plainPassword = 'my_super_secret_db_password';
const encrypted = encryptCredential(plainPassword);
const decrypted = decryptCredential(encrypted);
expect(decrypted).toBe(plainPassword);
});
test('should use AES-256-GCM with unique IV per credential', async () => {
const { encryptCredential } = await import('../encryption.js');
const plainPassword = 'same_password_encrypted_twice';
// Encrypt the same password twice
const encrypted1 = encryptCredential(plainPassword);
const encrypted2 = encryptCredential(plainPassword);
// Different ciphertext each time (unique IV)
expect(encrypted1).not.toBe(encrypted2);
// Extract IVs - they should be different
const iv1 = encrypted1.split(':')[0];
const iv2 = encrypted2.split(':')[0];
expect(iv1).not.toBe(iv2);
});
test('should correctly round-trip various password types', async () => {
const { encryptCredential, decryptCredential } = await import('../encryption.js');
const passwords = [
'simple',
'P@$$w0rd!#%^&*()',
'пароль-кириллица',
'密码中文',
'a'.repeat(1000), // long password
'', // empty password (some DBs allow root with no password)
];
for (const pwd of passwords) {
const encrypted = encryptCredential(pwd);
const decrypted = decryptCredential(encrypted);
expect(decrypted).toBe(pwd);
}
});
test('should reject tampered ciphertext', async () => {
const { encryptCredential, decryptCredential } = await import('../encryption.js');
const encrypted = encryptCredential('test_password');
// Tamper with the ciphertext portion
const parts = encrypted.split(':');
parts[2] = parts[2].slice(0, -2) + 'ff'; // change last byte
const tampered = parts.join(':');
expect(() => decryptCredential(tampered)).toThrow();
});
test('should reject invalid encrypted format', async () => {
const { decryptCredential } = await import('../encryption.js');
expect(() => decryptCredential('not-a-valid-format')).toThrow();
expect(() => decryptCredential('')).toThrow();
expect(() => decryptCredential(null)).toThrow();
expect(() => decryptCredential(undefined)).toThrow();
});
});
describe('missing MASTER_ENCRYPTION_KEY handling', () => {
test('should handle missing MASTER_ENCRYPTION_KEY gracefully in dev', async () => {
// Remove key and set dev env
delete process.env.MASTER_ENCRYPTION_KEY;
process.env.NODE_ENV = 'development';
// Re-import to get fresh module
// Use dynamic import with cache busting
const modulePath = '../encryption.js';
const mod = await import(modulePath + '?dev_test=' + Date.now());
// In dev, should warn but not throw, using fallback
const result = mod.encryptCredential('test_password');
expect(result).toBeDefined();
expect(typeof result).toBe('string');
// Should still be able to decrypt
const decrypted = mod.decryptCredential(result);
expect(decrypted).toBe('test_password');
});
test('should throw error for missing MASTER_ENCRYPTION_KEY in production', async () => {
delete process.env.MASTER_ENCRYPTION_KEY;
process.env.NODE_ENV = 'production';
const mod = await import('../encryption.js?prod_test=' + Date.now());
expect(() => mod.encryptCredential('test_password')).toThrow(/MASTER_ENCRYPTION_KEY/);
});
});
describe('sanitizeCredentialsForResponse', () => {
beforeEach(() => {
process.env.MASTER_ENCRYPTION_KEY = 'a]3Fj9$kL2mN7pQ4rS6tU8vW0xY1zA5b';
});
test('should never return decrypted password in API response', async () => {
const { sanitizeCredentialsForResponse, encryptCredential } = await import('../encryption.js');
const encryptedPassword = encryptCredential('secret_db_password');
const dataSource = {
id: 'ds_abc123',
name: 'My Database',
db_host: 'localhost',
db_port: 3306,
db_username: 'root',
db_password_encrypted: encryptedPassword,
db_password_key: 'some_key',
ssh_private_key: 'some_ssh_key',
type: 'local_mysql'
};
const sanitized = sanitizeCredentialsForResponse(dataSource);
// Encrypted password field should be removed or masked
expect(sanitized.db_password_encrypted).toBeUndefined();
// Password key reference should be removed
expect(sanitized.db_password_key).toBeUndefined();
// SSH private key should be removed
expect(sanitized.ssh_private_key).toBeUndefined();
// Non-sensitive fields should remain
expect(sanitized.id).toBe('ds_abc123');
expect(sanitized.name).toBe('My Database');
expect(sanitized.db_host).toBe('localhost');
expect(sanitized.db_username).toBe('root');
// Has password indicator
expect(sanitized.has_password).toBe(true);
});
test('should indicate when no password is set', async () => {
const { sanitizeCredentialsForResponse } = await import('../encryption.js');
const dataSource = {
id: 'ds_abc123',
name: 'My Database',
db_password_encrypted: null,
};
const sanitized = sanitizeCredentialsForResponse(dataSource);
expect(sanitized.has_password).toBe(false);
});
});
describe('isEncrypted', () => {
beforeEach(() => {
process.env.MASTER_ENCRYPTION_KEY = 'a]3Fj9$kL2mN7pQ4rS6tU8vW0xY1zA5b';
});
test('should detect encrypted vs plain text', async () => {
const { encryptCredential, isEncrypted } = await import('../encryption.js');
const encrypted = encryptCredential('password123');
expect(isEncrypted(encrypted)).toBe(true);
expect(isEncrypted('plain_text_password')).toBe(false);
expect(isEncrypted('')).toBe(false);
expect(isEncrypted(null)).toBe(false);
});
});
});