import { putFileBlob } from '../../src/lib/server/db/file-blobs.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Database from 'better-sqlite3'; 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'; import { activateFiles } from '../helpers/staged-files.js'; let fixture: Fixture; const servers = ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']; beforeEach(async () => { vi.resetModules(); fixture = await newFixture(servers); vi.stubEnv('RCON_PASSWORD', ''); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); vi.unstubAllEnvs(); }); async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'map' = 'game') { const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { openBeta } = await import('../../src/lib/server/fleet/beta.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { script } = createScript({ name: 'Review test', kind, targetRelPath: kind === 'script' ? 'review.gsc' : undefined, scopeType: scope, scopeValue: scope === 'game' ? 't5' : 'zombie_theater', content: Buffer.from(kind === 'script' ? 'main(){}' : 'set sv_voice "1"\n') }); const version = getLatestVersion(script.id)!; const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [servers[0]], waitForHealth: false, waitForMatch: false }); await activateFiles('beta', betaId); db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId); db() .prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?") .run(new Date().toISOString(), betaId); return { betaId, script, version }; } const destination = () => join(fixture.t5, servers[1], 'runtime/plutonium/storage/t5/raw/scripts/sp/review.gsc'); describe('reviewed beta promotion', () => { it('upgrades existing campaign history without marking known snapshots pending', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); db() .prepare( "INSERT INTO deployments (id, action, started_at) VALUES ('legacy', 'promote', ?)" ) .run(new Date().toISOString()); db() .prepare( "INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)" ) .run(servers[0], putFileBlob(Buffer.from('original'), db())); closeDb(); const legacy = new Database(fixture.dbPath); try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); legacy.pragma('user_version = 0'); } finally { legacy.close(); } expect( db() .prepare( 'SELECT snapshot_pending, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files' ) .get() ).toEqual({ snapshot_pending: 0, content_before: Buffer.from('original') }); }); it.each(['script', 'baseline'] as const)( 'queues %s promotion without contacting additional host agents', async (kind) => { const { betaId } = await ready(kind); const { prepareNativeHost } = await import('../helpers/native-host.js'); await prepareNativeHost([servers[1]]); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( `INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at) SELECT 'launch',host_id,id,generation,'instance.create',?,'completed',created_at,created_at FROM native_instances WHERE server=?` ) .run(JSON.stringify({ rconPassword: 'assigned-password' }), servers[1]); const native = await import('../../src/lib/server/backends/native.js'); const request = vi .spyOn(native, 'nativeRequest') .mockRejectedValue(new Error('Agent unavailable')); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const result = await promoteBeta(betaId, previewBetaPromotion(betaId).token); expect(result.awaitingActivation).toBe(1); expect(request).not.toHaveBeenCalled(); expect( db() .prepare( 'SELECT snapshot_pending FROM deployment_files WHERE deployment_id = ? AND server = ?' ) .get(result.deploymentId, servers[1]) ).toEqual({ snapshot_pending: 1 }); if (kind === 'baseline') { const staged = db() .prepare( `SELECT b.content FROM staged_file_changes f JOIN file_blobs b ON b.sha256=f.content_hash WHERE f.source_kind='campaign' AND f.source_id=? AND f.server=?` ) .get(result.deploymentId, servers[1]) as { content: Buffer }; expect(staged.content.toString()).toContain( 'set rcon_password "assigned-password"' ); } } ); it.each([ { kind: 'script', before: 'changed while queued' }, { kind: 'baseline', before: 'configuration changed while queued' }, { kind: 'script', before: '' }, { kind: 'script', before: null } ] as const)( 'captures activation-time bytes and restores them: %j', async ({ kind, before }) => { const { betaId } = await ready(kind); const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js'); const path = kind === 'script' ? destination() : join(destOfKind(servers[1], 'config'), configFileOf(servers[1])); const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const result = await promoteBeta(betaId); const { db } = await import('../../src/lib/server/db/index.js'); const file = db() .prepare('SELECT id FROM deployment_files WHERE deployment_id = ? AND server = ?') .get(result.deploymentId, servers[1]) as { id: number }; const { GET: diff } = await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js'); const params = { id: result.deploymentId, fileId: String(file.id) }; expect((await diff({ params })).status).toBe(409); // An edit after confirmation must be the rollback target, not an older preview. if (before !== null) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, before); } await activateFiles('campaign', result.deploymentId); expect( db() .prepare( 'SELECT snapshot_pending, verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE id = ?' ) .get(file.id) ).toEqual({ snapshot_pending: 0, verb: before === null ? 'added' : 'updated', content_before: before === null ? null : Buffer.from(before) }); expect( db() .prepare('SELECT message FROM campaign_events WHERE deployment_id = ?') .all(result.deploymentId) ).toContainEqual({ message: `${servers[1]}: captured rollback snapshot for ${kind === 'script' ? 'review.gsc' : configFileOf(servers[1])}` }); expect((await (await diff({ params })).json()).before).toBe(before); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const rollback = await rollbackPromoteDeployment(result.deploymentId); await activateFiles('campaign', rollback.deploymentId); if (before === null) expect(existsSync(path)).toBe(false); else expect(await readFile(path, 'utf8')).toBe(before); } ); it('withdraws unactivated changes without needing snapshots, preserving existing files', async () => { const { betaId } = await ready(); await writeFile(destination(), 'untouched'); const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const result = await promoteBeta(betaId); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const rollback = await rollbackPromoteDeployment(result.deploymentId); expect(rollback.affectedServers).not.toContain(servers[1]); await activateFiles('campaign', rollback.deploymentId); expect(await readFile(destination(), 'utf8')).toBe('untouched'); }); it('blocks rollback without mutation when activation has not confirmed its snapshot', async () => { const { betaId } = await ready(); const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const result = await promoteBeta(betaId); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare("UPDATE staged_file_changes SET state = 'applying' WHERE source_id = ?") .run(result.deploymentId); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); await expect(rollbackPromoteDeployment(result.deploymentId)).rejects.toMatchObject({ kind: 'snapshot-pending' }); expect( db() .prepare('SELECT rolled_back_by FROM deployments WHERE id = ?') .get(result.deploymentId) ).toEqual({ rolled_back_by: null }); expect( db().prepare("SELECT COUNT(*) n FROM deployments WHERE action = 'rollback'").get() ).toEqual({ n: 0 }); }); it('previews exact versions and additional targets without writing, then links the activation campaign', async () => { const { betaId, script, version } = await ready(); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const preview = previewBetaPromotion(betaId); expect(preview.files[0]).toMatchObject({ id: script.id, versionId: version.id, sha256: version.sha256, servers: [...servers].sort() }); expect(preview.servers).toEqual( expect.arrayContaining([ expect.objectContaining({ name: servers[0], tested: true }), expect.objectContaining({ name: servers[1], tested: false }) ]) ); expect(previewBetaPromotion(betaId).token).toBe(preview.token); expect(existsSync(destination())).toBe(false); const result = await promoteBeta(betaId, preview.token); expect(result).toMatchObject({ awaitingActivation: 1, failedServers: [] }); expect(result.deploymentId).toContain('promote'); const { GET: campaign } = await import('../../src/routes/api/campaigns/[id]/+server.js'); const summary = await (await campaign({ params: { id: result.deploymentId } })).json(); expect(summary.files).toEqual( expect.arrayContaining([ expect.objectContaining({ server: servers[0], snapshot_pending: 0 }), expect.objectContaining({ server: servers[1], snapshot_pending: 1 }) ]) ); expect(existsSync(destination())).toBe(false); await activateFiles('campaign', result.deploymentId); expect(await readFile(destination(), 'utf8')).toBe('main(){}'); }); it('rejects changed scope membership before creating a campaign or writing extra files', async () => { const { betaId } = await ready('script', 'map'); const { previewBetaPromotion, promoteBeta, getBeta } = await import('../../src/lib/server/fleet/beta.js'); const { db } = await import('../../src/lib/server/db/index.js'); const preview = previewBetaPromotion(betaId); expect(preview.servers).toHaveLength(1); db().prepare('UPDATE servers SET map = ? WHERE name = ?').run('zombie_theater', servers[1]); await expect(promoteBeta(betaId, preview.token)).rejects.toMatchObject({ kind: 'preview-changed', details: expect.objectContaining({ servers: expect.arrayContaining([expect.objectContaining({ name: servers[1] })]) }) }); expect(existsSync(destination())).toBe(false); expect(getBeta(betaId)?.state).toBe('ready-promote'); expect(db().prepare('SELECT COUNT(*) n FROM deployments').get()).toEqual({ n: 0 }); await expect( promoteBeta(betaId, previewBetaPromotion(betaId).token) ).resolves.toMatchObject({ awaitingActivation: 1 }); }); it('includes effective config contents in the guard, including map-specific composition', async () => { const { betaId } = await ready('baseline'); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const { db } = await import('../../src/lib/server/db/index.js'); const preview = previewBetaPromotion(betaId); expect(preview.configWrites).toHaveLength(2); expect(new Set(preview.configWrites.map((write) => write.sha256)).size).toBe(2); db() .prepare('UPDATE servers SET map = ?, map_slug = ? WHERE name = ?') .run('zombie_moon', 'moon', servers[1]); await expect(promoteBeta(betaId, preview.token)).rejects.toMatchObject({ kind: 'preview-changed' }); expect(db().prepare('SELECT COUNT(*) n FROM deployments').get()).toEqual({ n: 0 }); }); it('requires the reviewed token at the HTTP boundary', async () => { const { betaId } = await ready(); const { POST, GET } = await import('../../src/routes/api/betas/[id]/promote/+server.js'); const response = await POST({ params: { id: String(betaId) }, request: new Request('http://localhost/promote', { method: 'POST', body: '{}' }) }); expect(response.status).toBe(400); const preview = await GET({ params: { id: String(betaId) } }); expect(preview.headers.get('cache-control')).toBe('no-store'); expect((await preview.json()).preview.token).toHaveLength(64); }); });