/** * Unit tests for `celilo system apply-config`. * * The parser is pure (key=value handling); we test it directly. The * actual DB write goes through `initializeSystem` which is already * covered by system-init's tests — we just confirm apply-config's * thin wrapper hands the right shape to it. */ import { describe, expect, test } from 'bun:test'; import { networkWriteRefusal, parseKeyValueArgs, rejectedNetworkKeys } from './system-apply-config'; describe('parseKeyValueArgs', () => { test('empty args → empty overrides + no errors', () => { expect(parseKeyValueArgs([])).toEqual({ overrides: {}, errors: [] }); }); test('parses a single key=value', () => { expect(parseKeyValueArgs(['network.dmz.subnet=10.0.10.0/24'])).toEqual({ overrides: { 'network.dmz.subnet': '10.0.10.0/24' }, errors: [], }); }); test('parses multiple key=value pairs', () => { const result = parseKeyValueArgs([ 'network.dmz.subnet=10.0.10.0/24', 'dns.primary=1.1.1.1', 'dns.fallback=8.8.8.8 1.1.1.1', ]); expect(result.overrides).toEqual({ 'network.dmz.subnet': '10.0.10.0/24', 'dns.primary': '1.1.1.1', 'dns.fallback': '8.8.8.8 1.1.1.1', }); expect(result.errors).toEqual([]); }); test('records error for positional without "="', () => { const result = parseKeyValueArgs(['not-a-pair']); expect(result.overrides).toEqual({}); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain('Expected key=value'); }); test('only-equal at index 0 is rejected (empty key)', () => { // `=value` has eqIndex 0, which the parser rejects so we don't // accept anonymous keys. const result = parseKeyValueArgs(['=orphan']); expect(result.errors).toHaveLength(1); }); test('preserves values containing "=" (splits on first "=" only)', () => { // SSH public keys end with `user@host`, but trailing `=` characters // appear in base64-encoded key bodies. Make sure those survive. const sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA== user@host'; const result = parseKeyValueArgs([`ssh.public_key=${sshKey}`]); expect(result.overrides['ssh.public_key']).toBe(sshKey); }); test('keys can contain dots (system config convention)', () => { const result = parseKeyValueArgs(['network.dmz.subnet=10.0.10.0/24']); expect(result.overrides['network.dmz.subnet']).toBe('10.0.10.0/24'); }); test('later occurrences of a key overwrite earlier ones', () => { const result = parseKeyValueArgs(['dns.primary=1.1.1.1', 'dns.primary=8.8.8.8']); expect(result.overrides['dns.primary']).toBe('8.8.8.8'); }); }); /** * §2.2 — the write path is CLOSED, not merely unused. * * `apply-config` is the automation surface a module hook shells out to, and it * is how `wireguard` and `iptables` used to write network definitions. Deleting * those two calls stops those two modules; refusing the key stops the next one. * The distinction matters because the authority is the point: a rule every * caller must remember is not a rule. */ describe('network keys are refused (celilo owns the network namespace)', () => { test('the subnet a module used to write is rejected', () => { expect(rejectedNetworkKeys(['network.control-plane-vpn.subnet'])).toEqual([ 'network.control-plane-vpn.subnet', ]); }); test('the whole network namespace is refused, not just .subnet', () => { // A module that could set a gateway or a VLAN tag for a network it does not // own would be redefining that network by increments, and a subnet-only rule // would read as an invitation to do exactly that. expect( rejectedNetworkKeys([ 'network.dmz.subnet', 'network.dmz.gateway', 'network.dmz.vlan', 'network.secure-mgmt.subnet', ]), ).toHaveLength(4); }); test('network.bridge is exempt — a container-service concept, not addressing', () => { // It is also the one network.* key with a schema default, so refusing it // would break `system init`. expect(rejectedNetworkKeys(['network.bridge'])).toEqual([]); }); test('everything else still writes', () => { expect(rejectedNetworkKeys(['dns.primary', 'ssh.public_key', 'public_dns.resolver'])).toEqual( [], ); }); test('the refusal says whose the namespace is, and what to do instead', () => { // A bare "rejected" would send a module author looking for a typo. The // message has to name the alternative, because there IS one: declare the // network and read it. const message = networkWriteRefusal(['network.dmz.subnet']); expect(message).toContain('network.dmz.subnet'); expect(message).toContain('requires.networks'); expect(message).toContain('$system:network..subnet'); // And the operator's own escape hatches, which are not closed. expect(message).toContain('celilo system config set'); expect(message).toContain('celilo system discover-network'); }); });