import { describe, expect, test } from 'bun:test'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ModuleManifest } from './schema'; import { type TemplateValidationError, formatTemplateValidationErrors, validateModuleTemplates, } from './template-validator'; /** * Create a minimal valid manifest for testing */ function createTestManifest(overrides?: Partial): ModuleManifest { return { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', description: 'Test module', requires: { capabilities: [], system: { cpu: 2, memory: 2048, disk: 20, storage: 'local-lvm', zone: 'dmz', }, }, provides: { capabilities: [], }, variables: { owns: [ { name: 'hostname', type: 'string', required: false, source: 'user', }, { name: 'vmid', type: 'integer', required: false, source: 'user', }, { name: 'target_ip', type: 'string', required: false, source: 'user', }, ], imports: [], }, ...overrides, } as ModuleManifest; } describe('template-validator', () => { describe('validateModuleTemplates - valid references', () => { test('validates nested manifest paths (requires.system.*)', async () => { const manifest = createTestManifest(); // Test that requires.system.cpu is valid const system = manifest.requires.system; expect(system?.cpu).toBe(2); expect(system?.memory).toBe(2048); expect(system?.disk).toBe(20); expect(system?.storage).toBe('local-lvm'); }); test('accepts $self:{cores,memory,disk,storage} as auto-allocated sizing (ISS-0150)', async () => { // ISS-0150 repointed instance Terraform to read $self:cores/memory/disk/ // storage, which are injected at generate time from module_systems (see // variables/context.ts) — not declared in the manifest. They must validate // at `module import` time like vmid/target_ip, or every VM/CT module fails // to import. Regression guard: this previously errored "Self variable // 'cores' not found in module configuration". const manifest = createTestManifest(); const dir = await mkdtemp(join(tmpdir(), 'celilo-tpl-')); try { await mkdir(join(dir, 'terraform'), { recursive: true }); await writeFile( join(dir, 'terraform/main.tf.tpl'), 'cores = $self:cores\nmemory = $self:memory\ndisk = $self:disk\nstorage = "$self:storage"\n', ); const result = await validateModuleTemplates(dir, manifest); expect(result.errors).toEqual([]); expect(result.success).toBe(true); } finally { await rm(dir, { recursive: true, force: true }); } }); test('validates capability references', async () => { const manifest = createTestManifest({ requires: { capabilities: [ { name: 'dns_external', version: '1.0.0', }, ], }, }); // Capability variable should be valid if module requires it expect(manifest.requires.capabilities).toHaveLength(1); expect(manifest.requires.capabilities[0]?.name).toBe('dns_external'); }); }); describe('validateModuleTemplates - invalid references', () => { test('detects invalid $self: variable paths', () => { const errors: TemplateValidationError[] = [ { file: 'terraform/main.tf.tpl', variable: '$self:resources.vm.cpu', error: "Self variable 'resources.vm.cpu' not found in module configuration", }, ]; expect(errors).toHaveLength(1); expect(errors[0]?.error).toContain('not found in module configuration'); }); test('detects undeclared variables', () => { const manifest = createTestManifest(); // Variable 'unknown_var' is not declared const declaredVars = manifest.variables?.owns?.map((v) => v.name) || []; expect(declaredVars).not.toContain('unknown_var'); }); test('detects missing capability requirements', () => { const manifest = createTestManifest(); // Module doesn't require dns_external const requiresCapability = manifest.requires?.capabilities?.some( (cap) => cap.name === 'dns_external', ); expect(requiresCapability).toBe(false); }); test('detects invalid capability format (missing path)', () => { const error = 'Capability variable must specify data path (e.g., dns_external.nameserver)'; expect(error).toContain('must specify data path'); }); }); describe('formatTemplateValidationErrors', () => { test('formats single file errors', () => { const errors: TemplateValidationError[] = [ { file: 'terraform/main.tf.tpl', variable: '$self:resources.vm.cpu', error: "Self variable 'resources.vm.cpu' not found in module configuration", }, { file: 'terraform/main.tf.tpl', variable: '$self:resources.vm.memory', error: "Self variable 'resources.vm.memory' not found in module configuration", }, ]; const formatted = formatTemplateValidationErrors(errors); expect(formatted).toContain('Failed to validate template variables'); expect(formatted).toContain('terraform/main.tf.tpl'); expect(formatted).toContain('$self:resources.vm.cpu'); expect(formatted).toContain('$self:resources.vm.memory'); }); test('formats multiple file errors', () => { const errors: TemplateValidationError[] = [ { file: 'terraform/main.tf.tpl', variable: '$self:resources.vm.cpu', error: "Self variable 'resources.vm.cpu' not found", }, { file: 'terraform/outputs.tf.tpl', variable: '$self:resources.vm.zone', error: "Self variable 'resources.vm.zone' not found", }, ]; const formatted = formatTemplateValidationErrors(errors); expect(formatted).toContain('terraform/main.tf.tpl'); expect(formatted).toContain('terraform/outputs.tf.tpl'); }); test('groups errors by file', () => { const errors: TemplateValidationError[] = [ { file: 'file1.tpl', variable: '$self:var1', error: 'Error 1', }, { file: 'file2.tpl', variable: '$self:var2', error: 'Error 2', }, { file: 'file1.tpl', variable: '$self:var3', error: 'Error 3', }, ]; const formatted = formatTemplateValidationErrors(errors); // Should have both files expect(formatted).toContain('file1.tpl'); expect(formatted).toContain('file2.tpl'); // file1.tpl should have 2 errors const file1Matches = formatted.match(/file1\.tpl/g); expect(file1Matches).toHaveLength(1); // File name appears once, but has multiple errors under it }); }); describe('edge cases', () => { test('handles empty templates', () => { const errors: TemplateValidationError[] = []; expect(errors).toHaveLength(0); }); test('handles templates without variables', () => { const content = ` # Static terraform file resource "local_file" "test" { content = "hello world" } `; // No variables to validate expect(content).not.toContain('$self:'); expect(content).not.toContain('$system:'); }); test('handles mixed valid and invalid variables', () => { const errors: TemplateValidationError[] = [ { file: 'test.tpl', variable: '$self:invalid_var', error: 'Invalid variable', }, ]; // Should only report the invalid one expect(errors).toHaveLength(1); expect(errors[0]?.variable).toBe('$self:invalid_var'); }); }); /** * The capability-secret gate at import consumes these, so what happens when * templates cannot be read is a security property rather than a detail. It * has to FAIL CLOSED: an unreadable tree must abort the import, never hand * the gate an empty reference set that reads as "this module references no * secrets". It was already true here — but true by inspection, which is the * weakest way for a security property to be true. */ describe('capability references, and failing closed', () => { test('collects $capability: references from templates', async () => { const dir = await mkdtemp(join(tmpdir(), 'celilo-tplrefs-')); try { await mkdir(join(dir, 'terraform'), { recursive: true }); await writeFile( join(dir, 'terraform', 'main.tf.tpl'), 'acme_dns = "$capability:dns_internal.tsig_key"\nzone = "$capability:dns_internal.dns.domain"\n', ); const manifest = createTestManifest({ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] }, }); const result = await validateModuleTemplates(dir, manifest); expect(result.capabilityReferences.sort()).toEqual([ 'dns_internal.dns.domain', 'dns_internal.tsig_key', ]); } finally { await rm(dir, { recursive: true, force: true }); } }); test('an unreadable module tree fails, and yields no references', async () => { // A path that does not exist stands in for any read failure. The PAIRING // is the property: success:false travels WITH the empty array, and // `import.ts` returns on !success before the access gate runs, so the // empty set can never be mistaken for "nothing referenced". const result = await validateModuleTemplates( join(tmpdir(), 'celilo-no-such-module-tree-9f3a2b'), createTestManifest(), ); expect(result.success).toBe(false); expect(result.capabilityReferences).toEqual([]); }); }); });