Move host operation history to a paginated Hosts page
This commit is contained in:
@@ -12,6 +12,7 @@ describe('management route permissions', () => {
|
||||
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
|
||||
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
|
||||
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
|
||||
'/hosts/operations', '/api/hosts/operations',
|
||||
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
|
||||
])('lets viewers read the safe summary route %s', route => {
|
||||
expect(routePermission(route, 'GET')).toBe('viewer');
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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 () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||
|
||||
async function seedHistory(count = 53) {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const first = hosts.createHost({ name: 'Alpha host' });
|
||||
const second = hosts.createHost({ name: 'Beta host' });
|
||||
const deleted = hosts.createHost({ name: 'Removed host' });
|
||||
const handle = db();
|
||||
const ids: string[] = [];
|
||||
handle.transaction(() => {
|
||||
const insert = handle.prepare(`INSERT INTO ec_host_operations
|
||||
(id,host_id,kind,instance_id,generation,payload,result,state,error,created_at,updated_at)
|
||||
VALUES(?,?,'instance.inspect','instance-one',2,?,?,'completed',NULL,?,?)`);
|
||||
for (let index = 0; index < count; index++) {
|
||||
// Reverse IDs and tied timestamps ensure insertion order breaks ties.
|
||||
const id = `operation-${String(count - index).padStart(4, '0')}`;
|
||||
const timestamp = index < 3 ? '2026-10-01T12:00:00.000Z' : '2026-10-05T12:00:00.000Z';
|
||||
ids.push(id);
|
||||
insert.run(id, [first, second, deleted][index % 3].id,
|
||||
'{"credential":"payload-secret"}', '{"credential":"result-secret"}', timestamp, timestamp);
|
||||
}
|
||||
handle.prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run('2026-10-05T12:30:00.000Z', deleted.id);
|
||||
})();
|
||||
return { ...hosts, handle, ids, first, second, deleted };
|
||||
}
|
||||
|
||||
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));
|
||||
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 }));
|
||||
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]);
|
||||
});
|
||||
|
||||
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 query = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.includes('FROM ec_host_operations o LEFT JOIN'))!;
|
||||
prepare.mockRestore();
|
||||
const projection = query.split('FROM ec_host_operations')[0];
|
||||
expect(projection).not.toContain('payload');
|
||||
expect(projection).not.toContain('result');
|
||||
expect(projection).not.toContain('*');
|
||||
expect(query).toContain('LIMIT ? OFFSET ?');
|
||||
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_history'))).toBe(true);
|
||||
expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false);
|
||||
expect(history.operations).toHaveLength(25);
|
||||
expect(history.operations[0]).toMatchObject({ instanceId: 'instance-one', generation: 2, state: 'completed' });
|
||||
expect(history.operations.every(operation => !('payload' in operation) && !('result' in operation))).toBe(true);
|
||||
expect(JSON.stringify(history)).not.toContain('secret');
|
||||
});
|
||||
|
||||
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 };
|
||||
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('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') });
|
||||
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') });
|
||||
const body = await second.json();
|
||||
expect(body).toEqual(api.listHostOperationHistory(2));
|
||||
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));
|
||||
});
|
||||
|
||||
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';
|
||||
for (const page of ['', '0', '-1', '1.5', 'nope', 'Infinity', '9007199254740992']) {
|
||||
expect(() => GET({ request: new Request(`${url}?page=${page}`) })).toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
const blockedHeaders: HeadersInit[] = [
|
||||
{ origin: 'https://other.test' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ authorization: 'Bearer ec_host_secret' },
|
||||
{ authorization: 'Bearer ec_enroll_secret' }
|
||||
];
|
||||
for (const headers of blockedHeaders) {
|
||||
expect(() => GET({ request: new Request(url, { headers }) })).toThrow(expect.objectContaining({ status: 403 }));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps operation history out of host detail responses', async () => {
|
||||
const api = await seedHistory();
|
||||
const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||
const body = await GET({ request: new Request(`https://ec.test/api/hosts/${api.first.id}`), params: { id: api.first.id } }).json();
|
||||
expect(body.host.id).toBe(api.first.id);
|
||||
expect(body).not.toHaveProperty('operations');
|
||||
expect(JSON.stringify(body)).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user