Files
electric-cherry/tests/unit/server-key-migration.test.ts
T

109 lines
7.4 KiB
TypeScript

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 { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
migrateNativeInstances(previous);
ensureHostSchema(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);
});