import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { Mock } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { create as tarCreate } from 'tar'; import { type DbClient, getDb } from '../../db/client'; import { type ModuleState, modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { RegistryClient } from '../../registry/client'; import * as moduleDeploy from '../../services/module-deploy'; import { resetTestDbPath } from '../../test-utils/db-path'; import { type PollCandidate, isPollInvocation, needsPreUpgradeBackup, needsSystemStateSnapshot, pickAutoUpgrade, pickUpgradePolicy, selectPollTargets, upgradeOneModule, } from './module-upgrade'; /** Minimal manifest fixture; only `hooks` matters for the backup gate. */ function manifest(hooks?: ModuleManifest['hooks']): ModuleManifest { return { id: 'forgejo', name: 'Forgejo', version: '0.3.0', hooks } as ModuleManifest; } const withBackupHook = manifest({ on_backup: { script: './scripts/backup.ts', timeout: 300000 } }); const noBackupHook = manifest({ on_install: { script: './scripts/setup.ts', timeout: 180000 } }); describe('pickUpgradePolicy (ISS-0138 — operator config, else by-semver)', () => { test('operator config decides', () => { expect(pickUpgradePolicy('always-safe')).toBe('always-safe'); expect(pickUpgradePolicy('always-fast')).toBe('always-fast'); }); test('defaults to by-semver when unset', () => { expect(pickUpgradePolicy(undefined)).toBe('by-semver'); }); test('an unknown value falls back to by-semver (no crash on bad input)', () => { expect(pickUpgradePolicy('bogus')).toBe('by-semver'); }); }); describe('pickAutoUpgrade (ISS-0139 — opt-in via operator config, default off)', () => { test('config value (string or boolean) decides', () => { expect(pickAutoUpgrade('true')).toBe(true); expect(pickAutoUpgrade('false')).toBe(false); expect(pickAutoUpgrade(true)).toBe(true); expect(pickAutoUpgrade(false)).toBe(false); }); test('defaults to OFF (opt-in) when unset', () => { expect(pickAutoUpgrade(undefined)).toBe(false); }); }); // The dispatcher spawns a subprocess handler as ` ` // (openspec/specs/event-bus/spec.md), so the registry-poll subscription's // handler arrives as `celilo module upgrade --poll 5517`. Without --poll the // event id landed in the module-name slot and every 15m tick died with // "Module not found: 5517" — the poll never ran on celilo-mgr for weeks. describe('isPollInvocation (the CD poll must survive the appended event id)', () => { test('--poll wins over a positional (the dispatcher-appended event id)', () => { expect(isPollInvocation(['5517'], { poll: true })).toBe(true); }); test('bare `module upgrade` is still the poll', () => { expect(isPollInvocation([], {})).toBe(true); }); test('a named module without --poll is a single-module upgrade', () => { expect(isPollInvocation(['lunacycle'], {})).toBe(false); }); }); describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)', () => { const base = (over: Partial): PollCandidate => ({ moduleId: 'm', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true, inError: false, ...over, }); test('selects opted-in modules with a newer version', () => { expect( selectPollTargets([base({ moduleId: 'caddy', installed: '1.0.0', latest: '1.1.0' })]), ).toEqual([{ moduleId: 'caddy', from: '1.0.0', to: '1.1.0' }]); }); test('skips modules not opted in', () => { expect(selectPollTargets([base({ autoUpgrade: false })])).toEqual([]); }); test('skips modules already up to date or ahead', () => { expect(selectPollTargets([base({ installed: '1.0.1', latest: '1.0.1' })])).toEqual([]); expect(selectPollTargets([base({ installed: '1.0.2', latest: '1.0.1' })])).toEqual([]); }); test('skips modules absent from the registry (latest=null)', () => { expect(selectPollTargets([base({ latest: null })])).toEqual([]); }); // celilo#1363: the poll burned five attempts redeploying celilo-website // against a failure that never went away. A module in ERROR stays skipped // until an attended health check clears it. test('skips modules in ERROR state (a failed upgrade deploy)', () => { expect(selectPollTargets([base({ inError: true })])).toEqual([]); }); test('picks only the eligible subset from a mixed set', () => { const targets = selectPollTargets([ base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }), base({ moduleId: 'b', installed: '1.0.0', latest: '2.0.0', autoUpgrade: false }), base({ moduleId: 'c', installed: '1.0.0', latest: null, autoUpgrade: true }), base({ moduleId: 'd', installed: '1.0.0', latest: '1.0.0', autoUpgrade: true }), ]); expect(targets.map((t) => t.moduleId)).toEqual(['a']); }); }); // A 0.x minor/major bump can migrate the celilo DB schema; the system_state // snapshot is what makes a later `apt install celilo=` (or dpkg -i from // the apt cache) a real rollback instead of a gamble. Patch/revision bumps // don't migrate, so they stay fast. describe('needsSystemStateSnapshot (safe posture snapshots the celilo DB before mutating)', () => { test('safe posture (minor/major) requires the snapshot', () => { expect(needsSystemStateSnapshot('safe')).toBe(true); }); test('fast posture (patch/revision) skips the snapshot', () => { expect(needsSystemStateSnapshot('fast')).toBe(false); }); }); describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifest)', () => { // The regression: the upgrade that FIRST adds on_backup must still back up. // The installed version lacked the hook; the target (passed here) adds it. test('safe posture + target adds on_backup → backs up', () => { expect(needsPreUpgradeBackup('safe', withBackupHook)).toBe(true); }); test('safe posture + target has no on_backup → does not back up', () => { expect(needsPreUpgradeBackup('safe', noBackupHook)).toBe(false); }); test('fast posture (patch/revision) never backs up, even with on_backup', () => { expect(needsPreUpgradeBackup('fast', withBackupHook)).toBe(false); }); test('safe posture + no hooks block at all → does not back up', () => { expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false); }); }); // celilo#1363: fetchAndUpdate commits the new version to the store BEFORE the // deploy runs, so a failed deploy left a new version recorded beside a stale // VERIFIED state and a frozen updatedAt, and the audit read the module as // fine. Option A: the row must say what happened — state ERROR with the // error, and updatedAt moving whenever the version moves. describe('upgradeOneModule — a failed deploy records the failure in the row (celilo#1363)', () => { const FROZEN = new Date('2026-04-25T16:26:31Z'); let tempDir: string; let installedDir: string; let db: DbClient; let getIndexSpy: Mock<(name: string) => Promise>; let downloadSpy: Mock<(name: string, vers: string, cksum?: string) => Promise>; let deploySpy: Mock; beforeEach(async () => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; installedDir = join(tempDir, 'installed', 'testmod'); mkdirSync(installedDir, { recursive: true }); writeFileSync( join(installedDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n', ); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: installedDir, version: '1.0.0+5', state: 'VERIFIED', updatedAt: FROZEN, manifestData: { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', }, }) .run(); // Build the registry package for a patch bump (1.0.0+5 → 1.0.0+6). // Patch/revision → fast posture, so no snapshot and no backup are needed // on the path to the deploy. const srcDir = join(tempDir, 'pkg-src'); mkdirSync(srcDir, { recursive: true }); writeFileSync( join(srcDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n', ); const pkgPath = join(tempDir, 'testmod-1.0.0.netapp'); await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']); const pkgBytes = new Uint8Array(await Bun.file(pkgPath).arrayBuffer()); getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([ { name: 'testmod', vers: '1.0.0+6', deps: [], cksum: 'abc', yanked: false }, ]); downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(pkgBytes.buffer); deploySpy = spyOn(moduleDeploy, 'deployModule').mockResolvedValue({ success: false, error: 'ssh: connection refused', phases: {}, }); }); afterEach(() => { getIndexSpy.mockRestore(); downloadSpy.mockRestore(); deploySpy.mockRestore(); rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; }); test('a deploy failure leaves state ERROR, the new version, and a moved updatedAt', async () => { const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get(); if (!mod) throw new Error('seed row missing'); const result = await upgradeOneModule(mod, '1.0.0+6', new RegistryClient(''), db, {}); expect(result.success).toBe(false); const row = db.select().from(modules).where(eq(modules.id, 'testmod')).get(); if (!row) throw new Error('row missing after upgrade'); expect(row.version).toBe('1.0.0+6'); expect(row.state).toBe('ERROR'); expect(row.errorMessage).toContain('1.0.0+6'); expect(row.errorMessage).toContain('ssh: connection refused'); expect(row.updatedAt?.getTime()).toBeGreaterThan(FROZEN.getTime()); }); });