import { describe, expect, test } from 'bun:test'; import type { ModuleManifest, VariableDeclare } from '../../manifest/schema'; import { type InstalledModuleConfig, auditModuleConfigs } from './module-configs'; function makeVariable(overrides: Partial): VariableDeclare { return { name: 'foo', type: 'string', required: false, source: 'user', ...overrides, } as VariableDeclare; } function makeModule( id: string, variables: VariableDeclare[], configs: Record = {}, ): InstalledModuleConfig { const manifest = { id, name: id, version: '1.0.0', celilo_contract: '1.0', variables: { owns: variables, imports: [] }, } as unknown as ModuleManifest; // Default state INSTALLED so existing tests continue to expect // `blocked` severity. Tests that exercise the IMPORTED path // override the state explicitly. return { id, state: 'INSTALLED', manifest, configs }; } describe('auditModuleConfigs', () => { test('no findings when every required variable has a value', async () => { const result = await auditModuleConfigs({ modules: [ makeModule('caddy', [makeVariable({ name: 'hostname', required: true })], { hostname: 'www', }), ], }); expect(result).toEqual([]); }); test('blocked finding when required variable has no value and no default', async () => { const result = await auditModuleConfigs({ modules: [makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {})], }); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ severity: 'blocked', code: 'module_config_required_unset', subject: 'caddy', }); expect(result[0].message).toContain('acme_email'); }); test('no finding when required variable has a default (default applies)', async () => { const result = await auditModuleConfigs({ modules: [ makeModule( 'caddy', [makeVariable({ name: 'hostname', required: true, default: 'www' })], {}, ), ], }); expect(result).toEqual([]); }); test('no finding when optional variable is unset (no default needed)', async () => { const result = await auditModuleConfigs({ modules: [makeModule('caddy', [makeVariable({ name: 'acme_ca', required: false })], {})], }); expect(result).toEqual([]); }); test('no finding when optional variable with default is unset', async () => { const result = await auditModuleConfigs({ modules: [ makeModule( 'greenwave', [makeVariable({ name: 'port_forwards', required: false, default: [] })], {}, ), ], }); expect(result).toEqual([]); }); test('skips non-user-sourced variables (infrastructure/capability/system)', async () => { const result = await auditModuleConfigs({ modules: [ makeModule( 'caddy', [ makeVariable({ name: 'target_node', required: true, source: 'infrastructure' }), makeVariable({ name: 'primary_domain', required: true, source: 'capability' }), makeVariable({ name: 'dns_primary', required: true, source: 'system' }), ], {}, ), ], }); expect(result).toEqual([]); }); test('treats empty string as unset', async () => { const result = await auditModuleConfigs({ modules: [ makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], { acme_email: '', }), ], }); expect(result).toHaveLength(1); expect(result[0].severity).toBe('blocked'); }); test('reports across multiple modules (only required-without-default)', async () => { const result = await auditModuleConfigs({ modules: [ makeModule('caddy', [makeVariable({ name: 'a', required: true })], {}), // iptables.b has a default, so even though it's required and unset, // the default resolves the value — no finding. makeModule('iptables', [makeVariable({ name: 'b', required: true, default: 'x' })], {}), ], }); expect(result).toHaveLength(1); expect(result[0].subject).toBe('caddy'); }); // The exact regression the operator hit on celilo-mgmt: required // configs missing on IMPORTED-but-not-yet-deployed modules used to // surface as `blocked`, gating system update entirely. The deploy // interview collects them at deploy time, so they're todos here. test('IMPORTED module with missing required config → todo, not blocked', async () => { const moduleWithImportedState = { ...makeModule('namecheap', [makeVariable({ name: 'domains', required: true })], {}), state: 'IMPORTED', }; const result = await auditModuleConfigs({ modules: [moduleWithImportedState], }); expect(result).toHaveLength(1); expect(result[0].severity).toBe('todo'); // Remediation also flips: don't tell the operator to manually // `module config set` (the deploy interview is the right path). expect(result[0].remediation).toBe('celilo module deploy namecheap'); }); test('VALIDATED / GENERATING / other pre-deploy states also demote to todo', async () => { const validated = { ...makeModule('foo', [makeVariable({ name: 'x', required: true })], {}), state: 'VALIDATED', }; const generating = { ...makeModule('bar', [makeVariable({ name: 'x', required: true })], {}), state: 'GENERATING', }; const result = await auditModuleConfigs({ modules: [validated, generating] }); expect(result).toHaveLength(2); expect(result.every((f) => f.severity === 'todo')).toBe(true); }); test('VERIFIED state stays blocked (deployed module)', async () => { const verified = { ...makeModule('caddy', [makeVariable({ name: 'acme_email', required: true })], {}), state: 'VERIFIED', }; const result = await auditModuleConfigs({ modules: [verified] }); expect(result).toHaveLength(1); expect(result[0].severity).toBe('blocked'); }); });