/** * Recurrence gate for ISS-0069: `config set` must never accept an * infrastructure-managed key and then have the deploy silently override it. * Framework-managed (`source: infrastructure`) keys are rejected at set time; * operator-settable keys are honored. */ 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 { type DbClient, getDb } from '../../db/client'; import { moduleConfigs, modules } from '../../db/schema'; import { resolveDeployPosture } from '../../services/deploy-posture'; import { resetTestDbPath } from '../../test-utils/db-path'; import { FRAMEWORK_CONFIG_KEYS, handleModuleConfigGet, handleModuleConfigSet, handleModuleConfigUnset, validateFrameworkConfigValue, } from './module-config'; import { pickUpgradePolicy } from './module-upgrade'; describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => { let tempDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-module-config-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: tempDir, version: '1.0.0', manifestData: { variables: { owns: [ { name: 'vmid', type: 'integer', source: 'infrastructure' }, { name: 'target_node', type: 'string', source: 'infrastructure' }, { name: 'app_port', type: 'integer' }, { name: 'public_ip', type: 'string', source: 'hook' }, ], }, }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); }); test('rejects an infrastructure-managed key (no silent accept-then-override)', async () => { const result = await handleModuleConfigSet(['testmod', 'vmid', '203']); expect(result.success).toBe(false); if (!result.success) { // The refusal now names the source rather than a bespoke // "infrastructure-managed" phrase, because `infrastructure` stopped being // the only refused source — every non-`user` source is refused, and each // gets guidance aimed at its own upstream. expect(result.error).toContain('derived by celilo (source: infrastructure)'); expect(result.error).toContain('not operator-settable'); } }); test('rejects target_node and points at the real lever', async () => { const result = await handleModuleConfigSet(['testmod', 'target_node', 'node3']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('not operator-settable'); } }); test('accepts a normal operator-settable key', async () => { const result = await handleModuleConfigSet(['testmod', 'app_port', '8080']); expect(result.success).toBe(true); }); // Hook-owned-state task 2.4 (design D2/D6): a `source: hook` variable is // written ONLY by the module's own hook via `context.config.set`. A hand set // value would be meaningless — the next hook run overwrites it — so the // command refuses rather than reporting success over nothing. test('rejects a hook-owned key and points at the accessor', async () => { const result = await handleModuleConfigSet(['testmod', 'public_ip', '203.0.113.7']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('source: hook'); expect(result.error).toContain('not operator-settable'); expect(result.error).toContain('context.config.set'); } // And no row may exist — a refusal that leaves the row behind is the // accept-then-override bug in a different coat. const rows = db.select().from(moduleConfigs).all(); expect(rows).toEqual([]); }); test('still rejects an undeclared key, and omits infra keys from the valid-keys hint', async () => { const result = await handleModuleConfigSet(['testmod', 'nope', 'x']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('Invalid config key'); expect(result.error).toContain('app_port'); expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint } }); // #515: these describe how celilo TREATS a module, so they must not depend on // the module having declared them. `testmod` declares neither — which is the // bug exactly: `upgrade_policy` was declared by NO module, so `always-safe` // (the only control over unattended-upgrade risk) could never be set. test('accepts upgrade_policy on a module that does not declare it', async () => { const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'always-safe']); expect(result.success).toBe(true); }); test('accepts auto_upgrade on a module that does not declare it', async () => { const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'true']); expect(result.success).toBe(true); }); test('rejects a mistyped policy instead of silently falling back to by-semver', async () => { // `pickUpgradePolicy` fails OPEN: an unrecognized value becomes `by-semver`, // which on a patch means fast posture and NO backup. Accepting this would // leave the operator believing the safe floor was armed. const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'alwayssafe']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('always-safe'); } }); test('rejects a non-boolean auto_upgrade', async () => { const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'yes']); expect(result.success).toBe(false); }); test('a per-module policy key is settable on a module whose manifest never mentions it', async () => { expect((await handleModuleConfigSet(['testmod', 'backup_schedule', '6h'])).success).toBe(true); const read = await handleModuleConfigGet(['testmod', 'backup_schedule']); expect(read.success).toBe(true); if (read.success) expect(read.message).toContain('6h'); }); test('unset removes the override so the module follows its manifest again', async () => { await handleModuleConfigSet(['testmod', 'backup_schedule', 'weekly']); const unset = await handleModuleConfigUnset(['testmod', 'backup_schedule']); expect(unset.success).toBe(true); expect((await handleModuleConfigGet(['testmod', 'backup_schedule'])).success).toBe(false); }); test('unsetting a key that was never set reports it and still succeeds', async () => { // `unset` states a desired end state. Failing on an already-clean one makes // it unusable from any script that cannot check first. const result = await handleModuleConfigUnset(['testmod', 'backup_schedule']); expect(result.success).toBe(true); if (result.success) expect(result.message).toContain('No override set'); }); test('the valid-keys hint advertises the celilo-managed keys', async () => { const result = await handleModuleConfigSet(['testmod', 'nope', 'x']); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('upgrade_policy'); expect(result.error).toContain('auto_upgrade'); } }); }); describe('validateFrameworkConfigValue (pure)', () => { test('passes through non-framework keys untouched', () => { expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull(); }); test('every framework key accepts at least its documented values', () => { const documented: Record = { auto_upgrade: ['true', 'false'], upgrade_policy: ['by-semver', 'always-safe', 'always-fast'], backup_schedule: ['hourly', 'daily', 'weekly', 'monthly', 'manual', '6h'], health_check_interval: ['15m', '1h', 'daily', 'manual'], backup_retention_count: ['1', '3', '30'], backup_retention_max_age_days: ['1', '30', '365'], }; // Every key must be covered, so adding one without deciding what it accepts // fails here rather than shipping unvalidated. expect(Object.keys(documented).sort()).toEqual(Object.keys(FRAMEWORK_CONFIG_KEYS).sort()); for (const [key, values] of Object.entries(documented)) { for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull(); } }); test('rejects an unlisted value', () => { expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed'); }); test('rejects a cadence the sweep that would serve it cannot run', () => { // The backup sweep rides an hourly tick; accepting `5m` would leave the // operator believing they configured something that can never happen. expect(validateFrameworkConfigValue('backup_schedule', '5m')).toContain('hourly'); // The alerting sweep ticks every five minutes, so the same value is fine there. expect(validateFrameworkConfigValue('health_check_interval', '5m')).toBeNull(); expect(validateFrameworkConfigValue('health_check_interval', '1m')).toContain('Allowed'); }); test('a retention of zero or below is refused rather than read as "keep nothing"', () => { // A `0` read as a bound would delete every backup the module has. for (const bad of ['0', '-1', '2.5', 'lots']) { expect(validateFrameworkConfigValue('backup_retention_count', bad)).toContain('1 or greater'); expect(validateFrameworkConfigValue('backup_retention_max_age_days', bad)).toContain( '1 or greater', ); } }); test('a misspelled cadence is refused, and says it was not coerced', () => { const error = validateFrameworkConfigValue('backup_schedule', 'dailyy'); expect(error).toContain('named period'); expect(error).toContain('Rejected rather than coerced'); }); }); // The point of the whole control: with always-safe set, a PATCH upgrade — which // by default is fast posture and skips the pre-deploy backup — becomes safe. // #515 made this unreachable, so this asserts the chain end to end. describe('always-safe actually changes posture on a patch (#515)', () => { test('patch is fast by default, safe once always-safe is chosen', () => { const patch = { installed: '1.0.3', next: '1.0.4' }; expect( resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy(undefined) }).posture, ).toBe('fast'); expect( resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy('always-safe') }).posture, ).toBe('safe'); }); });