Add filters and context to host operation history
This commit is contained in:
@@ -30,27 +30,43 @@ async function seedHistory(count = 53) {
|
||||
return { ...hosts, handle, ids, first, second, deleted };
|
||||
}
|
||||
|
||||
async function operationHistory() {
|
||||
const api = await seedHistory(0);
|
||||
function add(kind: string, options: { payload?: Record<string, unknown>; state?: string; result?: unknown; error?: string } = {}) {
|
||||
const operation = api.queueHostOperation(api.first.id, kind, options.payload ?? {});
|
||||
if (options.state !== 'pending') api.reportHostOperationStatus(api.first.id, operation.id, {
|
||||
state: options.state ?? 'completed', result: options.result, error: options.error
|
||||
});
|
||||
return operation.id;
|
||||
}
|
||||
return { ...api, add };
|
||||
}
|
||||
|
||||
describe('host operation history', () => {
|
||||
it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => {
|
||||
const api = await seedHistory();
|
||||
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page));
|
||||
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page, 'all'));
|
||||
expect(pages.map(page => page.operations.length)).toEqual([25, 25, 3]);
|
||||
pages.forEach((page, index) => expect(page).toMatchObject({ page: index + 1, pageSize: 25, total: 53, totalPages: 3 }));
|
||||
pages.forEach((page, index) => expect(page).toMatchObject({ page: index + 1, pageSize: 25, total: 53, totalPages: 3, filter: 'all' }));
|
||||
const operations = pages.flatMap(page => page.operations);
|
||||
expect(operations.map(operation => operation.id)).toEqual([...api.ids].reverse());
|
||||
expect(operations.find(operation => operation.hostId === api.first.id)?.hostName).toBe('Alpha host');
|
||||
expect(operations.find(operation => operation.hostId === api.second.id)?.hostName).toBe('Beta host');
|
||||
expect(operations.filter(operation => operation.hostId === api.deleted.id).length).toBeGreaterThan(0);
|
||||
expect(operations.filter(operation => operation.hostId === api.deleted.id).every(operation => operation.hostName === null)).toBe(true);
|
||||
expect(api.listHostOperationHistory(999)).toEqual(pages[2]);
|
||||
expect(api.listHostOperationHistory(999, 'all')).toEqual(pages[2]);
|
||||
});
|
||||
|
||||
it('bounds reads in SQLite using the history index and omits private payloads and results', async () => {
|
||||
const api = await seedHistory(1003);
|
||||
const prepare = vi.spyOn(api.handle, 'prepare');
|
||||
const history = api.listHostOperationHistory(2);
|
||||
const history = api.listHostOperationHistory(2, 'all');
|
||||
api.listHostOperationHistory(1, 'important');
|
||||
api.listHostOperationHistory(1, 'routine');
|
||||
const statements = prepare.mock.calls.map(([sql]) => sql);
|
||||
const query = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.includes('FROM ec_host_operations o LEFT JOIN'))!;
|
||||
prepare.mockRestore();
|
||||
expect(statements.every(sql => !/\b(?:payload|result|json_extract|ec_host_operation_payloads)\b/.test(sql))).toBe(true);
|
||||
const projection = query.split('FROM ec_host_operations')[0];
|
||||
expect(projection).not.toContain('payload');
|
||||
expect(projection).not.toContain('result');
|
||||
@@ -65,28 +81,167 @@ describe('host operation history', () => {
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('seeks through the selected history index without sorting or reading private payloads', async () => {
|
||||
const api = await seedHistory(1003);
|
||||
for (const filter of ['important', 'routine'] as const) {
|
||||
const prepare = vi.spyOn(api.handle, 'prepare');
|
||||
api.listHostOperationHistory(2, filter);
|
||||
const query = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.includes('FROM ec_host_operations o LEFT JOIN'))!;
|
||||
prepare.mockRestore();
|
||||
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all(25, 25) as { detail: string }[];
|
||||
expect(plan.some(row => row.detail.includes(`USING INDEX ec_host_operations_${filter}_history`))).toBe(true);
|
||||
expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false);
|
||||
expect(query).not.toMatch(/\b(?:payload|result|json_extract)\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty first page and normalizes invalid internal page numbers', async () => {
|
||||
const api = await seedHistory(0);
|
||||
const empty = { operations: [], page: 1, pageSize: 25, total: 0, totalPages: 1 };
|
||||
const empty = { operations: [], page: 1, pageSize: 25, total: 0, totalPages: 1, filter: 'important' };
|
||||
expect(api.listHostOperationHistory()).toEqual(empty);
|
||||
for (const page of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 999]) {
|
||||
expect(api.listHostOperationHistory(page)).toEqual(empty);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to actions and errors, with independent counts and pagination for every filter', async () => {
|
||||
const api = await operationHistory();
|
||||
const actions = Array.from({ length: 26 }, () => api.add('instance.restart'));
|
||||
actions.push(api.add('rcon.execute', { payload: { command: 'map_restart' } }));
|
||||
const successful = Array.from({ length: 26 }, () => api.add('files.manifest', { result: { files: [] } }));
|
||||
const pending = ['pending', 'accepted', 'running'].map(state => api.add('files.read', { state }));
|
||||
const failures = ['failed', 'unknown'].map(state => api.add('files.read', { state, error: 'Observation unavailable' }));
|
||||
const negative = [
|
||||
api.add('rcon.execute', { payload: { command: 'status' }, result: { ok: false, reason: 'timeout' } }),
|
||||
api.add('instance.inspect', { result: { healthy: false, state: 'stopped' } }),
|
||||
api.add('files.read', { result: { error: 'Read was incomplete' } }),
|
||||
api.add('files.stat', { error: 'Could not inspect the file' })
|
||||
];
|
||||
const ids = [...actions, ...successful, ...pending, ...failures, ...negative];
|
||||
for (const [filter, matching] of [
|
||||
['important', [...actions, ...failures, ...negative]],
|
||||
['routine', [...successful, ...pending, ...failures, ...negative]],
|
||||
['all', ids]
|
||||
] as const) {
|
||||
const pages = Array.from({ length: Math.ceil(matching.length / 25) }, (_, index) => api.listHostOperationHistory(index + 1, filter));
|
||||
expect(pages[0]).toMatchObject({ filter, total: matching.length, totalPages: pages.length, pageSize: 25 });
|
||||
expect(pages.flatMap(page => page.operations.map(operation => operation.id))).toEqual([...matching].reverse());
|
||||
expect(api.listHostOperationHistory(999, filter)).toEqual(pages.at(-1));
|
||||
}
|
||||
expect(api.listHostOperationHistory()).toEqual(api.listHostOperationHistory(1, 'important'));
|
||||
const routine = api.listHostOperationHistory(1, 'routine').operations;
|
||||
expect(routine.every(operation => operation.routine)).toBe(true);
|
||||
expect(api.listHostOperationHistory(2, 'important').operations.every(operation => !operation.routine)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps manual RCON commands visible and only exposes safe read command names', async () => {
|
||||
const api = await operationHistory();
|
||||
const reads = ['status', 'getstatus', 'version'].map(command => api.add('rcon.execute', { payload: { command } }));
|
||||
const manual = api.add('rcon.execute', { payload: { command: 'say credential-secret' } });
|
||||
const combined = api.add('rcon.execute', { payload: { command: 'status; map_restart' } });
|
||||
const important = api.listHostOperationHistory();
|
||||
expect(important.operations.map(operation => operation.id)).toEqual([combined, manual]);
|
||||
expect(important.operations.every(operation => !operation.routine && operation.targetPath === null)).toBe(true);
|
||||
const routine = api.listHostOperationHistory(1, 'routine');
|
||||
expect(routine.operations.map(operation => operation.id)).toEqual([...reads].reverse());
|
||||
expect(routine.operations.map(operation => operation.targetPath)).toEqual(['version', 'getstatus', 'status']);
|
||||
expect(JSON.stringify(api.listHostOperationHistory(1, 'all'))).not.toContain('credential-secret');
|
||||
});
|
||||
|
||||
it('persists server, target, and source summaries without depending on current instance records', async () => {
|
||||
const api = await operationHistory();
|
||||
const { withOperationSource } = await import('../../src/lib/server/hosts/operation-context.js');
|
||||
api.handle.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('history-instance','t5_zombie_theater_1',?,'library','plutainer:test',28960,?)`)
|
||||
.run(api.first.id, new Date().toISOString());
|
||||
const read = withOperationSource('Routine status', () => api.queueHostOperation(api.first.id, 'files.read', {
|
||||
path: 'configs/server.cfg', credential: 'payload-secret'
|
||||
}, { instanceId: 'history-instance', generation: 1 }));
|
||||
const manifest = withOperationSource('Watchdog drift scan', () => api.queueHostOperation(api.first.id, 'files.manifest', {
|
||||
roots: ['scripts', 'configs', 'plugins'], ignoreNames: [], paths: ['configs/server.cfg']
|
||||
}, { instanceId: 'history-instance', generation: 1 }));
|
||||
api.reportHostOperationStatus(api.first.id, read.id, { state: 'completed', result: { contentBase64: 'result-secret' } });
|
||||
api.handle.prepare('DELETE FROM native_instances WHERE id=?').run('history-instance');
|
||||
const history = api.listHostOperationHistory(1, 'all');
|
||||
expect(history.operations.find(operation => operation.id === read.id)).toMatchObject({
|
||||
serverName: 't5_zombie_theater_1', targetPath: 'configs/server.cfg', source: 'Routine status', routine: true
|
||||
});
|
||||
expect(history.operations.find(operation => operation.id === manifest.id)).toMatchObject({
|
||||
serverName: 't5_zombie_theater_1', targetPath: 'scripts, configs, plugins', source: 'Watchdog drift scan', routine: true
|
||||
});
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('refuses unsafe target summaries and never derives labels from arbitrary payload fields', async () => {
|
||||
const api = await operationHistory();
|
||||
const unsafe = ['/etc/private-secret', '../private-secret', 'configs/../../private-secret', 'configs/line\nprivate-secret', 'configs\\private-secret'];
|
||||
const ids = unsafe.map(path => api.add('files.read', { payload: { path, command: 'command-secret', name: 'name-secret' } }));
|
||||
ids.push(api.add('files.manifest', { payload: { roots: ['scripts', '../private-secret'] } }));
|
||||
ids.push(api.add('instance.restart', { payload: { path: 'configs/private-secret', command: 'command-secret' } }));
|
||||
const history = api.listHostOperationHistory(1, 'all');
|
||||
expect(history.operations).toHaveLength(ids.length);
|
||||
expect(history.operations.every(operation => operation.targetPath === null && operation.serverName === null)).toBe(true);
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('backfills safe legacy summaries from immutable payloads while leaving source unknown', async () => {
|
||||
const api = await operationHistory();
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
api.handle.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('legacy-instance','t5_zombie_theater_1',?,'library','plutainer:test',28960,?)`)
|
||||
.run(api.first.id, new Date().toISOString());
|
||||
const read = api.queueHostOperationSerialized(api.first.id, 'files.read', Buffer.from(JSON.stringify({
|
||||
path: 'configs/server.cfg', credential: 'payload-secret'
|
||||
})), { instanceId: 'legacy-instance', generation: 1 }, { path: 'configs/routing-secret' });
|
||||
api.reportHostOperationStatus(api.first.id, read.id, { state: 'completed', result: { credential: 'result-secret' } });
|
||||
const command = api.queueHostOperationSerialized(api.first.id, 'rcon.execute', Buffer.from('{"command":"status"}'), {}, { command: 'command-secret' });
|
||||
const manual = api.add('rcon.execute', { payload: { command: 'say command-secret' } });
|
||||
const unsafe = api.add('files.read', { payload: { path: '../private-secret' } });
|
||||
api.handle.exec(`DROP INDEX ec_host_operations_routine_history;
|
||||
DROP INDEX ec_host_operations_important_history;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN routine;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN server_name;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN target_path;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN source;`);
|
||||
closeDb();
|
||||
const history = api.listHostOperationHistory(1, 'all');
|
||||
expect(history.operations).toHaveLength(4);
|
||||
expect(history.operations.find(operation => operation.id === read.id)).toMatchObject({
|
||||
routine: true, serverName: 't5_zombie_theater_1', targetPath: 'configs/server.cfg', source: null
|
||||
});
|
||||
expect(history.operations.find(operation => operation.id === command.id)).toMatchObject({ routine: true, targetPath: 'status', source: null });
|
||||
expect(history.operations.find(operation => operation.id === manual)).toMatchObject({ routine: false, targetPath: null, source: null });
|
||||
expect(history.operations.find(operation => operation.id === unsafe)).toMatchObject({ routine: true, targetPath: null, source: null });
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
closeDb();
|
||||
expect(api.listHostOperationHistory(1, 'all')).toEqual(history);
|
||||
});
|
||||
|
||||
it('serves fixed-size API pages without caching or exposing private operation data', async () => {
|
||||
const api = await seedHistory();
|
||||
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||
const first = GET({ request: new Request('https://ec.test/api/hosts/operations') });
|
||||
const first = GET({ request: new Request('https://ec.test/api/hosts/operations?filter=all') });
|
||||
expect(first.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await first.json()).toEqual(api.listHostOperationHistory(1));
|
||||
const second = GET({ request: new Request('https://ec.test/api/hosts/operations?page=2&pageSize=500') });
|
||||
expect(await first.json()).toEqual(api.listHostOperationHistory(1, 'all'));
|
||||
const second = GET({ request: new Request('https://ec.test/api/hosts/operations?page=2&pageSize=500&filter=all') });
|
||||
const body = await second.json();
|
||||
expect(body).toEqual(api.listHostOperationHistory(2));
|
||||
expect(body).toEqual(api.listHostOperationHistory(2, 'all'));
|
||||
expect(body.operations).toHaveLength(25);
|
||||
expect(JSON.stringify(body)).not.toContain('secret');
|
||||
const last = GET({ request: new Request('https://ec.test/api/hosts/operations?page=999') });
|
||||
expect(await last.json()).toEqual(api.listHostOperationHistory(3));
|
||||
const last = GET({ request: new Request('https://ec.test/api/hosts/operations?page=999&filter=all') });
|
||||
expect(await last.json()).toEqual(api.listHostOperationHistory(3, 'all'));
|
||||
});
|
||||
|
||||
it('serves the selected filter and defaults the API to actions and errors', async () => {
|
||||
const api = await operationHistory();
|
||||
const action = api.add('instance.restart');
|
||||
const read = api.add('instance.inspect', { result: { healthy: true } });
|
||||
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
|
||||
const request = (query = '') => GET({ request: new Request(`https://ec.test/api/hosts/operations${query}`) }).json();
|
||||
expect(await request()).toEqual(api.listHostOperationHistory());
|
||||
expect((await request()).operations.map((operation: { id: string }) => operation.id)).toEqual([action]);
|
||||
expect((await request('?filter=routine')).operations.map((operation: { id: string }) => operation.id)).toEqual([read]);
|
||||
expect(await request('?filter=all')).toEqual(api.listHostOperationHistory(1, 'all'));
|
||||
});
|
||||
|
||||
it('rejects invalid API pages and keeps host management access restrictions', async () => {
|
||||
@@ -95,6 +250,9 @@ describe('host operation history', () => {
|
||||
for (const page of ['', '0', '-1', '1.5', 'nope', 'Infinity', '9007199254740992']) {
|
||||
expect(() => GET({ request: new Request(`${url}?page=${page}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
for (const filter of ['', 'unknown', 'ALL', 'important%27%20OR%201=1']) {
|
||||
expect(() => GET({ request: new Request(`${url}?filter=${filter}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
const blockedHeaders: HeadersInit[] = [
|
||||
{ origin: 'https://other.test' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
|
||||
Reference in New Issue
Block a user