import { prepareNativeHost } from '../helpers/native-host.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { access } from 'node:fs/promises'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(), rconStatusResult: vi.fn() })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); let fixture: Fixture; const otherServer = 't5_zombie_cosmodrome_1'; const server = 't5_zombie_theater_1'; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([otherServer, server]); vi.stubEnv('RCON_PASSWORD', 'test'); const { db } = await import('../../src/lib/server/db/index.js'); for (const [index, name] of [otherServer, server].entries()) { const map = name === server ? 'theater' : 'cosmodrome'; db() .prepare( `INSERT INTO servers (name, game, map, game_mode, map_slug, game_port, host_dir) VALUES (?, 't5', ?, 'zombie', ?, ?, ?)` ) .run(name, 'zombie_' + map, map, 28960 + index, join(fixture.t5, name)); } await prepareNativeHost([otherServer, server]); const { mockAgentFiles } = await import('../helpers/agent-files.js'); await mockAgentFiles(); const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconGetPlayerCount).mockReset().mockResolvedValue(0); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); async function attachBeta(state = 'running', outcome?: string, deleted = false) { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { script, version } = createScript({ name: 'Protected beta', targetRelPath: 'protected.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('beta content') }); const id = Number( db() .prepare( `INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', ?)` ) .run(script.id, version.id, state).lastInsertRowid ); db() .prepare( `INSERT INTO beta_servers (beta_id, server, had_original, stage, server_deleted_at) VALUES (?, ?, 0, ?, ?)` ) .run(id, server, outcome === 'failed' ? 'failed' : 'ready', deleted ? '2026-09-01' : null); if (outcome) db() .prepare('INSERT INTO beta_test_plans (beta_id, plan, outcome) VALUES (?, ?, ?)') .run(id, '{}', outcome); return id; } async function expectServerPreserved() { const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind LIKE 'instance.%'").all() ).toEqual([]); expect(db().prepare('SELECT name FROM servers WHERE name = ?').get(server)).toEqual({ name: server }); await expect(access(join(fixture.t5, server))).resolves.toBeUndefined(); } describe('active beta destruction guard', () => { it.each([ { state: 'running', outcome: undefined }, { state: 'ready-promote', outcome: 'passed' }, { state: 'running', outcome: 'paused' }, { state: 'running', outcome: 'failed' } ])('rejects destruction for $state with test outcome $outcome', async ({ state, outcome }) => { const id = await attachBeta(state, outcome); const { destroyServer } = await import('../../src/lib/server/fleet/create.js'); const { db } = await import('../../src/lib/server/db/index.js'); const result = destroyServer(server); expect(result).toMatchObject({ ok: false, kind: 'conflict' }); if (result.ok) throw new Error('Expected active beta conflict'); expect(result.message).toContain(String(id)); expect(result.message).toMatch(/promot/i); expect(result.message).toMatch(/roll.?back/i); expect( db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all() ).toEqual([]); await expectServerPreserved(); }); it('returns HTTP 409 with actionable beta information and does not kick the worker', async () => { const id = await attachBeta(); const { DELETE } = await import('../../src/routes/api/servers/[name]/+server.js'); const { kick } = await import('../../src/lib/server/pipeline/worker.js'); const response = await DELETE({ params: { name: server } }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ ok: false, kind: 'conflict', error: expect.stringContaining(String(id)) }); expect(kick).not.toHaveBeenCalled(); await expectServerPreserved(); }); it('rejects a rebuild request with HTTP 409 before reserving a replacement', async () => { const id = await attachBeta(); const { POST } = await import('../../src/routes/api/provisioning/+server.js'); const { db } = await import('../../src/lib/server/db/index.js'); const response = await POST({ request: new Request('http://localhost/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action: 'rebuild' }) }) }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ error: expect.stringContaining(String(id)) }); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); await expectServerPreserved(); }); it.each([ { state: 'promoted', deleted: false }, { state: 'rolled-back', deleted: false }, { state: 'running', deleted: true }, { state: 'ready-promote', deleted: true } ])( 'allows removal for $state with historical deletion $deleted', async ({ state, deleted }) => { await attachBeta(state, undefined, deleted); const { destroyServer } = await import('../../src/lib/server/fleet/create.js'); expect(destroyServer(server)).toMatchObject({ ok: true, deploymentId: expect.any(String) }); } ); it.each(['destroy', 'rebuild'] as const)( 'requires cancelling a pending %s before opening a beta', async (action) => { const { destroyServer } = await import('../../src/lib/server/fleet/create.js'); const { requestLifecycle } = await import('../../src/lib/server/fleet/lifecycle.js'); const { cancelRemoval } = await import('../../src/lib/server/fleet/deployments.js'); const { cancelProvisioning } = await import('../../src/lib/server/provisioning/requests.js'); const { reserveServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { createScript } = 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'); reserveServerKey(server, 't5', 'saved-key'); const queued = action === 'destroy' ? destroyServer(server) : requestLifecycle(server, 'rebuild'); expect(queued.ok).toBe(true); if (!queued.ok) throw new Error('Expected queued removal'); const { script, version } = createScript({ name: 'Beta admission', targetRelPath: 'admission.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('beta content') }); const input = { scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [server], waitForHealth: false, waitForMatch: false }; await expect(openBeta(input)).rejects.toMatchObject({ kind: 'conflict', message: expect.stringMatching(/cancel or finish/i) }); expect(db().prepare('SELECT id FROM betas').all()).toEqual([]); expect( action === 'destroy' ? cancelRemoval(queued.deploymentId!) : cancelProvisioning(queued.deploymentId!) ).toBe(true); await expect(openBeta(input)).resolves.toMatchObject({ servers: [server], betaId: expect.any(Number) }); await expectServerPreserved(); } ); it.each( (['destroy', 'rebuild'] as const).flatMap((action) => ['failed', 'interrupted'].flatMap((state) => ['Stopping and removing container', 'Removing server files and registry'].map( (stage) => ({ action, state, stage }) ) ) ) )( 'rejects beta admission after $action is $state at $stage', async ({ action, state, stage }) => { const { beginDeployment } = await import('../../src/lib/server/fleet/deployments.js'); const { createScript } = 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 operation = beginDeployment(server, action); db() .prepare('UPDATE server_deployments SET state = ?, stage = ? WHERE id = ?') .run(state, stage, operation); const { script, version } = createScript({ name: 'Beta admission', targetRelPath: 'admission.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('beta content') }); await expect( openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [server], waitForHealth: false, waitForMatch: false }) ).rejects.toMatchObject({ kind: 'conflict' }); expect(db().prepare('SELECT id FROM betas').all()).toEqual([]); expect(db().prepare('SELECT * FROM staged_file_changes').all()).toEqual([]); await expectServerPreserved(); } ); }); describe('bulk removal beta protection', () => { it('rolls back earlier House Party removals when another target has an active beta', async () => { const id = await attachBeta(); const { housePartyTargets, queueHouseParty } = await import('../../src/lib/server/fleet/house-party.js'); const { db } = await import('../../src/lib/server/db/index.js'); expect(housePartyTargets()).toEqual([otherServer, server]); expect(() => queueHouseParty(housePartyTargets())).toThrow(new RegExp(String(id))); expect( db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all() ).toEqual([]); await expectServerPreserved(); }); it('returns an actionable HTTP 409 for a blocked House Party without queuing partial removals', async () => { const id = await attachBeta(); const { POST } = await import('../../src/routes/api/provisioning/house-party/+server.js'); const { db } = await import('../../src/lib/server/db/index.js'); const response = await POST({ request: new Request('http://localhost/api/provisioning/house-party', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ servers: [otherServer, server] }) }) }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ ok: false, error: expect.stringContaining(String(id)) }); expect( db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all() ).toEqual([]); await expectServerPreserved(); }); it('shows the active beta as an exact collection blocker while fill mode remains available', async () => { const id = await attachBeta(); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection } = await import('../../src/lib/server/fleet/collections.js'); const blueprint = saveBlueprint({ name: 'Replace fleet', entries: [{ game: 't5', mapSlug: 'moon', count: 1 }] }); const preview = previewCollection(blueprint.id, 'exact'); expect(preview.ready).toBe(false); expect(preview.blockers).toContainEqual(expect.stringContaining(String(id))); expect(preview.blockers.join(' ')).toMatch(/promot.*roll.?back/i); expect(previewCollection(blueprint.id, 'fill').ready).toBe(true); }); it('rolls back an exact collection if a beta starts after its preview', async () => { const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const { db } = await import('../../src/lib/server/db/index.js'); const blueprint = saveBlueprint({ name: 'Replace fleet', entries: [{ game: 't5', mapSlug: 'moon', count: 1 }] }); const preview = previewCollection(blueprint.id, 'exact'); expect(preview.removals.map((target) => target.name)).toEqual([otherServer, server]); const id = await attachBeta(); expect(() => queueCollection({ previewId: preview.id, keys: { t5_zombie_moon_1: 'new-key' } }) ).toThrow(new RegExp(String(id))); for (const table of [ 'server_deployments', 'server_launch_collections', 'server_collection_removals', 'server_creation_jobs', 'server_keys' ]) { expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]); } expect( db() .prepare('SELECT collection_id FROM server_collection_previews WHERE id = ?') .get(preview.id) ).toEqual({ collection_id: null }); await expectServerPreserved(); }); });