import { describe, expect, it } from 'bun:test'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { SystemConfigProperty, SystemConfigSchema } from './system-config-schema-types'; import { validateKey, validateValue } from './system-config-validator'; describe('validateValue', () => { describe('integer type validation', () => { const property: SystemConfigProperty = { type: 'integer', minimum: 1, maximum: 4094, description: 'VLAN tag', }; it('accepts valid integer', () => { expect(validateValue('network.dmz.vlan', '10', property).valid).toBe(true); }); it('rejects non-numeric value', () => { const result = validateValue('network.dmz.vlan', 'abc', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('must be an integer'); }); it('rejects value below minimum', () => { const result = validateValue('network.dmz.vlan', '0', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('>= 1'); }); it('rejects value above maximum', () => { const result = validateValue('network.dmz.vlan', '5000', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('<= 4094'); }); it('accepts boundary values', () => { expect(validateValue('network.dmz.vlan', '1', property).valid).toBe(true); expect(validateValue('network.dmz.vlan', '4094', property).valid).toBe(true); }); }); describe('string pattern validation', () => { const property: SystemConfigProperty = { type: 'string', pattern: '^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$', description: 'IP address', }; it('accepts valid IP addresses', () => { expect(validateValue('dns.primary', '1.1.1.1', property).valid).toBe(true); expect(validateValue('dns.primary', '192.168.0.1', property).valid).toBe(true); expect(validateValue('dns.primary', '10.0.0.1', property).valid).toBe(true); }); it('rejects invalid format', () => { const result = validateValue('dns.primary', 'invalid', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('does not match required pattern'); }); it('rejects incomplete IP', () => { const result = validateValue('dns.primary', '192.168.0', property); expect(result.valid).toBe(false); }); }); describe('CIDR pattern validation', () => { const property: SystemConfigProperty = { type: 'string', pattern: '^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$', description: 'Subnet CIDR', }; it('accepts valid CIDR notation', () => { expect(validateValue('network.dmz.subnet', '10.0.10.0/24', property).valid).toBe(true); expect(validateValue('network.dmz.subnet', '192.168.0.0/16', property).valid).toBe(true); expect(validateValue('network.dmz.subnet', '172.16.0.0/12', property).valid).toBe(true); }); it('rejects CIDR without prefix length', () => { const result = validateValue('network.dmz.subnet', '10.0.10.0', property); expect(result.valid).toBe(false); }); it('rejects invalid CIDR format', () => { const result = validateValue('network.dmz.subnet', '10.0.10.0/abc', property); expect(result.valid).toBe(false); }); }); describe('email format validation', () => { const property: SystemConfigProperty = { type: 'string', format: 'email', description: 'Email address', }; it('accepts valid email addresses', () => { expect(validateValue('admin.email', 'admin@example.com', property).valid).toBe(true); expect(validateValue('admin.email', 'user@homelab.local', property).valid).toBe(true); expect(validateValue('admin.email', 'test.user@sub.domain.com', property).valid).toBe(true); }); it('rejects invalid email format', () => { const result = validateValue('admin.email', 'not-an-email', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('valid email address'); }); it('rejects email without domain', () => { const result = validateValue('admin.email', 'admin@', property); expect(result.valid).toBe(false); }); }); describe('cidr-list format validation', () => { const property: SystemConfigProperty = { type: 'string', format: 'cidr-list', description: 'Trusted source subnets', }; it('accepts both stored forms: comma-separated CIDRs and a JSON array', () => { expect(validateValue('firewall.trusted_subnets', '10.0.0.0/24', property).valid).toBe(true); expect( validateValue('firewall.trusted_subnets', '10.0.0.0/24, 10.1.0.0/24', property).valid, ).toBe(true); expect(validateValue('firewall.trusted_subnets', '["10.226.120.0/24"]', property).valid).toBe( true, ); }); it('rejects a token that is not a CIDR, naming it, at write time', () => { const result = validateValue( 'firewall.trusted_subnets', '["10.226.120.0/24", oops]', property, ); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('looks like JSON but does not parse'); }); it('rejects a JSON-looking value outright rather than mangling it into a subnet name', () => { const result = validateValue('firewall.trusted_subnets', 'nonsense', property); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('nonsense'); }); }); }); describe('validateKey', () => { const schema: SystemConfigSchema = { properties: { 'network.dmz.subnet': { type: 'string', description: 'DMZ subnet', }, 'network.dmz.gateway': { type: 'string', description: 'DMZ gateway', }, 'network.bridge': { type: 'string', description: 'Network bridge', }, }, }; it('accepts valid keys', () => { expect(validateKey('network.dmz.subnet', schema).valid).toBe(true); expect(validateKey('network.dmz.gateway', schema).valid).toBe(true); expect(validateKey('network.bridge', schema).valid).toBe(true); }); it('rejects invalid key', () => { const result = validateKey('invalid.key', schema); expect(result.valid).toBe(false); if (result.valid) return; expect(result.error).toContain('Invalid system config key'); }); it('shows valid keys in error message', () => { const result = validateKey('invalid.key', schema); if (result.valid) return; expect(result.error).toContain('network.dmz.subnet'); expect(result.error).toContain('network.dmz.gateway'); expect(result.error).toContain('network.bridge'); }); }); // The shipped schema is what `celilo system config set` validates against, and it // is a hand-maintained enumeration of zones. `secure-mgmt` was added to // NETWORK_ZONES, IPAM, zone detection and the manifest schema but NOT here, so the // zone existed everywhere except the one place its subnet could be RECORDED — and // recording it is what derives firewall trust and the resolver's split-horizon // view. It failed only on the live box, at `system config set`. describe('shipped system_config.json covers every placement zone', () => { const shipped = JSON.parse( readFileSync(join(import.meta.dir, '../../schemas/system_config.json'), 'utf-8'), ) as SystemConfigSchema; // `external` is deliberately absent: cloud/VPS systems carry their own // addressing and celilo allocates nothing for them. const ADDRESSABLE_ZONES = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'] as const; for (const zone of ADDRESSABLE_ZONES) { it(`accepts network.${zone}.subnet and .gateway`, () => { expect(validateKey(`network.${zone}.subnet`, shipped).valid).toBe(true); expect(validateKey(`network.${zone}.gateway`, shipped).valid).toBe(true); }); } // The same defect one step further out: a module can declare // `derive_from: "$system:"` for a key the shipped schema never accepts. // technitium's `vpn_subnet` derived from `$system:network.vpn.subnet` for // months while `system config set network.vpn.subnet` rejected the key — the // derive had no source and DNS silently lost its VPN split-horizon view. // Enumerating the manifests keeps this from needing a hand-maintained list. const MODULES_DIR = join(import.meta.dir, '../../../../modules'); const derivedSystemKeys = new Set(); for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const manifest = join(MODULES_DIR, entry.name, 'manifest.yml'); if (!existsSync(manifest)) continue; // `derive_from:` only — a bare `$system:` also appears in prose descriptions. for (const m of readFileSync(manifest, 'utf-8').matchAll( /derive_from:\s*["']?\$system:([\w.-]+)/g, )) { derivedSystemKeys.add(m[1]); } } it('found system-derived keys to check (the scan itself must not silently pass)', () => { expect(derivedSystemKeys.size).toBeGreaterThan(0); }); for (const key of [...derivedSystemKeys].sort()) { it(`accepts ${key}, which a module derives from`, () => { expect(validateKey(key, shipped).valid).toBe(true); }); } });