Files
2026-10-12 18:46:57 -04:00

158 lines
6.3 KiB
TypeScript

import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chmod, readFile, stat, writeFile } from 'node:fs/promises';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof import('node:fs')>();
return { ...original, writeFileSync: vi.fn(original.writeFileSync) };
});
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(),
rconStatusResult: vi.fn()
}));
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't5_zombie_theater_2'];
const relPath = 'staged.gsc';
const before = Buffer.from('main(){ /* current live script */ }');
const after = Buffer.from('main(){ /* reviewed replacement */ }');
type Source = 'campaign' | 'beta';
const destination = (server = servers[0]) =>
join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', relPath);
beforeEach(async () => {
vi.resetModules();
vi.resetAllMocks();
const actualFs = await vi.importActual<typeof import('node:fs')>('node:fs');
vi.mocked(writeFileSync).mockImplementation(actualFs.writeFileSync);
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-27T15:00:00Z'));
fixture = await newFixture(servers);
vi.stubEnv('RCON_PASSWORD', 'test');
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '1');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { rconGetPlayerCount, rconStatusResult } =
await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
vi.mocked(rconStatusResult).mockResolvedValue({
ok: true,
endpoint: { host: 'test', port: 28960 },
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
});
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
vi.useRealTimers();
});
async function queueChange(source: Source, targets = [servers[0]]) {
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript, uploadNewVersion } =
await import('../../src/lib/server/fleet/scripts.js');
const { recordScript, getServerScript } =
await import('../../src/lib/server/fleet/inventory.js');
const original = createScript({
releaseVersion: '1.0.0',
name: 'Staged script',
kind: 'script',
targetRelPath: relPath,
scopeType: 'game',
scopeValue: 't5',
content: before
});
const replacement = uploadNewVersion(original.script.id, after, undefined, '1.0.1');
for (const server of targets) {
await writeFile(destination(server), before);
recordScript(
server,
relPath,
original.version.id,
original.version.sha256,
before.length,
'fixture'
);
}
const inventory = getServerScript(targets[0], relPath);
let sourceId: string;
if (source === 'campaign') {
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(replacement.id, original.script.id);
const { runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
const result = await runDriftDeployment({ servers: targets });
expect(result.deploymentId).toBeTruthy();
sourceId = result.deploymentId!;
} else {
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await openBeta({
scripts: [{ scriptId: original.script.id, scriptVersionId: replacement.id }],
servers: targets,
waitForHealth: false,
waitForMatch: false
});
sourceId = String(result.betaId);
}
return { sourceId, inventory, replacement };
}
// File-storage unit tests exercise activation directly. Native provisioning and
// safety suites cover admission, host delivery, player gates, and recovery.
async function tick(source: Source) {
const { db } = await import('../../src/lib/server/db/index.js');
const { activateFiles } = await import('../helpers/staged-files.js');
const sources = db()
.prepare(
"SELECT DISTINCT source_id FROM staged_file_changes WHERE source_kind=? AND state='pending'"
)
.all(source) as { source_id: string }[];
for (const row of sources) await activateFiles(source, row.source_id);
}
async function expectLiveUnchanged(
inventory: Awaited<ReturnType<typeof queueChange>>['inventory'],
server = servers[0]
) {
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(await readFile(destination(server))).toEqual(before);
expect(getServerScript(server, relPath)).toEqual(inventory);
}
describe.each<Source>(['campaign', 'beta'])('%s staged file activation', (source) => {
it('preserves existing file permissions when activating staged bytes', async () => {
await queueChange(source);
await chmod(destination(), 0o640);
await tick(source);
expect(await readFile(destination())).toEqual(after);
expect((await stat(destination())).mode & 0o777).toBe(0o640);
});
it('applies the exact queued bytes even if artifact storage changes while waiting', async () => {
const { replacement } = await queueChange(source);
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?')
.run(putFileBlob(Buffer.from('changed after review'), db()), replacement.id);
await tick(source);
expect(await readFile(destination())).toEqual(after);
});
it('keeps the queued payload across an application restart', async () => {
const { inventory } = await queueChange(source);
await expectLiveUnchanged(inventory);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
vi.resetModules();
await expectLiveUnchanged(inventory);
await tick(source);
expect(await readFile(destination())).toEqual(after);
});
});