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'); }); });