import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { activateFiles } from '../helpers/staged-files.js'; let fixture: Fixture; const town = 't6_zombie_town_1'; const farm = 't6_zombie_farm_1'; const t5 = 't5_zombie_theater_1'; const cfgPath = (server: string) => join(fixture.t6, server, 'configs/dedicated_zm.cfg'); beforeEach(async () => { vi.resetModules(); fixture = await newFixture([town, farm, t5]); vi.stubEnv('RCON_PASSWORD', ''); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); for (const name of [town, farm]) await mkdir(join(fixture.t6, name, 'configs'), { recursive: true }); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); describe('T6 config lifecycle in a mixed fleet', () => { it('preserves variants through beta, scope promotion, drift repair, and campaign rollback', async () => { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const { db } = await import('../../src/lib/server/db/index.js'); const previous = '// prior operator settings\r\nsv_voice 0\r\n'; await writeFile(cfgPath(town), previous); const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', content: Buffer.from('sv_voice 1\nsv_maprotation "map zm_buried"\n') }); expect(script.target_rel_path).toBe('dedicated_zm.cfg'); const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [town], waitForHealth: false, waitForMatch: false }); expect(await readFile(cfgPath(town), 'utf8')).toBe(previous); await activateFiles('beta', betaId); expect(await readFile(cfgPath(town), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit'); db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), betaId); db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId); const promoted = await promoteBeta(betaId); expect(new Set(promoted.landed)).toEqual(new Set([town, farm])); expect(existsSync(cfgPath(farm))).toBe(false); await activateFiles('campaign', promoted.deploymentId); const farmConfig = await readFile(cfgPath(farm), 'utf8'); expect(farmConfig).toContain('execgts zm_standard_farm.cfg map zm_transit'); expect(existsSync(join(fixture.t5, t5, 'configs/dedicated_sp.cfg'))).toBe(false); expect(existsSync(join(fixture.t6, farm, 'configs/dedicated_sp.cfg'))).toBe(false); expect(await scanServerDrift(farm)).toEqual([]); await rm(cfgPath(farm)); const drift = await runDriftDeployment({ servers: [farm] }); expect(drift.totalRestored).toBe(1); expect(existsSync(cfgPath(farm))).toBe(false); await activateFiles('campaign', drift.deploymentId!); expect(await readFile(cfgPath(farm), 'utf8')).toBe(farmConfig); const rollback = await rollbackPromoteDeployment(promoted.deploymentId); expect(await readFile(cfgPath(farm), 'utf8')).toBe(farmConfig); await activateFiles('campaign', rollback.deploymentId); expect(await readFile(cfgPath(town), 'utf8')).toBe(previous); expect(existsSync(cfgPath(farm))).toBe(false); }); it('restores the exact pre-beta T6 config and preserves seeded rulesets during drift repair', async () => { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js'); const previous = 'sv_voice 1\n'; await writeFile(cfgPath(town), previous); const settings = join(fixture.t6, town, 'runtime/plutonium/storage/t6/gamesettings'); await mkdir(settings, { recursive: true }); await writeFile(join(settings, 'zm_standard_town.cfg'), 'ui_zm_mapstartlocation town\n'); await writeFile(join(fixture.t6, town, 'configs/dedicated.cfg'), 'sv_voice 1\n'); const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', content: Buffer.from('sv_voice 0\n') }); const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [town], waitForHealth: false, waitForMatch: false }); await activateFiles('beta', betaId); const rollback = await rollbackBeta(betaId); await activateFiles('campaign', rollback.deploymentId!); expect(await readFile(cfgPath(town), 'utf8')).toBe(previous); expect(await scanServerDrift(town)).toEqual([]); await runDriftDeployment({ servers: [town] }); expect(await readFile(join(settings, 'zm_standard_town.cfg'), 'utf8')).toBe('ui_zm_mapstartlocation town\n'); }); it('reads the T6 game log and keeps log path traversal checks', async () => { const { resolveLogPath, gameLogKey, tailLog } = await import('../../src/lib/server/fleet/logs.js'); const logs = join(fixture.t6, town, 'logs'); await mkdir(logs, { recursive: true }); await writeFile(join(logs, 'games_zm.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n'); expect(resolveLogPath(town, gameLogKey(town))).toBe(resolve(logs, 'games_zm.log')); expect((await tailLog(resolveLogPath(town, gameLogKey(town))!, 10))?.lines).toContain('0:01 J;123;0;Player'); expect(resolveLogPath('t6_/../../escape', 'games_zm')).toBeNull(); }); });