Files
electric-cherry/tests/unit/native-observations.test.ts
T

115 lines
6.7 KiB
TypeScript

import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
async function setup() {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Observation host' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1 });
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO servers(name) VALUES('test')").run();
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES('instance','test',?,'library','image',28960,?)`).run(host.id, new Date().toISOString());
const native = await import('../../src/lib/server/backends/native.js');
const instance = native.nativeInstance('test')!;
return { ...hosts, ...native, db: db(), host, instance };
}
describe('overlapping native observations', () => {
it.each(['files.read', 'files.list', 'files.stat', 'files.manifest', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => {
const api = await setup();
const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(1);
api.reportHostOperation(api.host.id, operations[0].id, { state: 'completed', result: { value: 'first' } });
expect(await Promise.all(requests)).toEqual(Array(4).fill({ value: 'first' }));
const fresh = api.nativeRequest(api.instance, kind, { path: 'same' });
const next = api.listHostOperations(api.host.id)[0];
expect(next.id).not.toBe(operations[0].id);
api.reportHostOperation(api.host.id, next.id, { state: 'completed', result: { value: 'changed' } });
expect(await fresh).toEqual({ value: 'changed' });
});
it('does not merge different paths, mutations, or reads across lifecycle submissions', async () => {
const api = await setup();
const requests = [api.nativeRequest(api.instance, 'files.read', { path: 'one' }),
api.nativeRequest(api.instance, 'files.read', { path: 'two' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' })];
api.db.prepare("INSERT INTO restart_rollout(server,requested_at) VALUES('test',?)").run(new Date().toISOString());
requests.push(api.nativeRequest(api.instance, 'files.read', { path: 'one' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(5);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, { state: 'completed', result: {} });
await Promise.all(requests);
});
it('releases failed observations so the next request can recover', async () => {
const api = await setup();
const first = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(first).rejects.toThrow('unavailable');
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'failed', error: 'unavailable' });
await rejected;
const next = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
expect(api.listHostOperations(api.host.id)).toHaveLength(2);
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'completed', result: {} });
await next;
});
it('keeps replacement generations separate and rejects the old result', async () => {
const api = await setup();
const old = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(old).rejects.toThrow('Instance was replaced');
api.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
const current = api.nativeRequest(api.nativeInstance('test')!, 'files.read', { path: 'one' });
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(2);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, {
state: 'completed', result: { generation: operation.generation }
});
await rejected;
expect(await current).toEqual({ generation: 2 });
});
});
describe('native file reads through stored agent receipts', () => {
it.each([
{ name: 'binary content', content: Buffer.from([0, 255, 1, 128, 10]) },
{ name: 'empty content', content: Buffer.alloc(0) }
])('resolves $name from a stored blob reference', async ({ content }) => {
const api = await setup();
const { readFile } = await import('../../src/lib/server/backends/files.js');
const reading = readFile(join(fixture.t5, 'test', 'configs/server.cfg'));
const operation = api.listHostOperations(api.host.id)[0];
expect(operation).toMatchObject({ kind: 'files.read', payload: { path: 'configs/server.cfg' } });
api.reportHostOperationStatus(api.host.id, operation.id, {
state: 'completed', result: { contentBase64: content.toString('base64') }
});
expect(api.getHostOperationStatus(operation.id)?.result).toEqual({ contentSha256: expect.any(String) });
expect(await reading).toEqual(content);
});
it('preserves missing-file errors from the agent', async () => {
const api = await setup();
const { readFile } = await import('../../src/lib/server/backends/files.js');
const reading = expect(readFile(join(fixture.t5, 'test', 'missing'))).rejects.toMatchObject({ code: 'ENOENT' });
api.reportHostOperationStatus(api.host.id, api.listHostOperations(api.host.id)[0].id, {
state: 'completed', result: { contentBase64: null }
});
await reading;
});
it.each([null, {}, { contentBase64: 123 }, { contentSha256: 123 }])('reports an invalid response for malformed file content: %j', async result => {
const api = await setup();
const { readFile } = await import('../../src/lib/server/backends/files.js');
const reading = expect(readFile(join(fixture.t5, 'test', 'configs/server.cfg')))
.rejects.toThrow('Agent returned an invalid response for files.read: configs/server.cfg');
api.reportHostOperationStatus(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'completed', result });
await reading;
});
});