Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
213 lines
7.5 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|