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 { type DbClient, getDb } from '../db/client'; import { moduleConfigs, modules, secrets } from '../db/schema'; import type { Ensure } from '../manifest/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { resetTestDbPath } from '../test-utils/db-path'; import { findEnsureOnProvider, interviewForEnsureInputs, renderEnsureRecipe, } from './config-interview'; const ENSURE: Ensure = { id: 'managed_domain', description: 'Add a domain.', inputs: [ { kind: 'append_to_array', target: 'config.additional_domains' }, { kind: 'set_in_object', target: 'secret.additional_ddns_passwords', key: '{{value}}', prompt: 'Namecheap DDNS password for {{value}}', hint: 'Advanced DNS panel', }, ], post: 'redeploy_self', }; describe('interviewForEnsureInputs', () => { let tempDir: string; let testDb: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-ensure-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); testDb = getDb(); testDb .insert(modules) .values({ id: 'namecheap', name: 'Namecheap', sourcePath: tempDir, version: '2.0.0', manifestData: { provides: { capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }], }, }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); }); test('appends to array config and sets secret object key', async () => { const promptedFor: string[] = []; const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, { promptOverride: async (msg) => { promptedFor.push(msg); return 'super-secret-pw'; }, }); expect(result.success).toBe(true); expect(result.alreadyApplied).toBe(false); expect(promptedFor[0]).toContain('celilo.computer'); // config array const configRow = testDb .select() .from(moduleConfigs) .where( and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')), ) .get(); expect(configRow?.valueJson).toBeTruthy(); expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['celilo.computer']); // secret JSON object const secretRow = testDb .select() .from(secrets) .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords'))) .get(); expect(secretRow).toBeTruthy(); if (!secretRow) return; const masterKey = await getOrCreateMasterKey(); const decoded = decryptSecret( { encryptedValue: secretRow.encryptedValue, iv: secretRow.iv, authTag: secretRow.authTag, }, masterKey, ); expect(JSON.parse(decoded)).toEqual({ 'celilo.computer': 'super-secret-pw' }); }); test('idempotent — re-running with same value is a no-op', async () => { await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, { promptOverride: async () => 'pw1', }); let promptCount = 0; const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, { promptOverride: async () => { promptCount++; return 'should-not-be-used'; }, }); expect(result.success).toBe(true); expect(result.alreadyApplied).toBe(true); expect(promptCount).toBe(0); }); test('preserves existing entries when adding a second domain', async () => { await interviewForEnsureInputs('namecheap', ENSURE, 'first.com', testDb, { promptOverride: async () => 'pw1', }); await interviewForEnsureInputs('namecheap', ENSURE, 'second.com', testDb, { promptOverride: async () => 'pw2', }); const configRow = testDb .select() .from(moduleConfigs) .where( and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')), ) .get(); expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['first.com', 'second.com']); const secretRow = testDb .select() .from(secrets) .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords'))) .get(); if (!secretRow) throw new Error('expected secret row'); const masterKey = await getOrCreateMasterKey(); const decoded = decryptSecret( { encryptedValue: secretRow.encryptedValue, iv: secretRow.iv, authTag: secretRow.authTag, }, masterKey, ); expect(JSON.parse(decoded)).toEqual({ 'first.com': 'pw1', 'second.com': 'pw2' }); }); // The "declined confirm short-circuits" test was removed when the // confirm prompt itself was removed — running `module deploy ` // is the user's consent for the cross-module config its hooks imply. // See config-interview.ts and CADDY_HOSTNAME_LIST.md, Decision 6. test('promptOverride applies inputs directly without bus or terminal', async () => { // promptOverride is the test escape hatch — bypasses the bus path // so unit tests don't need to set up a responder. See bus-ensure- // flow.test.ts for the full bus-mediated flow. const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, { promptOverride: async () => 'pw', }); expect(result.success).toBe(true); }); }); describe('findEnsureOnProvider', () => { let tempDir: string; let testDb: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-find-ensure-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); testDb = getDb(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); }); test('finds ensure by id on a registered provider', () => { testDb .insert(modules) .values({ id: 'namecheap', name: 'Namecheap', sourcePath: tempDir, version: '2.0.0', manifestData: { provides: { capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }], }, }, }) .run(); const found = findEnsureOnProvider('namecheap', 'managed_domain', testDb); expect(found?.id).toBe('managed_domain'); }); test('returns null when module exists but ensure id does not match', () => { testDb .insert(modules) .values({ id: 'namecheap', name: 'Namecheap', sourcePath: tempDir, version: '2.0.0', manifestData: { provides: { capabilities: [] } }, }) .run(); expect(findEnsureOnProvider('namecheap', 'managed_domain', testDb)).toBeNull(); }); test('returns null when provider module does not exist', () => { expect(findEnsureOnProvider('ghost', 'managed_domain', testDb)).toBeNull(); }); }); describe('renderEnsureRecipe', () => { test('mentions module id, ensure id, value, and post action', () => { const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer'); expect(recipe).toContain('namecheap'); expect(recipe).toContain('managed_domain'); expect(recipe).toContain('celilo.computer'); expect(recipe).toContain('celilo module deploy namecheap'); }); test('renders the per-domain prompt template', () => { const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer'); expect(recipe).toContain('Namecheap DDNS password for celilo.computer'); }); });