Files
electric-cherry/tests/unit/seed-fixture.test.ts
T
josh 8d1bb69591
CI / checks (push) Successful in 5m6s
Use ordered SQLite migrations (Refs #75)
2026-10-10 00:01:18 -04:00

391 lines
17 KiB
TypeScript

import Database from 'better-sqlite3';
import {
access,
mkdtemp,
readFile,
realpath,
rm,
stat,
symlink,
writeFile
} from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseFixtureOptions, reserveFixture } from '../../scripts/fixture-options.js';
import {
FIXTURE_KEY,
FIXTURE_VERSION,
refreshFixtureData,
seedFixtureData
} from '../../scripts/fixture-data.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
const NOW = Date.parse('2026-10-07T12:00:00Z');
const run = promisify(execFile);
describe('fixture destination isolation', () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'cherry-fixture-safety-'));
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
});
it('requires an explicit absolute destination even with a production database in the environment', () => {
vi.stubEnv('DB_PATH', join(root, 'production.db'));
expect(() => parseFixtureOptions([], NOW)).toThrow(/--db/);
expect(() => parseFixtureOptions(['--db', './data/cherry.db'], NOW)).toThrow(/absolute/);
expect(parseFixtureOptions(['--db', join(root, 'preview', 'fixture.db')], NOW)).toEqual({
dbPath: join(root, 'preview', 'fixture.db'),
now: NOW,
watch: false
});
expect(() =>
parseFixtureOptions(['--db', join(root, 'preview', 'fixture.db'), '--force'], NOW)
).toThrow(/Unknown option/);
});
it('keeps deterministic snapshot time separate from a live watch', () => {
const target = ['--db', join(root, 'preview', 'fixture.db')];
expect(parseFixtureOptions([...target, '--now', '2026-10-07T08:00:00-04:00'], 0).now).toBe(
NOW
);
expect(() => parseFixtureOptions([...target, '--now', '2026-10-07T12:00:00'], NOW)).toThrow(
/timezone/
);
expect(() =>
parseFixtureOptions([...target, '--now', '2026-10-07T12:00:00Z', '--watch'], NOW)
).toThrow(/separately/);
expect(() =>
parseFixtureOptions([...target, '--db', join(root, 'second', 'fixture.db')], NOW)
).toThrow(/Duplicate/);
});
it('reserves a private new directory and refuses reuse without touching the database or sidecars', async () => {
const reservation = await reserveFixture(join(root, 'preview', 'fixture.db'));
await writeFile(reservation.dbPath, 'retained database');
await writeFile(`${reservation.dbPath}-wal`, 'retained WAL');
await expect(reserveFixture(reservation.dbPath)).rejects.toThrow(/already exists/);
expect(await readFile(reservation.dbPath, 'utf8')).toBe('retained database');
expect(await readFile(`${reservation.dbPath}-wal`, 'utf8')).toBe('retained WAL');
expect((await stat(reservation.root)).mode & 0o777).toBe(0o700);
expect((await stat(reservation.dbPath)).mode & 0o777).toBe(0o600);
});
it('rejects a pre-existing destination-directory symlink instead of following it', async () => {
const target = await reserveFixture(join(root, 'production', 'database.db'));
await writeFile(target.dbPath, 'do not replace');
await symlink(target.root, join(root, 'preview'), 'dir');
await expect(reserveFixture(join(root, 'preview', 'database.db'))).rejects.toThrow(
/already exists/
);
expect(await readFile(target.dbPath, 'utf8')).toBe('do not replace');
});
it('runs the real CLI against only the explicit destination despite ambient production paths', async () => {
const ambient = join(root, 'production.db');
await writeFile(ambient, 'original production sentinel');
const destination = join(root, 'preview', 'fixture.db');
const { stdout } = await run(
process.execPath,
[
'--import',
'tsx',
'scripts/seed-fixture.ts',
'--db',
destination,
'--now',
'2026-10-07T12:00:00Z'
],
{
cwd: process.cwd(),
timeout: 30_000,
maxBuffer: 200_000,
env: {
...process.env,
DB_PATH: ambient,
SERVERS_ROOT: join(root, 'production-t5'),
T6_SERVERS_ROOT: join(root, 'production-t6'),
BETA_ARCHIVE_DIR: join(root, 'production-archive'),
GAME_FILES_ROOT: join(root, 'production-files'),
RCON_PASSWORD: 'ambient-secret-must-not-escape'
}
}
);
expect(await readFile(ambient, 'utf8')).toBe('original production sentinel');
await expect(access(join(root, 'production-t5'))).rejects.toThrow();
await expect(access(join(root, 'production-files'))).rejects.toThrow();
expect(stdout).not.toContain('ambient-secret-must-not-escape');
expect(stdout).not.toContain('[worker] started');
const saved = JSON.parse(await readFile(join(root, 'preview', 'fixture.json'), 'utf8'));
expect(saved.dbPath).toBe(await realpath(destination));
expect(saved.password.length).toBeGreaterThanOrEqual(24);
const environment = await readFile(join(root, 'preview', 'preview.env'), 'utf8');
expect(environment).not.toContain('production');
expect(environment).not.toContain('ambient-secret');
const seeded = new Database(destination, { readonly: true, fileMustExist: true });
try {
expect(seeded.pragma('integrity_check', { simple: true })).toBe('ok');
expect(seeded.pragma('foreign_key_check')).toEqual([]);
expect(seeded.prepare('SELECT COUNT(*) n FROM leaderboard_matches').get()).toEqual({
n: 500
});
expect(
seeded
.prepare(
"SELECT COUNT(*) n FROM auth_users WHERE username='demo' AND role='admin'"
)
.get()
).toEqual({ n: 1 });
expect(
seeded
.prepare(
"SELECT COUNT(*) n FROM ec_host_operations WHERE state IN ('pending','running','accepted')"
)
.get()
).toEqual({ n: 0 });
} finally {
seeded.close();
}
}, 35_000);
it('cleans up only its newly owned directory when fixture creation cannot complete', async () => {
const retained = join(root, 'keep.txt');
await writeFile(retained, 'keep');
// This filename collides with the generated environment file. Whether
// rejected up front or at write time, no partial preview should remain.
await expect(
run(
process.execPath,
[
'--import',
'tsx',
'scripts/seed-fixture.ts',
'--db',
join(root, 'failed-preview', 'preview.env')
],
{
cwd: process.cwd(),
timeout: 30_000,
maxBuffer: 200_000
}
)
).rejects.toThrow();
await expect(access(join(root, 'failed-preview'))).rejects.toThrow();
expect(await readFile(retained, 'utf8')).toBe('keep');
}, 35_000);
});
describe('useful synthetic fixture data', () => {
let fixture: Fixture;
let handle: Database.Database;
beforeAll(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
seedFixtureData(handle, NOW);
}, 20_000);
beforeEach(() => handle.exec('SAVEPOINT fixture_test'));
afterEach(() => handle.exec('ROLLBACK TO fixture_test; RELEASE fixture_test'));
afterAll(async () => {
await fixture?.cleanup();
});
const count = (sql: string) => (handle.prepare(sql).get() as { n: number }).n;
it('provides complete paginated match, fleet and metrics evidence with valid relationships', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(count('SELECT COUNT(*) n FROM servers')).toBe(12);
expect(count('SELECT COUNT(*) n FROM ec_hosts')).toBe(3);
expect(count('SELECT COUNT(*) n FROM leaderboard_matches')).toBe(500);
expect(count("SELECT COUNT(*) n FROM leaderboard_matches WHERE status='active'")).toBe(6);
expect(
count("SELECT COUNT(*) n FROM leaderboard_matches WHERE status='completed'")
).toBeGreaterThan(400);
expect(
handle
.prepare(
'SELECT DISTINCT start_players FROM leaderboard_matches WHERE start_players IS NOT NULL ORDER BY start_players'
)
.all()
).toEqual([1, 2, 3, 4].map((start_players) => ({ start_players })));
expect(
count('SELECT COUNT(*) n FROM leaderboard_matches WHERE start_players IS NULL')
).toBeGreaterThan(0);
expect(
count('SELECT COUNT(DISTINCT guid) n FROM leaderboard_match_players')
).toBeGreaterThan(50);
const metrics = handle
.prepare(
'SELECT COUNT(DISTINCT at) samples,MIN(at) oldest,MAX(at) newest FROM metric_samples'
)
.get() as { samples: number; oldest: number; newest: number };
expect(metrics.samples).toBe(7 * 24 * 60);
expect(metrics.newest).toBe(NOW);
expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000);
expect(
count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>capacity')
).toBe(0);
});
it('supports actual leaderboard pagination and evidence for every exact squad size', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const first = getLeaderboard({ game: 't5', map: 'theater', players: 2, limit: 25 });
const second = getLeaderboard({
game: 't5',
map: 'theater',
players: 2,
limit: 25,
offset: 25
});
expect(first.total).toBeGreaterThan(50);
expect(first.entries).toHaveLength(25);
expect(second.entries).toHaveLength(25);
expect(new Set([...first.entries, ...second.entries].map((entry) => entry.guid)).size).toBe(
50
);
for (const players of [1, 2, 3, 4] as const) {
const board = getLeaderboard({ players });
expect(board.total).toBeGreaterThan(0);
const evidence = board.entries[0].records.highestRound;
expect(
handle
.prepare('SELECT start_players FROM leaderboard_matches WHERE id=?')
.get(evidence.matchId)
).toEqual({ start_players: players });
}
});
it('never seeds executable work or usable host credentials and pauses automated lifecycle policies', async () => {
expect(
count(
"SELECT COUNT(*) n FROM server_deployments WHERE state IN ('queued','waiting','running','awaiting-health')"
)
).toBe(0);
expect(
count(
"SELECT COUNT(*) n FROM ec_host_operations WHERE state IN ('pending','accepted','running','unknown')"
)
).toBe(0);
expect(
count(
"SELECT COUNT(*) n FROM maintenance_jobs WHERE state IN ('queued','running','waiting')"
)
).toBe(0);
expect(
count(
"SELECT COUNT(*) n FROM beta_servers bs JOIN betas b ON b.id=bs.beta_id WHERE b.state IN ('running','ready-promote')"
)
).toBe(0);
expect(
count(
"SELECT COUNT(*) n FROM deployment_servers WHERE restart_state IN ('pending','failed','held')"
)
).toBe(0);
const { maintenancePolicies } =
await import('../../src/lib/server/maintenance/policies.js');
expect(maintenancePolicies()).toEqual({
paused: true,
health: false,
drift: false,
updates: false
});
for (const row of handle.prepare('SELECT token_hash FROM ec_host_credentials').all() as {
token_hash: string;
}[])
expect(row.token_hash).not.toMatch(/^[0-9a-f]{64}$/);
expect(() => seedFixtureData(handle, NOW)).toThrow(/new database/);
expect(count('SELECT COUNT(*) n FROM leaderboard_matches')).toBe(500);
});
it('refuses watch updates without the exact ownership marker', () => {
const before = handle
.prepare('SELECT name,last_checked_at FROM servers ORDER BY name')
.all();
handle.prepare('DELETE FROM settings WHERE key=?').run(FIXTURE_KEY);
expect(() => refreshFixtureData(handle, NOW + 60_000)).toThrow(/unmarked/);
handle
.prepare('INSERT INTO settings(key,value) VALUES(?,?)')
.run(FIXTURE_KEY, 'some-other-fixture');
expect(() => refreshFixtureData(handle, NOW + 60_000)).toThrow(/unmarked/);
expect(
handle.prepare('SELECT name,last_checked_at FROM servers ORDER BY name').all()
).toEqual(before);
});
it('refreshes only owned observations while preserving unrelated resources and retained history', () => {
handle
.prepare('INSERT INTO servers(name,last_checked_at) VALUES(?,?)')
.run('unowned-server', '2020-01-01');
handle
.prepare('INSERT INTO ec_hosts(id,name,created_at,last_heartbeat) VALUES(?,?,?,?)')
.run('unowned-host', 'Keep this host', '2020-01-01', '2020-01-01');
const history = handle
.prepare(
"SELECT id,last_received_at FROM leaderboard_matches WHERE status!='active' ORDER BY id"
)
.all();
const operations = handle
.prepare('SELECT id,state,updated_at FROM ec_host_operations ORDER BY id')
.all();
refreshFixtureData(handle, NOW + 60_000);
expect(handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY)).toEqual({
value: FIXTURE_VERSION
});
expect(
handle.prepare('SELECT last_checked_at FROM servers WHERE name=?').get('unowned-server')
).toEqual({ last_checked_at: '2020-01-01' });
expect(
handle.prepare('SELECT last_heartbeat FROM ec_hosts WHERE id=?').get('unowned-host')
).toEqual({ last_heartbeat: '2020-01-01' });
expect(
handle
.prepare(
"SELECT id,last_received_at FROM leaderboard_matches WHERE status!='active' ORDER BY id"
)
.all()
).toEqual(history);
expect(
handle.prepare('SELECT id,state,updated_at FROM ec_host_operations ORDER BY id').all()
).toEqual(operations);
expect(
count(
`SELECT COUNT(*) n FROM servers WHERE last_checked_at='${new Date(NOW + 60_000).toISOString()}'`
)
).toBe(12);
});
it('restores synthetic observations after monitoring without making the offline host live', () => {
const original = handle
.prepare(
'SELECT name,container_state,last_error,rcon_failures,last_players,last_roster FROM servers ORDER BY name'
)
.all();
handle
.prepare(
"UPDATE servers SET container_state='unknown',last_error='monitor timeout',rcon_failures=2,last_players=NULL,last_roster=NULL"
)
.run();
refreshFixtureData(handle, NOW + 60_000);
expect(
handle
.prepare(
'SELECT name,container_state,last_error,rcon_failures,last_players,last_roster FROM servers ORDER BY name'
)
.all()
).toEqual(original);
const hosts = handle.prepare('SELECT last_heartbeat FROM ec_hosts').all() as {
last_heartbeat: string;
}[];
expect(
hosts.filter((host) => NOW + 60_000 - Date.parse(host.last_heartbeat) > 90_000)
).toHaveLength(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});