/** * Secret Storage Tests */ import Database from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { and, eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; import type { DbClient } from '../db/client'; import * as schema from '../db/schema'; import { secrets, systemSecrets } from '../db/schema'; import { decryptSecret } from './encryption'; import { getOrCreateMasterKey } from './master-key'; import { getSystemSecret, recordProviderSecret, storeSystemSecret, systemSecretExists, } from './storage'; describe('secret storage', () => { let testDir: string; let testDb: DbClient; beforeEach(() => { // Create temporary directory and database testDir = mkdtempSync(join(tmpdir(), 'celilo-test-')); const dbPath = join(testDir, 'test.db'); const sqlite = new Database(dbPath); testDb = drizzle(sqlite, { schema }); // Run migrations migrate(testDb, { migrationsFolder: './drizzle' }); // Set master key environment variable process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key'); }); afterEach(() => { // Cleanup rmSync(testDir, { recursive: true, force: true }); delete process.env.CELILO_MASTER_KEY_PATH; }); test('stores and retrieves system secret', async () => { // Store a secret await storeSystemSecret('proxmox.root_password', 'test-password-123', testDb); // Verify it was stored expect(systemSecretExists('proxmox.root_password', testDb)).toBe(true); // Retrieve and verify const decrypted = await getSystemSecret('proxmox.root_password', testDb); expect(decrypted).toBe('test-password-123'); }); test('updates existing secret', async () => { // Store initial secret await storeSystemSecret('proxmox.root_password', 'old-password', testDb); // Update with new value await storeSystemSecret('proxmox.root_password', 'new-password', testDb); // Should have only one record const records = testDb.select().from(systemSecrets).all(); expect(records).toHaveLength(1); // Should return new value const decrypted = await getSystemSecret('proxmox.root_password', testDb); expect(decrypted).toBe('new-password'); }); test('returns null for non-existent secret', async () => { const decrypted = await getSystemSecret('non.existent', testDb); expect(decrypted).toBeNull(); }); test('systemSecretExists returns false for non-existent', () => { expect(systemSecretExists('non.existent', testDb)).toBe(false); }); test('encrypts secrets (ciphertext differs from plaintext)', async () => { const plaintext = 'my-secret-password'; await storeSystemSecret('test.key', plaintext, testDb); // Read raw database record const record = testDb .select() .from(systemSecrets) .where(eq(systemSecrets.key, 'test.key')) .get(); expect(record).toBeDefined(); if (record) { // Encrypted value should not be plaintext expect(record.encryptedValue).not.toBe(plaintext); // Should have IV and auth tag expect(record.iv).toBeTruthy(); expect(record.authTag).toBeTruthy(); } }); test('handles special characters in secrets', async () => { const specialChars = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?/~`'; await storeSystemSecret('test.special', specialChars, testDb); const decrypted = await getSystemSecret('test.special', testDb); expect(decrypted).toBe(specialChars); }); test('handles long secrets', async () => { const longSecret = 'a'.repeat(1000); await storeSystemSecret('test.long', longSecret, testDb); const decrypted = await getSystemSecret('test.long', testDb); expect(decrypted).toBe(longSecret); }); describe('recordProviderSecret (hook-owned-state D3)', () => { const providerRow = (moduleId: string, name: string) => testDb .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); const decryptRow = async (moduleId: string, name: string) => { const row = providerRow(moduleId, name); expect(row).toBeDefined(); if (!row) throw new Error('provider secret row missing'); const mk = await getOrCreateMasterKey(); return decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag }, mk, ); }; test('records an encrypted secret onto the provider module', async () => { await recordProviderSecret(testDb, 'some-provider', 'api_key', 'minted-value'); expect(await decryptRow('some-provider', 'api_key')).toBe('minted-value'); // Encrypted on disk, never the plaintext. const row = providerRow('some-provider', 'api_key'); expect(row && row.encryptedValue !== 'minted-value').toBe(true); }); test('upserts on (moduleId, name) instead of duplicating', async () => { await recordProviderSecret(testDb, 'some-provider', 'api_key', 'first'); await recordProviderSecret(testDb, 'some-provider', 'api_key', 'second'); const rows = testDb.select().from(secrets).all(); expect(rows).toHaveLength(1); expect(await decryptRow('some-provider', 'api_key')).toBe('second'); }); test('keeps two providers with the same secret name distinct', async () => { await recordProviderSecret(testDb, 'provider-a', 'api_key', 'value-a'); await recordProviderSecret(testDb, 'provider-b', 'api_key', 'value-b'); const rows = testDb.select().from(secrets).all(); expect(rows).toHaveLength(2); }); test('refuses an empty module id, name, or value', async () => { await expect(recordProviderSecret(testDb, '', 'api_key', 'v')).rejects.toThrow( 'providerModuleId is required', ); await expect(recordProviderSecret(testDb, 'p', ' ', 'v')).rejects.toThrow( 'name is required', ); await expect(recordProviderSecret(testDb, 'p', 'api_key', '')).rejects.toThrow( 'value is required', ); expect(testDb.select().from(secrets).all()).toHaveLength(0); }); }); });