import Database from 'better-sqlite3'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import schema from '../../src/lib/server/db/schema.sql?raw'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { await fixture.cleanup(); }); it('migrates idle, occupied and duplicate legacy keys without releasing unresolved assignments', async () => { const previous = new Database(fixture.dbPath); previous.exec(schema); previous.exec(`DROP TABLE server_keys; CREATE TABLE server_keys(server_id TEXT PRIMARY KEY,pluto_key TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT (datetime('now')));`); const { migrateNativeInstances } = await import('../../src/lib/server/backends/schema.js'); migrateNativeInstances(previous); const cases = [ ['t5_zombie_theater_1', 'shared-occupied'], ['t5_zombie_pentagon_1', 'shared-occupied'], ['t5_zombie_cosmodrome_1', 'native-only'], ['t5_zombie_temple_1', 'queued-key'], ['t5_zombie_sumpf_1', 'failed-key'], ['t5_zombie_moon_1', 'idle-key'], ['t5_zombie_moon_2', 'idle-key'], ['t6_zombie_town_1', 't6-key'] ]; for (const [server, credential] of cases) previous .prepare('INSERT INTO server_keys(server_id,pluto_key,updated_at) VALUES(?,?,?)') .run(server, credential, '2026-01-01'); previous.exec(`INSERT INTO servers(name,game,desired_state) VALUES('t5_zombie_theater_1','t5','running'),('t5_zombie_pentagon_1','t5','stopped'); INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('native','t5_zombie_cosmodrome_1','host','library','image',28960,'2026-01-01'); INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES ('queued','t5_zombie_temple_1','create','queued','Queued','2026-01-01','fixture'), ('failed','t5_zombie_sumpf_1','create','failed','Placement failed','2026-01-01','fixture');`); previous.close(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const pool = await import('../../src/lib/server/fleet/server-keys.js'); for (let pass = 0; pass < 2; pass++) { expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 }); expect(pool.serverKeyPool('t6')).toEqual({ total: 1, available: 1 }); expect(pool.getServerKey('t5_zombie_theater_1')).toBe('shared-occupied'); expect(pool.getServerKey('t5_zombie_pentagon_1')).toBe('shared-occupied'); expect(pool.getServerKey('t5_zombie_cosmodrome_1')).toBe('native-only'); expect(pool.getServerKey('t5_zombie_temple_1')).toBe('queued-key'); expect(pool.getServerKey('t5_zombie_sumpf_1')).toBe('failed-key'); expect(pool.getServerKey('t5_zombie_moon_1')).toBeUndefined(); expect(db().pragma('foreign_key_check')).toEqual([]); closeDb(); } const duplicate = pool.listServerKeys().find((key) => key.assigned_servers.length === 2)!; expect(duplicate.assigned_servers).toEqual(['t5_zombie_pentagon_1', 't5_zombie_theater_1']); expect(duplicate.updated_at).toBe('2026-01-01'); expect(pool.reserveServerKey('t5_zombie_theater_2', 't5')).toBe('idle-key'); pool.releaseServerKey('t5_zombie_theater_1'); expect(pool.availableServerKeyCount('t5')).toBe(0); expect(() => pool.removeServerKey(duplicate.id)).toThrow('reserved'); pool.releaseServerKey('t5_zombie_pentagon_1'); expect(pool.reserveServerKey('t5_zombie_theater_3', 't5')).toBe('shared-occupied'); }); it('recovers actual launch credentials and holds both sides of an unresolved legacy rebuild', async () => { const previous = new Database(fixture.dbPath); previous.exec(schema); previous.exec(`DROP TABLE server_keys; CREATE TABLE server_keys(server_id TEXT PRIMARY KEY,pluto_key TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT (datetime('now')));`); const { migrateNativeInstances } = await import('../../src/lib/server/backends/schema.js'); const { migrateHostSchema } = await import('../../src/lib/server/hosts/schema.js'); migrateNativeInstances(previous); migrateHostSchema(previous); previous.exec(`INSERT INTO ec_hosts(id,name,created_at) VALUES('host','Fixture host','2026-01-01'); INSERT INTO servers(name,game) VALUES('t5_zombie_theater_1','t5'),('t5_zombie_pentagon_1','t5'); INSERT INTO server_keys(server_id,pluto_key) VALUES('t5_zombie_theater_1','edited-saved-key'), ('t5_zombie_pentagon_1','original-key'),('t5_zombie_moon_1','original-key'); INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES ('kino','t5_zombie_theater_1','host',2,'library','image',28960,'2026-01-01'), ('five','t5_zombie_pentagon_1','host',1,'library','image',28961,'2026-01-01'), ('queued','t5_zombie_cosmodrome_1','host',1,'library','image',28962,'2026-01-01');`); function operation( id: string, instance: string, generation: number, kind: string, state: string, key: string, staged = false, nextGeneration?: number ) { const payload = JSON.stringify({ game: 't5', env: { PLUTO_SERVER_KEY: key }, nextGeneration }); previous .prepare( `INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,state,payload,created_at,updated_at) VALUES(?,'host',?,?,?,?,?,'2026-01-01','2026-01-01')` ) .run(id, instance, generation, kind, state, staged ? '{}' : payload); if (staged) previous .prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)') .run(id, payload); } operation('obsolete-kino', 'kino', 1, 'instance.create', 'completed', 'obsolete-key'); operation('actual-kino', 'kino', 1, 'instance.rebuild', 'completed', 'actual-key', true, 2); operation('original-five', 'five', 1, 'instance.create', 'completed', 'original-key'); operation('unknown-five', 'five', 1, 'instance.rebuild', 'unknown', 'pending-key', true, 2); operation( 'unknown-creation', 'queued', 1, 'instance.create', 'unknown', 'unknown-creation-key' ); operation( 'failed-new-key', 'kino', 2, 'instance.rebuild', 'failed', 'never-applied-key', true, 3 ); previous.close(); const pool = await import('../../src/lib/server/fleet/server-keys.js'); expect(pool.getServerKey('t5_zombie_theater_1')).toBe('actual-key'); expect(pool.getServerKey('t5_zombie_pentagon_1')).toBe('pending-key'); expect(pool.getServerKey('t5_zombie_cosmodrome_1')).toBe('unknown-creation-key'); expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 }); expect(pool.serverKeyPool('t5', 't5_zombie_pentagon_1')).toEqual({ total: 5, available: 2 }); expect(() => pool.reserveServerKey('t5_zombie_moon_2', 't5', 'original-key')).toThrow( 'already reserved' ); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare( `SELECT k.pluto_key,l.is_current FROM server_key_leases l JOIN server_keys k ON k.id=l.key_id WHERE l.server_id='t5_zombie_pentagon_1' ORDER BY l.is_current` ) .all() ).toEqual([ { pluto_key: 'original-key', is_current: 0 }, { pluto_key: 'pending-key', is_current: 1 } ]); expect(db().pragma('foreign_key_check')).toEqual([]); closeDb(); expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 }); pool.releaseServerKey('t5_zombie_pentagon_1'); expect(pool.availableServerKeyCount('t5')).toBe(3); });