import { describe, expect, spyOn, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { type DbClient, getDb } from '../db/client'; import { modules } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import * as controlPlaneHealth from './control-plane-health'; import type { HealthCheckItem } from './health-runner'; import { deriveHealthStatus, nextModuleState, runModuleHealthCheck } from './health-runner'; /** * The D15 rule: a scheduled (monitor-driven) health check observes only and * never moves module lifecycle state; an operator-invoked one still does. * * These are the cases that regress invisibly — a wrong boolean here does not * throw, it just makes `celilo module list` flicker between INSTALLED and * VERIFIED every monitor interval. */ describe('deriveHealthStatus', () => { const item = (status: HealthCheckItem['status'], name = 'probe'): HealthCheckItem => ({ name, status, message: 'msg', }); test('fail dominates everything', () => { expect(deriveHealthStatus([item('pass'), item('fail'), item('warn')])).toBe('unhealthy'); }); test('warn without fail is degraded', () => { expect(deriveHealthStatus([item('pass'), item('warn')])).toBe('degraded'); }); test('measured passes with a skip are healthy — the audit carries the skip', () => { expect(deriveHealthStatus([item('pass'), item('skip')])).toBe('healthy'); }); test('only skips is no-checks — nothing was measured', () => { expect(deriveHealthStatus([item('skip'), item('skip')])).toBe('no-checks'); }); test('empty checks is healthy (the hook ran and reported nothing)', () => { expect(deriveHealthStatus([])).toBe('healthy'); }); }); describe('nextModuleState', () => { describe('operator-invoked run (unattended = false)', () => { test('passing check verifies an INSTALLED module', () => { expect(nextModuleState('INSTALLED', 'healthy', false)).toBe('VERIFIED'); }); test('a warning still verifies — degraded is not failure', () => { expect(nextModuleState('INSTALLED', 'degraded', false)).toBe('VERIFIED'); }); test('failing check un-verifies a VERIFIED module', () => { expect(nextModuleState('VERIFIED', 'unhealthy', false)).toBe('INSTALLED'); }); test('failing check on an already-INSTALLED module changes nothing', () => { expect(nextModuleState('INSTALLED', 'unhealthy', false)).toBeNull(); }); }); describe('scheduled run (unattended = true)', () => { test('failing check does NOT un-verify a VERIFIED module', () => { expect(nextModuleState('VERIFIED', 'unhealthy', true)).toBeNull(); }); test('passing check does NOT verify an INSTALLED module', () => { expect(nextModuleState('INSTALLED', 'healthy', true)).toBeNull(); }); test('no status moves state, whatever the module is currently in', () => { const states = ['INSTALLED', 'VERIFIED', 'ERROR', 'CONFIGURED'] as const; const statuses = ['healthy', 'degraded', 'unhealthy'] as const; for (const state of states) { for (const status of statuses) { expect(nextModuleState(state, status, true)).toBeNull(); } } }); }); }); // celilo#1363: a failed upgrade leaves state ERROR with an errorMessage. The // recovery path is an attended health check passing, which moves ERROR → // VERIFIED. The message must move with the state, or `module list` keeps // printing the old `Error:` line beside a healthy row. describe('runModuleHealthCheck — clearing a recorded failure (celilo#1363)', () => { test('an attended healthy check clears errorMessage along with the state', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'celilo-health-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); try { const db: DbClient = getDb(); db.insert(modules) .values({ id: 'celilo-mgmt', name: 'Control plane', sourcePath: join(tempDir, 'celilo-mgmt'), version: '1.0.0+5', state: 'ERROR', errorMessage: 'Upgrade to 1.0.0+6 failed to deploy: ssh: connection refused', manifestData: { celilo_contract: '1.0', id: 'celilo-mgmt', name: 'Control plane', version: '1.0.0', }, }) .run(); const checks: HealthCheckItem[] = [{ name: 'probe', status: 'pass', message: 'ok' }]; const spy = spyOn(controlPlaneHealth, 'controlPlaneHealthChecks').mockResolvedValue(checks); try { const result = await runModuleHealthCheck('celilo-mgmt', db, {}); expect(result.status).toBe('healthy'); } finally { spy.mockRestore(); } const row = db.select().from(modules).where(eq(modules.id, 'celilo-mgmt')).get(); if (!row) throw new Error('row missing after health check'); expect(row.state).toBe('VERIFIED'); expect(row.errorMessage).toBeNull(); } finally { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); } }); });