Files
electric-cherry/tests/unit/native-drift-manifest.test.ts

413 lines
17 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
const server = 't5_zombie_theater_1';
const scriptRoot = 'runtime/plutonium/storage/t5/raw/scripts/sp';
const pluginRoot = 'runtime/plutonium/plugins';
const hash = (content: Buffer) => createHash('sha256').update(content).digest('hex');
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
await fixture.cleanup();
vi.restoreAllMocks();
});
async function setup(capable = true) {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Drift manifest host' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, {
protocolVersion: 1,
capabilities: capable ? ['files.manifest'] : []
});
const { db } = await import('../../src/lib/server/db/index.js');
const database = db();
database
.prepare(
`INSERT INTO servers(name,game,map,game_mode,map_slug,created_by)
VALUES(?,'t5','zombie_theater','zombie','theater','ui')`
)
.run(server);
database
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES(?,?,?,'library','image',28960,?)`
)
.run(randomUUID(), server, host.id, new Date().toISOString());
database
.prepare(
`INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at)
SELECT 'launch',host_id,id,generation,'instance.create','{}','completed',created_at,created_at FROM native_instances WHERE server=?`
)
.run(server);
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
const contents = new Map<string, Buffer>();
function promoted(
kind: 'script' | 'plugin' | 'baseline',
name: string,
content: Buffer,
targetRelPath?: string
) {
const artifact = createScript({
releaseVersion: '1.0.0',
name,
kind,
targetRelPath,
scopeType: 'game',
scopeValue: 't5',
content
});
database
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(artifact.version.id, artifact.script.id);
return artifact;
}
const script = promoted(
'script',
'Managed script',
Buffer.from('main() { wait 1; }'),
'managed.gsc'
);
const plugin = promoted(
'plugin',
'Managed plugin',
Buffer.from('promoted plugin'),
'managed.dll'
);
const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n'));
for (const [root, artifact] of [
[scriptRoot, script],
[pluginRoot, plugin]
] as const) {
const { version } = artifact;
contents.set(
`${root}/${artifact.script.target_rel_path}`,
(await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id)
);
recordScript(
server,
artifact.script.target_rel_path,
version.id,
version.sha256,
version.size,
'test'
);
}
const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } =
await import('../../src/lib/server/fleet/server-config.js');
const config = getPromotedServerConfig('t5', 'zombie_theater', 'theater')!;
contents.set('configs/dedicated_sp.cfg', config);
recordScript(server, 'dedicated_sp.cfg', null, hash(config), config.length, 'test');
contents.set(
`configs/${MAP_CONFIG_FILE}`,
getServerMapConfig('t5', 'zombie_theater', 'theater')!
);
// The runtime owns this generated file; neither scanner should classify it as drift.
contents.set('configs/dedicated.cfg', Buffer.from('runtime generated'));
contents.set('configs/electric_cherry_bank.cfg', Buffer.from('set tws_bank_token private'));
const backend = await import('../../src/lib/server/backends/native.js');
const request = vi
.spyOn(backend, 'nativeRequest')
.mockImplementation(async (_instance, kind, payload = {}) => {
if (kind === 'files.manifest') {
const roots = payload.roots as string[];
const ignored = payload.ignoreNames as string[];
return {
files: [...contents]
.filter(
([path]) =>
roots.some((root) => path.startsWith(`${root}/`)) &&
!path.split('/').some((part) => ignored.includes(part))
)
.map(([path, content]) => ({
path,
size: content.length,
sha256: hash(content)
}))
} as never;
}
if (capable)
throw new Error(`Manifest scan requested an extra remote operation: ${kind}`);
const path = String(payload.path);
const content = contents.get(path);
if (kind === 'files.read')
return { contentBase64: content?.toString('base64') ?? null } as never;
if (kind === 'files.stat') {
const isDirectory =
path === '.' ||
[...contents.keys()].some((file) => file.startsWith(`${path}/`));
return {
exists: content !== undefined || isDirectory,
size: content?.length ?? 0,
mtime: new Date().toISOString(),
isDirectory,
isFile: content !== undefined
} as never;
}
if (kind === 'files.list') {
const entries = new Map<
string,
{ name: string; isDirectory: boolean; isFile: boolean }
>();
for (const file of contents.keys()) {
if (!file.startsWith(`${path}/`)) continue;
const [name, ...nested] = file.slice(path.length + 1).split('/');
entries.set(name, {
name,
isDirectory: nested.length > 0,
isFile: nested.length === 0
});
}
return { entries: [...entries.values()] } as never;
}
throw new Error(`Unexpected remote operation: ${kind}`);
});
const drift = await import('../../src/lib/server/fleet/drift.js');
return {
...drift,
database,
request,
contents,
script,
plugin,
baseline,
promoted,
mapConfigFile: MAP_CONFIG_FILE
};
}
function expectOneManifest(request: Awaited<ReturnType<typeof setup>>['request']) {
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][1]).toBe('files.manifest');
expect(request.mock.calls[0][2]).toEqual({
roots: [scriptRoot, 'configs', pluginRoot],
ignoreNames: ['dedicated.cfg'],
paths: [
`${scriptRoot}/managed.gsc`,
`${pluginRoot}/managed.dll`,
'configs/dedicated_sp.cfg',
'configs/electric_cherry_map.cfg'
]
});
}
describe('native drift manifests', () => {
it.each([true, false])(
'reconciles verified startup password changes without writing server files (manifest: %s)',
async (capable) => {
const api = await setup(capable);
const { applyRuntimePassword } =
await import('../../src/lib/server/fleet/runtime-config.js');
const { getServerScript, recordScript } =
await import('../../src/lib/server/fleet/inventory.js');
const promoted = Buffer.from(
'set rcon_password "password"\nset sv_maprotation "map zombie_theater"\n'
);
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
// Replace the promoted baseline bytes, retaining a legacy inventory hash.
const next = uploadNewVersion(api.baseline.script.id, promoted);
api.database
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(next.id, api.baseline.script.id);
const password = 'a'.repeat(64);
api.database
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
.run(JSON.stringify({ rconPassword: password }), 'launch');
recordScript(
server,
'dedicated_sp.cfg',
null,
hash(promoted),
promoted.length,
'native-create'
);
const effective = Buffer.from(applyRuntimePassword(promoted.toString(), password));
expect(effective.length - promoted.length).toBe(56);
api.contents.set('configs/dedicated_sp.cfg', effective);
expect(await api.scanServerDrift(server)).toEqual([]);
expect(getServerScript(server, 'dedicated_sp.cfg')).toMatchObject({
sha256: hash(effective),
size: effective.length,
source: 'runtime-config-reconciliation'
});
if (capable) expectOneManifest(api.request);
expect((await api.prepareDriftCampaign(server)).plan.files).toEqual([]);
// A wrong password of the same length is still real drift.
api.contents.set(
'configs/dedicated_sp.cfg',
Buffer.from(applyRuntimePassword(promoted.toString(), 'b'.repeat(64)))
);
expect(await api.scanServerDrift(server)).toEqual([
expect.objectContaining({ relPath: 'dedicated_sp.cfg', driftKind: 'replace' })
]);
if (!capable) {
const repair = await api.prepareDriftCampaign(server);
expect(repair.files[0].after).toEqual(effective);
}
api.contents.set(
'configs/dedicated_sp.cfg',
Buffer.concat([effective, Buffer.from('set sv_voice "0"\n')])
);
expect(await api.scanServerDrift(server)).toHaveLength(1);
}
);
it('reports unavailable credentials as a scan failure rather than scheduling a destructive repair', async () => {
const api = await setup();
api.database
.prepare(
"UPDATE ec_host_operations SET history_expired_at='expired',payload='{}' WHERE id='launch'"
)
.run();
await expect(api.scanServerDrift(server)).rejects.toThrow(
'launch credentials are unavailable'
);
});
it('checks managed files, composed config, generated config, and untracked trees in one request', async () => {
const api = await setup();
expect(await api.scanServerDrift(server)).toEqual([]);
expectOneManifest(api.request);
api.request.mockClear();
expect(await api.prepareDriftCampaign(server)).toMatchObject({
plan: { files: [] },
files: []
});
expectOneManifest(api.request);
});
it('detects changed, missing, and nested untracked files from the same manifest', async () => {
const api = await setup();
api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.alloc(0));
api.contents.delete(`${pluginRoot}/managed.dll`);
api.contents.set('configs/dedicated_sp.cfg', Buffer.from('changed config'));
const stray = Buffer.from('untracked bytes');
api.contents.set(`${scriptRoot}/custom/stray.gsc`, stray);
const changes = await api.scanServerDrift(server);
expect(changes).toHaveLength(4);
expect(changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'script',
relPath: 'managed.gsc',
driftKind: 'replace',
sha256: api.script.version.sha256
}),
expect.objectContaining({
kind: 'plugin',
relPath: 'managed.dll',
driftKind: 'missing',
sha256: api.plugin.version.sha256
}),
expect.objectContaining({
kind: 'config',
relPath: 'dedicated_sp.cfg',
driftKind: 'replace'
}),
expect.objectContaining({
kind: 'script',
relPath: 'custom/stray.gsc',
driftKind: 'untracked',
size: stray.length,
sha256: hash(stray)
})
])
);
expectOneManifest(api.request);
});
it('still repairs missing inventory and generated map config', async () => {
const api = await setup();
api.database
.prepare('DELETE FROM server_scripts WHERE server=? AND rel_path=?')
.run(server, 'managed.gsc');
api.contents.delete(`configs/${api.mapConfigFile}`);
expect(await api.scanServerDrift(server)).toEqual(
expect.arrayContaining([
expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }),
expect.objectContaining({ relPath: api.mapConfigFile, driftKind: 'missing' })
])
);
expectOneManifest(api.request);
});
it('compares explicitly managed ignored filenames without treating runtime-owned files as untracked', async () => {
const api = await setup();
api.promoted('plugin', 'Managed ignored name', Buffer.from('expected'), 'dedicated.cfg');
api.contents.set(`${pluginRoot}/dedicated.cfg`, Buffer.from('changed'));
expect(await api.scanServerDrift(server)).toEqual([
expect.objectContaining({
kind: 'plugin',
relPath: 'dedicated.cfg',
driftKind: 'replace'
})
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request.mock.calls[0][2]).toMatchObject({
ignoreNames: [],
paths: expect.arrayContaining([`${pluginRoot}/dedicated.cfg`])
});
});
it.each(['running', 'ready-promote'])(
'skips remote reads while a %s beta owns the server',
async (state) => {
const api = await setup();
const beta = api.database
.prepare(
"INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual',?)"
)
.run(api.script.script.id, api.script.version.id, state).lastInsertRowid;
api.database
.prepare(
"INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')"
)
.run(beta, server);
expect(await api.scanServerDrift(server)).toEqual([]);
expect(api.request).not.toHaveBeenCalled();
}
);
it('preserves unavailable evidence as a failed scan without falling back to per-file reads', async () => {
const api = await setup();
api.request.mockRejectedValueOnce(new Error('Agent manifest unavailable'));
await expect(api.scanServerDrift(server)).rejects.toThrow('Agent manifest unavailable');
expectOneManifest(api.request);
});
it('rejects a malformed manifest instead of treating it as an empty tree', async () => {
const api = await setup();
api.request.mockResolvedValueOnce({} as never);
await expect(api.scanServerDrift(server)).rejects.toThrow();
expectOneManifest(api.request);
});
it('keeps legacy agents on the existing per-file scan and detects their drift', async () => {
const api = await setup(false);
api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.from('old script'));
api.contents.set('configs/stray.cfg', Buffer.from('untracked config'));
expect(await api.scanServerDrift(server)).toEqual(
expect.arrayContaining([
expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }),
expect.objectContaining({ relPath: 'stray.cfg', driftKind: 'untracked' })
])
);
const operations = api.request.mock.calls.map(([, kind]) => kind);
expect(operations).not.toContain('files.manifest');
expect(operations).toEqual(
expect.arrayContaining(['files.stat', 'files.list', 'files.read'])
);
});
});