/** * Who owns a config value — the operator, or celilo. * * The load-bearing case here is the one that looks like a special case and is * not: `derive_from` does NOT mean derived. Getting that backwards refuses an * operator's edit to their own config, and a migration written on the same test * would delete the row. */ import { describe, expect, test } from 'bun:test'; import type { VariableDeclare } from '../manifest/schema'; import { declaredVariables, describeDerivedSource, explainNotSettable, isDerivedVariable, } from './config-provenance'; function variable(overrides: Partial & { name: string }): VariableDeclare { return { type: 'string', required: false, source: 'user', ...overrides, } as VariableDeclare; } describe('isDerivedVariable', () => { test('a user-sourced variable is the operator’s', () => { expect(isDerivedVariable(variable({ name: 'acme_email', source: 'user' }))).toBe(false); }); test.each(['capability', 'system', 'infrastructure', 'terraform'] as const)( 'a %s-sourced variable is celilo’s', (source) => { expect(isDerivedVariable(variable({ name: 'x', source }))).toBe(true); }, ); test('a variable with NO declared source reads as the operator’s', () => { // The manifest schema requires `source`, so this only happens for a // malformed or pre-schema manifest already sitting in `manifest_data`. The // question is which way to be wrong: guessing "derived" refuses an // operator's `set` with a message insisting celilo owns a value nothing // computes, which they cannot act on. const noSource = { name: 'app_port', type: 'integer', required: false, } as unknown as VariableDeclare; expect(isDerivedVariable(noSource)).toBe(false); }); test('a user-sourced variable WITH a derive_from is still the operator’s', () => { // iptables: `firewall_ip`, `source: user`, `derive_from: $machine:ipAddress`. // `$machine:` derives are answered by the config interview — they seed a // default the operator confirms — so the row is operator config. Classing // it as derived would refuse an operator correcting their own firewall // address, and deleting it on the same test would blind the trusted-sources // audit in services/firewall-reach.ts, which reads exactly this row. const firewallIp = variable({ name: 'firewall_ip', source: 'user', required: true, derive_from: '$machine:ipAddress', }); expect(isDerivedVariable(firewallIp)).toBe(false); }); }); describe('declaredVariables', () => { test('indexes a manifest’s owned variables by name', () => { const declared = declaredVariables({ variables: { owns: [variable({ name: 'hostname' }), variable({ name: 'vpn_subnet', source: 'system' })], }, } as never); expect([...declared.keys()].sort()).toEqual(['hostname', 'vpn_subnet']); expect(declared.get('vpn_subnet')?.source).toBe('system'); }); test('a manifest declaring nothing yields an empty index, not a throw', () => { expect(declaredVariables({} as never).size).toBe(0); }); }); describe('explainNotSettable', () => { test('a capability-sourced value points at the provider, not at this module', () => { // The live footgun: `celilo module config set authentik auth_url …` // reported success, wrote the row, and was discarded on the next deploy. const message = explainNotSettable( 'authentik', variable({ name: 'auth_url', source: 'capability', derive_from: '$capability:authentication.url', }), ); expect(message).toContain('not operator-settable'); expect(message).toContain('source: capability'); // Actionable: the only way to change a derived value is to fix its source. expect(message).toContain('provider'); expect(message).toContain('redeploy'); }); test('a system-sourced value names the system key to set', () => { const message = explainNotSettable( 'technitium', variable({ name: 'vpn_subnet', source: 'system', derive_from: '$system:network.control-plane-vpn.subnet', }), ); // The operator should be able to copy the fix out of the error. The // `$system:` prefix is stripped so the key is the one `system config set` // actually takes. expect(message).toContain('celilo system config set network.control-plane-vpn.subnet'); }); test('an infrastructure-sourced value keeps the placement guidance', () => { const message = explainNotSettable( 'caddy', variable({ name: 'vmid', source: 'infrastructure' }), ); expect(message).toContain('IPAM'); expect(message).toContain('celilo proxmox migrate'); }); }); describe('describeDerivedSource', () => { test('names the upstream, and the template when there is one', () => { expect( describeDerivedSource( variable({ name: 'dmz_subnet', source: 'system', derive_from: '$system:network.dmz.subnet', }), ), ).toBe('from system config ($system:network.dmz.subnet)'); }); test('degrades to the upstream alone when no template is declared', () => { expect(describeDerivedSource(variable({ name: 'vmid', source: 'infrastructure' }))).toContain( 'infrastructure celilo selected', ); }); });