/** * End-to-end equivalence test for openspec/specs/management-as-module/spec.md Phase 2. * * The spec calls for "celilo system apply-config" to write the same * systemConfig state that the legacy `celilo system init` produces, * just without the operator-facing framing. This test runs both * paths against a pair of isolated DBs and asserts the resulting * systemConfig rows match. * * Catches the bug class where a future change to system-init (e.g. * adding a side-effect or gateway computation) drifts away from * what apply-config writes. * * ── One deliberate DIVERGENCE, added by `network-declaration` ── * * The two surfaces are no longer equivalent for `network.*` keys, and that is * the point rather than drift. `system init` is the OPERATOR's surface and may * still set addressing; `apply-config` is the AUTOMATION surface a module hook * shells out to, and networks are celilo's to define, not a module's * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * So the equivalence below is asserted over everything EXCEPT addressing, and a * separate test asserts the refusal — because "these two agree" and "this one * refuses" are both properties worth keeping, and collapsing them would lose * whichever was written second. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getDb } from '../../db/client'; import { loadExistingConfiguration } from '../../services/system-init'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleSystemApplyConfig } from './system-apply-config'; import { handleSystemInit } from './system-init'; describe('system apply-config equivalence with system init --accept-defaults', () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-')); }); afterEach(() => { // Reset to the scratch path, never restore: the saved value may be // another suite's temp database, or unset — which sends the next // var-less reader to the operator's real celilo.db (celilo#1315). resetTestDbPath(); rmSync(tmpDir, { recursive: true, force: true }); }); test('apply-config writes the same systemConfig keys as system init --accept-defaults', async () => { // No `network.*` here: apply-config refuses those now, deliberately. // Everything else must still land identically through both paths. const overrides = [ 'dns.primary=9.9.9.9', 'dns.fallback=1.1.1.1 8.8.8.8', 'ssh.public_key=ssh-ed25519 AAAA== test@equivalence', ]; // Path A: legacy system init --accept-defaults. process.env.CELILO_DB_PATH = join(tmpDir, 'a.db'); const aResult = await handleSystemInit(overrides, { 'accept-defaults': true }); expect(aResult.success).toBe(true); const aSnapshot = loadExistingConfiguration(getDb()); // Path B: new apply-config. process.env.CELILO_DB_PATH = join(tmpDir, 'b.db'); const bResult = await handleSystemApplyConfig(overrides); expect(bResult.success).toBe(true); const bSnapshot = loadExistingConfiguration(getDb()); // The two paths should write the same set of keys (gateways are // auto-computed from subnets in both, since both go through // initializeSystem) and the same values. expect(Object.keys(bSnapshot).sort()).toEqual(Object.keys(aSnapshot).sort()); for (const key of Object.keys(aSnapshot)) { expect({ key, value: bSnapshot[key] }).toEqual({ key, value: aSnapshot[key] }); } }); test('the operator surface may still set addressing; the automation surface may not', async () => { // Same key, same value, two surfaces, two answers. `system init` is the // operator saying what the topology is; `apply-config` is a module asking to // decide it. Only the first is an authority over the network namespace. process.env.CELILO_DB_PATH = join(tmpDir, 'operator.db'); const viaInit = await handleSystemInit(['network.dmz.subnet=10.99.10.0/24'], { 'accept-defaults': true, }); expect(viaInit.success).toBe(true); expect(loadExistingConfiguration(getDb())['network.dmz.subnet']).toBe('10.99.10.0/24'); process.env.CELILO_DB_PATH = join(tmpDir, 'automation.db'); const viaApply = await handleSystemApplyConfig(['network.dmz.subnet=10.99.10.0/24']); expect(viaApply.success).toBe(false); if (!viaApply.success) { expect(viaApply.error).toContain('requires.networks'); } }); test('a refused network key takes the whole write with it — no partial application', async () => { // Half-applying would be worse than refusing: the caller sees an error and // the box is left in a state neither surface intended. process.env.CELILO_DB_PATH = join(tmpDir, 'partial.db'); const result = await handleSystemApplyConfig([ 'dns.primary=9.9.9.9', 'network.dmz.subnet=10.99.10.0/24', ]); expect(result.success).toBe(false); expect(loadExistingConfiguration(getDb())['dns.primary']).toBeUndefined(); }); test('apply-config refuses to run with no overrides (unlike init which has defaults)', async () => { process.env.CELILO_DB_PATH = join(tmpDir, 'empty.db'); const result = await handleSystemApplyConfig([]); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('No config values supplied'); } }); test('apply-config reports malformed positional args clearly', async () => { process.env.CELILO_DB_PATH = join(tmpDir, 'bad.db'); const result = await handleSystemApplyConfig(['not-a-pair', 'dns.primary=1.1.1.1']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('Expected key=value'); expect(result.error).toContain('not-a-pair'); } }); test('apply-config writes report the number of values applied', async () => { process.env.CELILO_DB_PATH = join(tmpDir, 'count.db'); const result = await handleSystemApplyConfig(['dns.primary=1.1.1.1', 'network.bridge=vmbr9']); expect(result.success).toBe(true); if (result.success) { // initializeSystem() merges with defaults + computed gateways, // so the reported count is the FULL config size — not just our // two overrides. The exact count depends on getDefaultConfiguration // and any auto-computed gateways; assert it's at least 2. expect(result.message).toMatch(/Applied \d+ config value\(s\)/); } }); });