/** * Module-Specific Test Fixtures * * Factory functions that create pre-configured test environments for specific modules. * Each fixture knows what config keys the module needs and sets up dependencies. * * Reduces test setup from 30+ lines to a single function call. */ import { CLIContext } from './cli-context'; import { resetTestDbPath } from './db-path'; import { getModuleTestConfig } from './fixtures'; import { type IntegrationTestContext, setupIntegrationTest } from './integration'; /** * Base fixture options */ interface BaseFixtureOptions { /** Whether to run module generate (default: false) */ generated?: boolean; } /** * Homebridge module fixture options */ interface HomebridgeModuleOptions extends BaseFixtureOptions { /** Hostname (default: 'iot') */ hostname?: string; /** Bridge name (default: 'Home Bridge') */ bridgeName?: string; /** Bridge username MAC address (default: generated) */ bridgeUsername?: string; /** Bridge port (default: 51826) */ bridgePort?: number; /** Bridge PIN (default: '031-45-154') */ bridgePin?: string; /** Setup full system config for generation (default: false) */ withSystemConfig?: boolean; /** Setup mock infrastructure for generation (default: false) */ withMockInfrastructure?: boolean; } /** * A verified container service covering every allocatable zone, so a fixture * can reach `module generate`. * * Extracted rather than copied: generation refuses without infrastructure, so * every fixture that generates needs this, and a second inline copy would drift * from the first the moment `containerServices` gains a column. */ async function insertMockContainerService(dbPath: string): Promise { const { getDb } = await import('../db/client'); const { containerServices } = await import('../db/schema'); process.env.CELILO_DB_PATH = dbPath; try { const db = getDb(); await db.insert(containerServices).values({ id: 'test-proxmox', serviceId: 'test-proxmox', name: 'Test Proxmox', providerName: 'proxmox', zones: ['internal', 'dmz', 'app', 'secure'], apiCredentialsEncrypted: JSON.stringify({ encryptedValue: 'dummy', iv: 'dummy', authTag: 'dummy', }), providerConfig: { default_target_node: 'pve', lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst', storage: 'local-lvm', }, verified: true, verifiedAt: new Date(), verificationError: null, createdAt: new Date(), updatedAt: new Date(), }); } finally { // Reset to the scratch path, never restore the previous value: it may // be another suite's temp database, or unset — which sends the next // var-less reader to the operator's real celilo.db (celilo#1315). resetTestDbPath(); } } /** * Homebridge module fixture result */ interface HomebridgeModuleFixture { /** CLI context (persistent process) */ cli: CLIContext; /** Module ID */ moduleId: string; /** Configuration used */ config: Record; /** Integration test context (DB, data dir) */ context: IntegrationTestContext; /** Cleanup function */ cleanup: () => Promise; } /** * Homebridge module fixture * * Sets up Homebridge module with all required configuration. * Optionally runs module generate to create Terraform/Ansible files. * * @example * ```typescript * const { cli, moduleId, config, cleanup } = await homebridgeModule({ * generated: true, * bridgePort: 51826, * hostname: 'iot' * }); * * // Module is configured and generated - run tests * await cli.run('module status homebridge').expectSuccess(); * * // Cleanup * await cleanup(); * ``` */ export async function homebridgeModule( options: HomebridgeModuleOptions = {}, ): Promise { // Setup integration test environment const ctx = await setupIntegrationTest(); // Setup mock infrastructure if requested (needed for generation) if (options.withMockInfrastructure) { await insertMockContainerService(ctx.dbPath); } // Create CLI context with integration test env const cli = await CLIContext.create('src/cli/index.ts', { CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, }); const moduleId = 'homebridge'; // Setup system config if requested (needed for generation) if (options.withSystemConfig) { // DNS configuration (await cli.run('system config set dns.primary 192.168.0.1')).expectSuccess(); (await cli.run(`system config set dns.fallback '8.8.8.8 1.1.1.1'`)).expectSuccess(); // Network configuration (await cli.run('system config set network.bridge vmbr0')).expectSuccess(); (await cli.run('system config set network.dmz.vlan 10')).expectSuccess(); (await cli.run('system config set network.dmz.gateway 10.0.10.1')).expectSuccess(); (await cli.run('system config set network.dmz.subnet 10.0.10.0/24')).expectSuccess(); (await cli.run('system config set network.app.vlan 20')).expectSuccess(); (await cli.run('system config set network.app.gateway 10.0.20.1')).expectSuccess(); (await cli.run('system config set network.app.subnet 10.0.20.0/24')).expectSuccess(); // SSH configuration ( await cli.run( 'system config set ssh.public_key ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@celilo', ) ).expectSuccess(); // Note: primary_domain and admin.email are no longer system config — // they live in the dns_registrar capability and the authentik/caddy // module variables respectively (see MANIFEST_V2 D9). } // User-configurable fields only (match manifest.yml) const config = { hostname: options.hostname ?? 'iot', bridge_name: options.bridgeName ?? 'Home Bridge', bridge_username: options.bridgeUsername ?? '0E:21:4A:BC:DE:F0', bridge_pin: options.bridgePin ?? '031-45-154', bridge_port: options.bridgePort ?? 51826, }; // Import module from fixture path const modulePath = './test-fixtures/modules/homebridge'; (await cli.run(`module import ${modulePath}`)).expectSuccess(); // Configure module (only user-configurable fields) for (const [key, value] of Object.entries(config)) { // Don't quote numbers, they need to be parsed as integers const valueStr = typeof value === 'number' ? String(value) : `"${value}"`; (await cli.run(`module config set ${moduleId} ${key} ${valueStr}`)).expectSuccess(); } // Generate if requested if (options.generated) { (await cli.run(`module generate ${moduleId}`)).expectSuccess(); } // Unified cleanup const cleanup = async () => { await cli.dispose(); await ctx.cleanup(); }; return { cli, moduleId, config, context: ctx, cleanup, }; } /** * `dnsmasq-dhcp` fixture, for the golden comparison. * * ⚠️ IMPORTS THE REAL MODULE, `../../modules/dnsmasq-dhcp`, and not a copy under * `test-fixtures/modules/`. That is the whole point of it. * * `homebridgeModule` imports a copy, and the copy has drifted 29 lines in the * manifest and 6 in the Terraform template. So the golden gate compares * generated-from-a-copy against golden-from-the-same-copy: both sides move * together, and the shipped module is on neither. Worse, the copy is frozen at * `version: 1.0.0` with the container-infrastructure variables `required: true` * — the pre-fix state of a real bug (marking them required made homebridge * container-only). The gate still passes against that shape and cannot notice * it returning. Tracked as celilo#1084. * * Pointing at the shipped module means this golden fails when the generator's * output for the shipped module changes, which is the only version worth having. */ export interface DnsmasqDhcpModuleFixture { cli: CLIContext; moduleId: string; config: Record; context: IntegrationTestContext; cleanup: () => Promise; } export interface DnsmasqDhcpModuleOptions extends BaseFixtureOptions { /** Seed a container service, without which generation has no infrastructure. */ withMockInfrastructure?: boolean; /** Set the `network.internal.*` system config the templates resolve against. */ withSystemConfig?: boolean; } export async function dnsmasqDhcpModule( options: DnsmasqDhcpModuleOptions = {}, ): Promise { const ctx = await setupIntegrationTest(); if (options.withMockInfrastructure) { await insertMockContainerService(ctx.dbPath); } const cli = await CLIContext.create('src/cli/index.ts', { CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, }); const moduleId = 'dnsmasq-dhcp'; if (options.withSystemConfig) { // `internal` only — it is the single zone this module declares, and adding // others would put values in the golden that nothing generated from. (await cli.run('system config set network.bridge vmbr0')).expectSuccess(); (await cli.run('system config set network.internal.vlan 1')).expectSuccess(); (await cli.run('system config set network.internal.gateway 10.99.1.1')).expectSuccess(); (await cli.run('system config set network.internal.subnet 10.99.1.0/24')).expectSuccess(); ( await cli.run( 'system config set ssh.public_key ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@celilo', ) ).expectSuccess(); } // Read from `test-fixtures/test-values.yml` rather than written out here, so // that file's entry for this module is load-bearing rather than decorative. A // golden generated from values nothing else references would drift from the // canonical set with nothing noticing. // // That entry carries EVERY `required: true` variable, including the ones with // defaults: celilo asks for a required variable whether or not it has one, // and an unanswered ask has no headless answer, so the command blocks until // its timeout naming no variable. const config = await getModuleTestConfig(moduleId); if (Object.keys(config).length === 0) { throw new Error( `test-values.yml carries no entry for '${moduleId}', so this fixture would generate from an empty config and the golden would record that. Add one.`, ); } (await cli.run(`module import ../../modules/${moduleId}`)).expectSuccess(); for (const [key, value] of Object.entries(config)) { // An array has to reach the CLI as JSON; a scalar is quoted as-is. const encoded = Array.isArray(value) ? `'${JSON.stringify(value)}'` : `"${value}"`; (await cli.run(`module config set ${moduleId} ${key} ${encoded}`)).expectSuccess(); } if (options.generated) { (await cli.run(`module generate ${moduleId}`)).expectSuccess(); } const cleanup = async () => { await cli.dispose(); await ctx.cleanup(); }; return { cli, moduleId, config, context: ctx, cleanup }; } /** * Caddy module fixture options */ interface CaddyModuleOptions extends BaseFixtureOptions { /** Hostname (default: 'www-test') */ hostname?: string; /** Whether to build Caddy from source (default: true) */ buildCaddy?: boolean; /** DNS provider (default: 'cloudflare') */ dnsProvider?: string; } /** * Caddy module fixture result */ interface CaddyModuleFixture { cli: CLIContext; moduleId: string; /** DNS external module ID (dependency) */ dnsExternalId: string; config: Record; context: IntegrationTestContext; cleanup: () => Promise; } /** * Caddy module fixture * * Sets up Caddy module with dns-external dependency. * Automatically creates and configures dns-external module. * * @example * ```typescript * const { cli, moduleId, dnsExternalId, cleanup } = await caddyModule({ * generated: true, * hostname: 'www' * }); * ``` */ export async function caddyModule(options: CaddyModuleOptions = {}): Promise { const ctx = await setupIntegrationTest(); const cli = await CLIContext.create('src/cli/index.ts', { CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, }); const moduleId = 'caddy'; const dnsExternalId = 'dns-external'; // Setup dns-external dependency first (await cli.run(`module add ${dnsExternalId}`)).expectSuccess(); (await cli.run(`module config set ${dnsExternalId} hostname dns-ext`)).expectSuccess(); (await cli.run(`module config set ${dnsExternalId} cores 1`)).expectSuccess(); (await cli.run(`module config set ${dnsExternalId} memory 512`)).expectSuccess(); // Setup caddy const config = { hostname: options.hostname ?? 'www-test', cores: 1, memory: 1024, build_caddy: options.buildCaddy ?? true, dns_provider: options.dnsProvider ?? 'cloudflare', }; (await cli.run(`module add ${moduleId}`)).expectSuccess(); for (const [key, value] of Object.entries(config)) { const valueStr = typeof value === 'boolean' ? String(value) : value; (await cli.run(`module config set ${moduleId} ${key} "${valueStr}"`)).expectSuccess(); } // Generate if requested if (options.generated) { (await cli.run(`module generate ${dnsExternalId}`)).expectSuccess(); (await cli.run(`module generate ${moduleId}`)).expectSuccess(); } const cleanup = async () => { await cli.dispose(); await ctx.cleanup(); }; return { cli, moduleId, dnsExternalId, config, context: ctx, cleanup, }; } /** * DNS-external module fixture options */ interface DnsExternalModuleOptions extends BaseFixtureOptions { /** DNS provider (default: 'cloudflare') */ provider?: string; /** API token for testing (default: 'test-token-123') */ apiToken?: string; } /** * DNS-external module fixture result */ interface DnsExternalModuleFixture { cli: CLIContext; moduleId: string; apiToken: string; config: Record; context: IntegrationTestContext; cleanup: () => Promise; } /** * DNS-external module fixture * * Sets up dns-external module for external DNS management. * * @example * ```typescript * const { cli, moduleId, apiToken, cleanup } = await dnsExternalModule({ * generated: true, * provider: 'cloudflare' * }); * ``` */ export async function dnsExternalModule( options: DnsExternalModuleOptions = {}, ): Promise { const ctx = await setupIntegrationTest(); const cli = await CLIContext.create('src/cli/index.ts', { CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, }); const moduleId = 'dns-external'; const apiToken = options.apiToken ?? 'test-token-123'; const config = { hostname: 'dns-ext-test', cores: 1, memory: 512, provider: options.provider ?? 'cloudflare', }; (await cli.run(`module add ${moduleId}`)).expectSuccess(); for (const [key, value] of Object.entries(config)) { (await cli.run(`module config set ${moduleId} ${key} "${value}"`)).expectSuccess(); } // Set API token as secret (await cli.run(`module secret set ${moduleId} api_token "${apiToken}"`)).expectSuccess(); // Generate if requested if (options.generated) { (await cli.run(`module generate ${moduleId}`)).expectSuccess(); } const cleanup = async () => { await cli.dispose(); await ctx.cleanup(); }; return { cli, moduleId, apiToken, config, context: ctx, cleanup, }; } /** * Multi-module environment options */ interface MultiModuleOptions { /** Module IDs to set up */ modules: string[]; /** Generate all modules (default: false) */ generateAll?: boolean; } /** * Multi-module environment result */ interface MultiModuleFixture { cli: CLIContext; /** Map of module ID to configuration */ modules: Map>; context: IntegrationTestContext; cleanup: () => Promise; } /** * Multi-module environment fixture * * Sets up multiple modules with dependencies. * Automatically resolves dependency order (e.g., caddy requires dns-external). * * @example * ```typescript * const { cli, modules, cleanup } = await multiModule({ * modules: ['homebridge', 'caddy'], * generateAll: true * }); * * expect(modules.has('homebridge')).toBe(true); * expect(modules.has('caddy')).toBe(true); * expect(modules.has('dns-external')).toBe(true); // Auto-added dependency * ``` */ export async function multiModule(options: MultiModuleOptions): Promise { const ctx = await setupIntegrationTest(); const cli = await CLIContext.create('src/cli/index.ts', { CELILO_DB_PATH: ctx.dbPath, CELILO_DATA_DIR: ctx.dataDir, }); const modules = new Map>(); // Resolve dependencies and setup modules // TODO: Implement proper dependency resolution // For now, manually handle known dependencies for (const moduleId of options.modules) { switch (moduleId) { case 'homebridge': { const fixture = await homebridgeModule({ generated: options.generateAll, }); modules.set(moduleId, fixture.config); // Don't cleanup sub-fixtures, we'll cleanup at the end break; } case 'caddy': { const fixture = await caddyModule({ generated: options.generateAll }); modules.set(moduleId, fixture.config); modules.set('dns-external', {}); // Mark as added break; } // Add more modules as needed default: throw new Error(`Unknown module: ${moduleId}`); } } const cleanup = async () => { await cli.dispose(); await ctx.cleanup(); }; return { cli, modules, context: ctx, cleanup, }; } /** * Export all fixtures as namespace */ export const fixtures = { homebridgeModule, caddyModule, dnsExternalModule, multiModule, };