Unify retained operation history in Activity

This commit is contained in:
2026-10-07 19:30:38 -04:00
parent c39563daef
commit 05bdeaf624
35 changed files with 1008 additions and 120 deletions
+35
View File
@@ -102,6 +102,27 @@ describe('host operation history', () => {
expect(api.listHostOperationHistory(1, 'important', { host: api.second.id }).operations.map(operation => operation.id)).toEqual([other.id]);
});
it('finds an exact old operation without walking pages and keeps all scope and retention filters', async () => {
const api = await seedHistory(1003);
const operation = api.ids[0];
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE id=?").run(operation);
const prepare = vi.spyOn(api.handle, 'prepare');
const history = api.listHostOperationHistory(999, 'all', { operation, host: api.first.id, server: 'server-one' });
const exactCount = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.startsWith('SELECT 1 AS total'))!;
prepare.mockRestore();
expect(history).toMatchObject({ page: 1, total: 1, totalPages: 1 });
expect(history.operations.map(row => row.id)).toEqual([operation]);
expect(JSON.stringify(history)).not.toContain('secret');
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${exactCount}`).all(api.first.id, 'server-one', operation) as { detail: string }[];
expect(plan.some(row => row.detail.includes('USING INDEX sqlite_autoindex_ec_host_operations_1'))).toBe(true);
expect(api.listHostOperationHistory(1, 'all', { operation, host: api.second.id }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'all', { operation, server: 'another-server' }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'routine', { operation }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'all', { operation: 'missing-operation' }).total).toBe(0);
api.handle.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE id=?').run(new Date().toISOString(), operation);
expect(api.listHostOperationHistory(1, 'all', { operation })).toMatchObject({ operations: [], page: 1, total: 0 });
});
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, 'all'));
@@ -309,6 +330,17 @@ describe('host operation history', () => {
expect(await request('?filter=all')).toEqual(api.listHostOperationHistory(1, 'all'));
});
it('serves an exact old operation through the API without exposing command data', async () => {
const api = await seedHistory();
const operation = api.ids[0];
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const response = GET({ request: new Request(`https://ec.test/api/hosts/operations?filter=all&operation=${operation}`) });
const body = await response.json();
expect(body).toMatchObject({ page: 1, total: 1, totalPages: 1 });
expect(body.operations.map((row: { id: string }) => row.id)).toEqual([operation]);
expect(JSON.stringify(body)).not.toContain('secret');
});
it('rejects invalid API pages and keeps host management access restrictions', async () => {
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const url = 'https://ec.test/api/hosts/operations';
@@ -318,6 +350,9 @@ describe('host operation history', () => {
for (const filter of ['', 'unknown', 'ALL', 'important%27%20OR%201=1']) {
expect(() => GET({ request: new Request(`${url}?filter=${filter}`) })).toThrow(expect.objectContaining({ status: 400 }));
}
for (const operation of ['', 'x'.repeat(513), 'operation%00bad', 'operation%0Abad']) {
expect(() => GET({ request: new Request(`${url}?operation=${operation}`) })).toThrow(expect.objectContaining({ status: 400 }));
}
const blockedHeaders: HeadersInit[] = [
{ origin: 'https://other.test' },
{ 'sec-fetch-site': 'cross-site' },