274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([]);
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(new Date('2026-09-28T12:00:00Z'));
|
|
});
|
|
afterEach(async () => {
|
|
await fixture.cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function setup() {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const handle = db();
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
|
VALUES ('operation', 'server', 'create', 'waiting', 'Recorded stage', '2026-09-28T12:00:00Z', 'test')`
|
|
)
|
|
.run();
|
|
const { recordProgress, readProgress } =
|
|
await import('../../src/lib/server/provisioning/progress.js');
|
|
const read = () =>
|
|
readProgress(
|
|
handle
|
|
.prepare('SELECT observation_json FROM server_deployments WHERE id = ?')
|
|
.get('operation') as { observation_json: string }
|
|
);
|
|
return { handle, recordProgress, read };
|
|
}
|
|
|
|
describe('provisioning evidence persistence', () => {
|
|
it('keeps phase duration stable and avoids semantic event spam during repeated occupancy checks', async () => {
|
|
const { handle, recordProgress, read } = await setup();
|
|
for (let players = 4; players > 0; players--) {
|
|
recordProgress('operation', {
|
|
phase: 'eligibility',
|
|
blocker: {
|
|
code: 'occupied',
|
|
message: `${players} players remain`,
|
|
automatic: true
|
|
},
|
|
checks: [
|
|
{
|
|
key: 'occupancy',
|
|
label: 'Players',
|
|
source: 'telemetry',
|
|
result: 'waiting',
|
|
actual: players,
|
|
receivedAt: new Date().toISOString()
|
|
}
|
|
]
|
|
});
|
|
vi.setSystemTime(Date.now() + 15_000);
|
|
}
|
|
expect(read()).toMatchObject({
|
|
phaseStartedAt: '2026-09-28T12:00:00.000Z',
|
|
observedAt: '2026-09-28T12:00:45.000Z',
|
|
checks: [expect.objectContaining({ actual: 1, receivedAt: '2026-09-28T12:00:45.000Z' })]
|
|
});
|
|
expect(
|
|
handle
|
|
.prepare(
|
|
"SELECT count(*) AS count FROM server_deployment_events WHERE kind = 'blocker'"
|
|
)
|
|
.get()
|
|
).toEqual({ count: 1 });
|
|
recordProgress('operation', {
|
|
phase: 'container-action',
|
|
blocker: null,
|
|
checkpoint: { key: 'eligibility', label: 'Eligibility checked', phase: 'eligibility' }
|
|
});
|
|
expect(read()).toMatchObject({ phaseStartedAt: '2026-09-28T12:01:00.000Z', blocker: null });
|
|
});
|
|
|
|
it('replaces transient checks while retaining confirmed milestones and prior command uncertainty', async () => {
|
|
const { handle, recordProgress, read } = await setup();
|
|
recordProgress('operation', {
|
|
phase: 'verify-game',
|
|
command: { state: 'uncertain', message: 'Reply was lost' },
|
|
checkpoint: {
|
|
key: 'container-action',
|
|
label: 'Submission attempted',
|
|
phase: 'container-action'
|
|
},
|
|
checks: [
|
|
{ key: 'container', label: 'Container', source: 'agent', result: 'pass' },
|
|
{ key: 'game', label: 'Game', source: 'telemetry', result: 'pass' }
|
|
]
|
|
});
|
|
vi.setSystemTime(Date.now() + 15_000);
|
|
recordProgress('operation', {
|
|
phase: 'verify-container',
|
|
checks: [{ key: 'container', label: 'Container', source: 'agent', result: 'unknown' }]
|
|
});
|
|
expect(read()?.checks).toHaveLength(1);
|
|
expect(read()?.checks[0].result).toBe('unknown');
|
|
expect(read()?.checkpoints).toHaveLength(1);
|
|
expect(read()?.command?.state).toBe('uncertain');
|
|
expect(
|
|
handle
|
|
.prepare("SELECT message FROM server_deployment_events WHERE kind = 'command'")
|
|
.get()
|
|
).toEqual({ message: 'Reply was lost' });
|
|
});
|
|
|
|
it('preserves Plutonium release and runtime evidence when later progress is recorded', async () => {
|
|
const { recordProgress, read } = await setup();
|
|
recordProgress('operation', {
|
|
phase: 'verify-plutonium',
|
|
checks: [
|
|
{
|
|
key: 'plutonium-release',
|
|
label: 'Production Plutonium release',
|
|
result: 'pass',
|
|
source: 'manifest',
|
|
actual: 5200
|
|
},
|
|
{
|
|
key: 'plutonium-version',
|
|
label: 'Running Plutonium revision',
|
|
result: 'pass',
|
|
source: 'rcon',
|
|
expected: 5200,
|
|
actual: 5200
|
|
}
|
|
],
|
|
checkpoint: { key: 'verify-plutonium', label: 'Plutonium revision verified' }
|
|
});
|
|
vi.setSystemTime(Date.now() + 15_000);
|
|
recordProgress('operation', { blocker: null });
|
|
expect(read()).toMatchObject({
|
|
phase: 'verify-plutonium',
|
|
phaseStartedAt: '2026-09-28T12:00:00.000Z',
|
|
checks: [
|
|
{ key: 'plutonium-release', result: 'pass', source: 'manifest', actual: 5200 },
|
|
{
|
|
key: 'plutonium-version',
|
|
result: 'pass',
|
|
source: 'rcon',
|
|
expected: 5200,
|
|
actual: 5200
|
|
}
|
|
],
|
|
checkpoints: [{ key: 'verify-plutonium', phase: 'verify-plutonium' }]
|
|
});
|
|
});
|
|
|
|
it('keeps operation-owned recovery evidence after slot/server removal and does not rewrite the failed outcome', async () => {
|
|
const { handle, recordProgress, read } = await setup();
|
|
handle
|
|
.prepare(
|
|
"UPDATE server_deployments SET state = 'failed', finished_at = '2026-09-28T12:00:00Z' WHERE id = 'operation'"
|
|
)
|
|
.run();
|
|
recordProgress('operation', {
|
|
recovery: { state: 'held', message: 'Observing possible submission' }
|
|
});
|
|
vi.setSystemTime(Date.now() + 30_000);
|
|
recordProgress('operation', {
|
|
recovery: { state: 'released', message: 'Recovery verified' }
|
|
});
|
|
recordProgress('operation', {
|
|
recovery: { state: 'released', message: 'Recovery verified' }
|
|
});
|
|
expect(read()?.recovery).toMatchObject({
|
|
state: 'released',
|
|
at: '2026-09-28T12:00:30.000Z'
|
|
});
|
|
expect(
|
|
handle
|
|
.prepare("SELECT state, finished_at FROM server_deployments WHERE id = 'operation'")
|
|
.get()
|
|
).toEqual({ state: 'failed', finished_at: '2026-09-28T12:00:00Z' });
|
|
expect(
|
|
handle
|
|
.prepare(
|
|
"SELECT count(*) AS count FROM server_deployment_events WHERE kind = 'recovery'"
|
|
)
|
|
.get()
|
|
).toEqual({ count: 2 });
|
|
});
|
|
|
|
it('rolls back state, snapshot, and milestone events together with its caller transaction', async () => {
|
|
const { handle, recordProgress, read } = await setup();
|
|
expect(() =>
|
|
handle.transaction(() => {
|
|
handle
|
|
.prepare(
|
|
"UPDATE server_deployments SET state = 'running' WHERE id = 'operation'"
|
|
)
|
|
.run();
|
|
recordProgress('operation', {
|
|
phase: 'cleanup',
|
|
checkpoint: { key: 'container-action', label: 'Remote deletion confirmed' }
|
|
});
|
|
throw new Error('Interrupted transaction');
|
|
})()
|
|
).toThrow('Interrupted transaction');
|
|
expect(read()).toBeNull();
|
|
expect(
|
|
handle.prepare("SELECT state FROM server_deployments WHERE id = 'operation'").get()
|
|
).toEqual({ state: 'waiting' });
|
|
expect(handle.prepare('SELECT * FROM server_deployment_events').all()).toEqual([]);
|
|
});
|
|
|
|
it('treats unsupported or malformed historical snapshots as unrecorded', async () => {
|
|
const { readProgress } = await import('../../src/lib/server/provisioning/progress.js');
|
|
for (const observation_json of [
|
|
null,
|
|
'',
|
|
'[]',
|
|
'{broken',
|
|
'{"version":2}',
|
|
'{"version":1,"checks":"pass"}'
|
|
]) {
|
|
expect(readProgress({ observation_json })).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('adds nullable evidence columns without losing historical identity, row order, or events across reopen', async () => {
|
|
const schema = (
|
|
await readFile(new URL('../../src/lib/server/db/schema.sql', import.meta.url), 'utf8')
|
|
)
|
|
.replace(
|
|
',\n phase_key TEXT,\n phase_started_at TEXT,\n observed_at TEXT,\n observation_json TEXT',
|
|
''
|
|
)
|
|
.replace(',\n kind TEXT,\n phase_key TEXT,\n details_json TEXT', '')
|
|
.replace(
|
|
"'create', 'start', 'restart', 'rebuild', 'stop', 'destroy'",
|
|
"'create', 'destroy'"
|
|
);
|
|
const old = new Database(fixture.dbPath);
|
|
old.exec(schema);
|
|
old.prepare(
|
|
`INSERT INTO server_deployments (rowid,id,server,server_deleted_at,action,state,stage,started_at,triggered_by)
|
|
VALUES (42,'legacy','server','2026-09-01','create','done','Recorded result','2026-08-01','test')`
|
|
).run();
|
|
old.prepare(
|
|
"INSERT INTO server_deployment_events (id,deployment_id,created_at,message) VALUES (19,'legacy','2026-08-01','Original evidence')"
|
|
).run();
|
|
old.close();
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
expect(
|
|
db()
|
|
.prepare(
|
|
"SELECT rowid, server_deleted_at, observation_json FROM server_deployments WHERE id = 'legacy'"
|
|
)
|
|
.get()
|
|
).toEqual({ rowid: 42, server_deleted_at: '2026-09-01', observation_json: null });
|
|
const { recordProgress, readProgress } =
|
|
await import('../../src/lib/server/provisioning/progress.js');
|
|
recordProgress('legacy', {
|
|
recovery: { state: 'unknown', message: 'Historical slot release was not recorded' }
|
|
});
|
|
closeDb();
|
|
const saved = db()
|
|
.prepare("SELECT observation_json FROM server_deployments WHERE id = 'legacy'")
|
|
.get() as { observation_json: string };
|
|
expect(readProgress(saved)?.recovery?.state).toBe('unknown');
|
|
expect(
|
|
db().prepare('SELECT id, message FROM server_deployment_events WHERE id = 19').get()
|
|
).toEqual({ id: 19, message: 'Original evidence' });
|
|
expect(db().pragma('foreign_key_check')).toEqual([]);
|
|
});
|
|
});
|