import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; // Mutable state the mocked NodeRegistry reads (same harness as // filesystem-backup.test.ts) so FileSystemService can be constructed against // temp directories. const mockState = { composeDir: '' }; vi.mock('../services/NodeRegistry', () => ({ NodeRegistry: { getInstance: () => ({ getComposeDir: () => mockState.composeDir, getDefaultNodeId: () => 1, }), }, })); import { aggregateRollbackOverall, buildRollbackItems, type RollbackInputs, } from '../services/updateGuard/readiness'; import type { ContainerProbe } from '../services/updateGuard/types'; const NOW = 1_750_000_000_000; const baseInputs = (over: Partial = {}): RollbackInputs => ({ backup: { exists: true, timestamp: NOW - 3_600_000 }, envSummary: { exists: true, envPresent: true, keys: ['DB_HOST', 'DB_PASS'] }, stackHasEnv: true, rollbackTarget: { target: 'nginx:1.27.1', moving: false }, lastDeployAt: NOW - 3_600_000, containers: [{ name: 'app-web-1', state: 'running', health: 'healthy', exitCode: null, hasHealthcheck: true, restartPolicy: 'unless-stopped', mounts: ['volume app_data'], }], recoveryGeneration: { exists: false }, policyEligibility: null, managedInputs: { covered: true, detail: 'Exact authored-project coverage includes 1 managed path(s).' }, ...over, }); const itemById = (inputs: RollbackInputs, id: string) => buildRollbackItems(inputs, NOW).find(i => i.id === id)!; describe('buildRollbackItems', () => { it('reports the full set of rollback readiness items', () => { const items = buildRollbackItems(baseInputs(), NOW); expect(items.map(i => i.id)).toEqual([ 'recovery_generation', 'compose_source', 'env_keys', 'previous_images', 'last_deploy', 'healthchecks', 'policy_eligibility', 'managed_inputs', 'volume_data', ]); }); it('marks the volume row not_covered unconditionally and names the mounts', () => { const item = itemById(baseInputs(), 'volume_data'); expect(item.state).toBe('not_covered'); expect(item.detail).toContain('not included in recovery generations'); expect(item.detail).toContain('volume app_data'); expect(item.detail).toMatch(/managed authored project/i); const noMounts = itemById(baseInputs({ containers: [] }), 'volume_data'); expect(noMounts.state).toBe('not_covered'); const dockerDown = itemById(baseInputs({ containers: 'error' }), 'volume_data'); expect(dockerDown.state).toBe('not_covered'); }); it('exposes env coverage as names only', () => { const item = itemById(baseInputs(), 'env_keys'); expect(item.state).toBe('ready'); expect(item.detail).toContain('2 variable names'); expect(item.detail).not.toContain('DB_HOST'); expect(item.detail).not.toContain('DB_PASS'); }); it('treats a stack without an env file as covered', () => { const item = itemById(baseInputs({ envSummary: { exists: true, envPresent: false, keys: [] }, stackHasEnv: false, }), 'env_keys'); expect(item.state).toBe('ready'); expect(item.detail).toContain('no env file'); }); it('flags an env file the backup predates', () => { const item = itemById(baseInputs({ envSummary: { exists: true, envPresent: false, keys: [] }, stackHasEnv: true, }), 'env_keys'); expect(item.state).toBe('missing'); }); it('marks the previous image unknown when no rollback target is known', () => { expect(itemById(baseInputs({ rollbackTarget: { target: null, moving: false } }), 'previous_images').state).toBe('unknown'); expect(itemById(baseInputs({ rollbackTarget: 'error' }), 'previous_images').state).toBe('unknown'); const known = itemById(baseInputs(), 'previous_images'); expect(known.state).toBe('ready'); expect(known.detail).toContain('nginx:1.27.1'); }); it('treats a moving tag as ready because full-stack updates retain the prior image ID', () => { const item = itemById(baseInputs({ rollbackTarget: { target: 'nginx:latest', moving: true } }), 'previous_images'); expect(item.state).toBe('ready'); expect(item.detail).toContain('moving image tag'); expect(item.detail).toContain('nginx:latest'); expect(item.detail).toContain('recovery window'); }); it('does not mistake an image literally named error for a failed preview', () => { const item = itemById(baseInputs({ rollbackTarget: { target: 'error', moving: false } }), 'previous_images'); expect(item.state).toBe('ready'); expect(item.detail).toContain('error'); }); it('reports last deploy and healthcheck coverage', () => { expect(itemById(baseInputs(), 'last_deploy').state).toBe('ready'); expect(itemById(baseInputs({ lastDeployAt: null }), 'last_deploy').state).toBe('missing'); expect(itemById(baseInputs(), 'healthchecks').state).toBe('ready'); const none: ContainerProbe[] = [{ name: 'a', state: 'running', health: null, exitCode: null, hasHealthcheck: false, restartPolicy: null, mounts: [], }]; expect(itemById(baseInputs({ containers: none }), 'healthchecks').state).toBe('missing'); }); }); it('marks policy eligibility blocked and warning states', () => { expect(itemById(baseInputs({ recoveryGeneration: { exists: true, shortId: 'abc' }, policyEligibility: 'prohibited', }), 'policy_eligibility').state).toBe('blocked'); expect(itemById(baseInputs({ recoveryGeneration: { exists: true, shortId: 'abc' }, policyEligibility: 'eligible_with_warning', }), 'policy_eligibility').state).toBe('warning'); }); it('lets recovery_generation supersede the legacy backup slot for compose_source', () => { const items = buildRollbackItems(baseInputs({ backup: { exists: false, timestamp: null }, recoveryGeneration: { exists: true, shortId: 'deadbeefcaf0' }, policyEligibility: 'eligible', }), NOW); expect(items.find(i => i.id === 'compose_source')!.state).toBe('ready'); expect(items.find(i => i.id === 'recovery_generation')!.state).toBe('ready'); }); describe('aggregateRollbackOverall', () => { it('is ready when compose, env, and previous image are all covered', () => { expect(aggregateRollbackOverall(buildRollbackItems(baseInputs(), NOW))).toBe('ready'); }); it('is not_ready without a backup slot', () => { const items = buildRollbackItems(baseInputs({ backup: { exists: false, timestamp: null }, envSummary: { exists: false, envPresent: false, keys: [] }, }), NOW); expect(aggregateRollbackOverall(items)).toBe('not_ready'); }); it('is partial when the previous image tag is unknown', () => { const items = buildRollbackItems(baseInputs({ rollbackTarget: { target: null, moving: false } }), NOW); expect(aggregateRollbackOverall(items)).toBe('partial'); }); it('is ready when the rollback target is a moving tag (image ID capture covers it)', () => { const items = buildRollbackItems(baseInputs({ rollbackTarget: { target: 'nginx:latest', moving: true } }), NOW); expect(aggregateRollbackOverall(items)).toBe('ready'); }); it('is partial when env coverage is missing', () => { const items = buildRollbackItems(baseInputs({ envSummary: { exists: true, envPresent: false, keys: [] }, stackHasEnv: true, }), NOW); expect(aggregateRollbackOverall(items)).toBe('partial'); }); it('never gates on the volume or healthcheck disclosures', () => { const items = buildRollbackItems(baseInputs({ containers: 'error' }), NOW); expect(aggregateRollbackOverall(items)).toBe('ready'); }); it('is not_ready when policy eligibility is blocked', () => { const items = buildRollbackItems(baseInputs({ recoveryGeneration: { exists: true, shortId: 'abc' }, policyEligibility: 'prohibited', }), NOW); expect(aggregateRollbackOverall(items)).toBe('not_ready'); }); it('is at best partial when policy eligibility is warning or unknown', () => { const warn = buildRollbackItems(baseInputs({ recoveryGeneration: { exists: true, shortId: 'abc' }, policyEligibility: 'eligible_with_warning', }), NOW); expect(aggregateRollbackOverall(warn)).toBe('partial'); const unk = buildRollbackItems(baseInputs({ recoveryGeneration: { exists: true, shortId: 'abc' }, policyEligibility: 'unknown', }), NOW); expect(aggregateRollbackOverall(unk)).toBe('partial'); }); }); describe('FileSystemService.getBackupEnvSummary', () => { let tmpDir: string; let composeDir: string; let originalDataDir: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-envsum-')); composeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-envsum-compose-')); mockState.composeDir = composeDir; originalDataDir = process.env.DATA_DIR; process.env.DATA_DIR = tmpDir; }); afterEach(() => { if (originalDataDir === undefined) delete process.env.DATA_DIR; else process.env.DATA_DIR = originalDataDir; fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(composeDir, { recursive: true, force: true }); }); async function getService() { const { FileSystemService } = await import('../services/FileSystemService'); return FileSystemService.getInstance(); } function writeBackupEnv(stackName: string, content: string | null) { const dir = path.join(tmpDir, 'backups', '1', stackName); fs.mkdirSync(dir, { recursive: true }); if (content !== null) fs.writeFileSync(path.join(dir, '.env'), content, 'utf-8'); } it('returns key names only, never values', async () => { writeBackupEnv('web', 'DB_HOST=db.internal\nDB_PASS=s3cret-value\n# comment\nEMPTY=\n INDENTED=ok\nnot a var line\n'); const svc = await getService(); const summary = await svc.getBackupEnvSummary('web'); expect(summary).toEqual({ exists: true, envPresent: true, keys: ['DB_HOST', 'DB_PASS', 'EMPTY', 'INDENTED'], }); expect(JSON.stringify(summary)).not.toContain('s3cret-value'); expect(JSON.stringify(summary)).not.toContain('db.internal'); }); it('reports a backup without an env file', async () => { writeBackupEnv('web', null); const svc = await getService(); expect(await svc.getBackupEnvSummary('web')).toEqual({ exists: true, envPresent: false, keys: [] }); }); it('reports a missing backup slot', async () => { const svc = await getService(); expect(await svc.getBackupEnvSummary('web')).toEqual({ exists: false, envPresent: false, keys: [] }); }); it('rejects traversal-shaped stack names without touching the filesystem', async () => { const svc = await getService(); expect(await svc.getBackupEnvSummary('../../etc')).toEqual({ exists: false, envPresent: false, keys: [] }); expect(await svc.getBackupEnvSummary('..')).toEqual({ exists: false, envPresent: false, keys: [] }); }); it('propagates a non-ENOENT env read failure instead of reporting "no env in backup"', async () => { // An unreadable .env (here: a directory, EISDIR) must throw so callers // degrade the item to unknown rather than falsely claiming the backup // contains no env file. const dir = path.join(tmpDir, 'backups', '1', 'web'); fs.mkdirSync(path.join(dir, '.env'), { recursive: true }); const svc = await getService(); await expect(svc.getBackupEnvSummary('web')).rejects.toThrow(); }); });