import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; const server = 't5_zombie_theater_1'; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([server]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); }); afterEach(async () => { await fixture.cleanup(); }); async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean) { const { db } = await import('../../src/lib/server/db/index.js'); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { destOfKind } = await import('../../src/lib/server/config.js'); const { sha256Buffer } = await import('../../src/lib/server/fleet/files.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const before = Buffer.from(kind === 'baseline' ? 'sv_voice 0\n' : 'original'); const after = Buffer.from(kind === 'baseline' ? 'sv_voice 1\n' : 'promoted'); const relPath = kind === 'baseline' ? 'dedicated_sp.cfg' : kind === 'plugin' ? 'managed.dll' : 'managed.gsc'; const { script, version: prior } = createScript({ name: 'managed', kind, targetRelPath: relPath, scopeType: 'game', scopeValue: 't5', content: before }); const version = uploadNewVersion(script.id, after); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES ('promote', 'promote', ?, 'ui')").run(new Date().toISOString()); db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('promote', ?, 1, 'pending')").run(server); db().prepare("INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)") .run(script.id, version.id, prior.id, kind); const file = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), before, after); const path = join(destOfKind(server, kind), relPath); await mkdir(dirname(path), { recursive: true }); await writeFile(path, staged ? before : after); if (staged) queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind, relPath, content: after, scriptVersionId: version.id, inventorySource: 'promote:promote', deploymentFileId: Number(file.lastInsertRowid) }); return { path, before, after, script, prior }; } async function applyCampaign(id: string) { const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { applyStagedFilesForOperation } = await import('../../src/lib/server/fleet/staged-files.js'); const operation = requestRestart(server, { kind: 'campaign', id }); applyStagedFilesForOperation(operation.id, server); return operation; } describe('deferred promotion rollback', () => { it('withdraws an unapplied promotion without restoring or restarting its server', async () => { const { path, before } = await promotion('script', true); const { db } = await import('../../src/lib/server/db/index.js'); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { applyStagedFilesForOperation, isFileApplied } = await import('../../src/lib/server/fleet/staged-files.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const operation = requestRestart(server, { kind: 'campaign', id: 'promote' }); const rollback = await rollbackPromoteDeployment('promote'); expect(rollback.affectedServers).toEqual([]); expect(await readFile(path)).toEqual(before); expect(isFileApplied('campaign', 'promote', server, 'script', 'managed.gsc')).toBe(false); expect(db().prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = 'promote'").get()).toEqual({ restart_state: 'skipped' }); expect(db().prepare('SELECT * FROM deployment_servers WHERE deployment_id = ?').all(rollback.deploymentId)).toEqual([]); applyStagedFilesForOperation(operation.id, server); expect(await readFile(path)).toEqual(before); }); it.each(['script', 'plugin', 'baseline'] as const)('queues restoration of applied %s bytes until rollback activation', async (kind) => { const { path, before, after, prior } = await promotion(kind, true); await applyCampaign('promote'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const result = await rollbackPromoteDeployment('promote'); expect(result.affectedServers).toEqual([server]); expect(await readFile(path)).toEqual(after); await applyCampaign(result.deploymentId); expect(await readFile(path)).toEqual(before); expect(getServerScript(server, kind === 'baseline' ? 'dedicated_sp.cfg' : kind === 'plugin' ? 'managed.dll' : 'managed.gsc')?.script_version_id).toBe(prior.id); }); it('restores only applied files when a promotion was partly installed', async () => { const { path, before } = await promotion('script', true); await applyCampaign('promote'); const { db } = await import('../../src/lib/server/db/index.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after) VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, Buffer.from('queued')); queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind: 'script', relPath: 'never.gsc', content: Buffer.from('queued'), inventorySource: 'promote:promote', deploymentFileId: Number(pending.lastInsertRowid) }); const result = await rollbackPromoteDeployment('promote'); expect(db().prepare('SELECT rel_path FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId)) .toEqual([{ rel_path: 'managed.gsc' }]); await applyCampaign(result.deploymentId); expect(await readFile(path)).toEqual(before); expect(existsSync(join(dirname(path), 'never.gsc'))).toBe(false); }); it('keeps another campaign eligible when withdrawing a shared activation source', async () => { const { path, before } = await promotion('script', true); const { db } = await import('../../src/lib/server/db/index.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES ('other', 'drift', ?, 'ui')").run(new Date().toISOString()); db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('other', ?, 1, 'pending')").run(server); queueFileChange({ sourceKind: 'campaign', sourceId: 'other', server, kind: 'script', relPath: 'other.gsc', content: Buffer.from('other campaign'), inventorySource: 'drift:other' }); const shared = requestRestart(server, { kind: 'campaign', id: 'promote' }); expect(requestRestart(server, { kind: 'campaign', id: 'other' }).id).toBe(shared.id); await rollbackPromoteDeployment('promote'); await applyCampaign('other'); expect(await readFile(path)).toEqual(before); expect(await readFile(join(dirname(path), 'other.gsc'), 'utf8')).toBe('other campaign'); }); it('stages rollback for legacy promotions with no deferred file records', async () => { const { path, before, after } = await promotion('plugin', false); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const result = await rollbackPromoteDeployment('promote'); expect(result.affectedServers).toEqual([server]); expect(await readFile(path)).toEqual(after); await applyCampaign(result.deploymentId); expect(await readFile(path)).toEqual(before); }); });