Files
electric-cherry/tests/unit/rollback-staging.test.ts
2026-10-12 18:46:57 -04:00

239 lines
10 KiB
TypeScript

import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => {
await fixture.cleanup();
});
async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean) {
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript, uploadNewVersion } =
await import('../../src/lib/server/fleet/scripts.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { sha256Buffer } = await import('../../src/lib/server/fleet/files.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const before = Buffer.from(kind === 'baseline' ? 'sv_voice 0\n' : 'original');
const after = Buffer.from(kind === 'baseline' ? 'sv_voice 1\n' : 'promoted');
const relPath =
kind === 'baseline'
? 'dedicated_sp.cfg'
: kind === 'plugin'
? 'managed.dll'
: 'managed.gsc';
const { script, version: prior } = createScript({
releaseVersion: '1.0.0',
name: 'managed',
kind,
targetRelPath: relPath,
scopeType: 'game',
scopeValue: 't5',
content: before
});
const version = uploadNewVersion(script.id, after, undefined, '1.0.1');
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(version.id, script.id);
db()
.prepare(
"INSERT INTO deployments (id, action, started_at, triggered_by) VALUES ('promote', 'promote', ?, 'ui')"
)
.run(new Date().toISOString());
db()
.prepare(
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('promote', ?, 1, 'pending')"
)
.run(server);
db()
.prepare(
"INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)"
)
.run(script.id, version.id, prior.id, kind);
const file = db()
.prepare(
`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`
)
.run(
server,
relPath,
sha256Buffer(before),
sha256Buffer(after),
putFileBlob(before, db()),
putFileBlob(after, db())
);
const path = join(destOfKind(server, kind), relPath);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, staged ? before : after);
if (staged)
queueFileChange({
sourceKind: 'campaign',
sourceId: 'promote',
server,
kind,
relPath,
content: after,
scriptVersionId: version.id,
inventorySource: 'promote:promote',
deploymentFileId: Number(file.lastInsertRowid)
});
return { path, before, after, script, prior };
}
async function applyCampaign(id: string) {
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation } =
await import('../../src/lib/server/fleet/staged-files.js');
const operation = requestRestart(server, { kind: 'campaign', id });
applyStagedFilesForOperation(operation.id, server);
return operation;
}
describe('deferred promotion rollback', () => {
it('withdraws an unapplied promotion without restoring or restarting its server', async () => {
const { path, before } = await promotion('script', true);
const { db } = await import('../../src/lib/server/db/index.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation, isFileApplied } =
await import('../../src/lib/server/fleet/staged-files.js');
const { rollbackPromoteDeployment } =
await import('../../src/lib/server/fleet/rollback.js');
const operation = requestRestart(server, { kind: 'campaign', id: 'promote' });
const rollback = await rollbackPromoteDeployment('promote');
expect(rollback.affectedServers).toEqual([]);
expect(await readFile(path)).toEqual(before);
expect(isFileApplied('campaign', 'promote', server, 'script', 'managed.gsc')).toBe(false);
expect(
db()
.prepare(
"SELECT restart_state FROM deployment_servers WHERE deployment_id = 'promote'"
)
.get()
).toEqual({ restart_state: 'skipped' });
expect(
db()
.prepare('SELECT * FROM deployment_servers WHERE deployment_id = ?')
.all(rollback.deploymentId)
).toEqual([]);
applyStagedFilesForOperation(operation.id, server);
expect(await readFile(path)).toEqual(before);
});
it.each(['script', 'plugin', 'baseline'] as const)(
'queues restoration of applied %s bytes until rollback activation',
async (kind) => {
const { path, before, after, prior } = await promotion(kind, true);
await applyCampaign('promote');
const { rollbackPromoteDeployment } =
await import('../../src/lib/server/fleet/rollback.js');
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const result = await rollbackPromoteDeployment('promote');
expect(result.affectedServers).toEqual([server]);
expect(await readFile(path)).toEqual(after);
await applyCampaign(result.deploymentId);
expect(await readFile(path)).toEqual(before);
expect(
getServerScript(
server,
kind === 'baseline'
? 'dedicated_sp.cfg'
: kind === 'plugin'
? 'managed.dll'
: 'managed.gsc'
)?.script_version_id
).toBe(prior.id);
}
);
it('restores only applied files when a promotion was partly installed', async () => {
const { path, before } = await promotion('script', true);
await applyCampaign('promote');
const { db } = await import('../../src/lib/server/db/index.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const { rollbackPromoteDeployment } =
await import('../../src/lib/server/fleet/rollback.js');
const pending = db()
.prepare(
`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash)
VALUES ('promote', ?, 'added', 'never.gsc', ?)`
)
.run(server, putFileBlob(Buffer.from('queued'), db()));
queueFileChange({
sourceKind: 'campaign',
sourceId: 'promote',
server,
kind: 'script',
relPath: 'never.gsc',
content: Buffer.from('queued'),
inventorySource: 'promote:promote',
deploymentFileId: Number(pending.lastInsertRowid)
});
const result = await rollbackPromoteDeployment('promote');
expect(
db()
.prepare('SELECT rel_path FROM deployment_files WHERE deployment_id = ?')
.all(result.deploymentId)
).toEqual([{ rel_path: 'managed.gsc' }]);
await applyCampaign(result.deploymentId);
expect(await readFile(path)).toEqual(before);
expect(existsSync(join(dirname(path), 'never.gsc'))).toBe(false);
});
it('keeps another campaign eligible when withdrawing an independent activation source', async () => {
const { path, before } = await promotion('script', true);
const { db } = await import('../../src/lib/server/db/index.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { rollbackPromoteDeployment } =
await import('../../src/lib/server/fleet/rollback.js');
db()
.prepare(
"INSERT INTO deployments (id, action, started_at, triggered_by) VALUES ('other', 'drift', ?, 'ui')"
)
.run(new Date().toISOString());
db()
.prepare(
"INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('other', ?, 1, 'pending')"
)
.run(server);
queueFileChange({
sourceKind: 'campaign',
sourceId: 'other',
server,
kind: 'script',
relPath: 'other.gsc',
content: Buffer.from('other campaign'),
inventorySource: 'drift:other'
});
const shared = requestRestart(server, { kind: 'campaign', id: 'promote' });
expect(requestRestart(server, { kind: 'campaign', id: 'other' }).id).not.toBe(shared.id);
await rollbackPromoteDeployment('promote');
await applyCampaign('other');
expect(await readFile(path)).toEqual(before);
expect(await readFile(join(dirname(path), 'other.gsc'), 'utf8')).toBe('other campaign');
});
it('stages rollback for legacy promotions with no deferred file records', async () => {
const { path, before, after } = await promotion('plugin', false);
const { rollbackPromoteDeployment } =
await import('../../src/lib/server/fleet/rollback.js');
const result = await rollbackPromoteDeployment('promote');
expect(result.affectedServers).toEqual([server]);
expect(await readFile(path)).toEqual(after);
await applyCampaign(result.deploymentId);
expect(await readFile(path)).toEqual(before);
});
});