diff --git a/docs/native-hosts.md b/docs/native-hosts.md index 90a021d..d866dce 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -59,7 +59,9 @@ T5 and for T6 when available, one setup operation at a time. Uploading new files or enrolling another host needs no manual propagation. Setup waits for an active, connected, healthy host with sufficient storage. Failed operations retry automatically with backoff from one minute to fifteen minutes; their errors remain visible in the -host's setup panel and operation history. Disconnected hosts resume when they +host's setup panel and **Hosts → Operation history**. This shared history lists +operations across hosts, newest first, with a host column and 25 rows per page. +Disconnected hosts resume when they reconnect. Draining and disabled hosts do not receive new automatic setup work. An automatic download that has not been delivered to the agent can be replaced @@ -284,7 +286,9 @@ explicit installer `--reenroll` when local credentials already exist. API outline: -- `GET /api/hosts/:id`: host details, operation history and deletion blockers. +- `GET /api/hosts/:id`: host details and deletion blockers. +- `GET /api/hosts/operations?page=1`: operation history across hosts, newest first, + with 25 summaries per page and total/page counts. Payloads and results are omitted. - `DELETE /api/hosts/:id`: administrator requests cleanup of an unused host; returns `202` while remote removal is pending, `200` for an unenrolled host, or `409` when the agent needs updating or unresolved ownership remains. diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index dccd698..5f2c1d3 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -100,6 +100,12 @@ export interface HostOperation { payload: Record; state: HostOperationState; result: unknown; error: string | null; createdAt: string; updatedAt: string; } +export interface HostOperationSummary extends Omit { + hostName: string | null; +} +export interface HostOperationHistoryPage { + operations: HostOperationSummary[]; page: number; pageSize: 25; total: number; totalPages: number; +} export interface HostPreparationItem { kind: 'runtime' | 'library'; label: string; diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index 00d3a9e..b5b4a8c 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -6,7 +6,7 @@ import { db } from '../db/index.js'; import { ensureHostSchema } from './schema.js'; import { recordInstanceResources, validateInstanceResources } from './calibration.js'; import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js'; -import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js'; +import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationHistoryPage, HostOperationState } from '../../hosts.js'; export const AGENT_PROTOCOL_VERSION = 1; export const HEARTBEAT_MAX_AGE_MS = 90_000; @@ -356,6 +356,22 @@ export function listHostOperations(hostId: string, limit = 100, summary = false) return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`) .all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); } +/** Browse retained history without loading command payloads or credential-bearing results. */ +export function listHostOperationHistory(page = 1): HostOperationHistoryPage { + const handle = database(); + const pageSize = 25; + return handle.transaction((): HostOperationHistoryPage => { + const { total } = handle.prepare('SELECT COUNT(*) AS total FROM ec_host_operations').get() as { total: number }; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1); + const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind, + o.instance_id AS instanceId,o.generation,o.state,o.error,o.created_at AS createdAt,o.updated_at AS updatedAt + FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL + ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`) + .all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations']; + return { operations, page: currentPage, pageSize, total, totalPages }; + })(); +} /** Heartbeats can tell an executing create to stop at its next safe command boundary. */ export function pendingCreationCancellations(hostId: string): string[] { return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations diff --git a/src/lib/server/hosts/schema.ts b/src/lib/server/hosts/schema.ts index 5429424..b3d15a0 100644 --- a/src/lib/server/hosts/schema.ts +++ b/src/lib/server/hosts/schema.ts @@ -31,6 +31,8 @@ export function ensureHostSchema(handle: Database.Database): void { UNIQUE(host_id, idempotency_key) ); CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at); + -- Reversing this index also reverses its implicit rowid tie-breaker. + CREATE INDEX IF NOT EXISTS ec_host_operations_history ON ec_host_operations(created_at); -- Polling checks cancellations and preparation by kind across retained -- history. The queue index cannot narrow those lookups beyond host_id, -- causing a history scan for every queued command on Node's main thread. diff --git a/src/lib/ui/components/DeleteHostDialog.svelte b/src/lib/ui/components/DeleteHostDialog.svelte index 428bd4a..496d839 100644 --- a/src/lib/ui/components/DeleteHostDialog.svelte +++ b/src/lib/ui/components/DeleteHostDialog.svelte @@ -48,7 +48,7 @@

This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.

The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.

{#if checking}

Checking servers and unfinished operations…

- {:else if deletion && !deletion.allowed}
This host still has work to finish:
    {#each deletion.blockers as blocker}
  • {blocker}
  • {/each}

Open open = false}>host details to review its operations or drain its servers.

+ {:else if deletion && !deletion.allowed}
This host still has work to finish:
    {#each deletion.blockers as blocker}
  • {blocker}
  • {/each}

Review open = false}>operation history or open open = false}>host details to drain its servers.

{:else if deletion?.allowed}

Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.

{/if} {#if error}{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 57c2bd6..83aacf7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -143,9 +143,18 @@ const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin')); const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname) ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }] + : tabs.find(tab => tab.label === 'Hosts')!.match($page.url.pathname) + ? [{ href: '/hosts', label: 'All hosts' }, { href: '/hosts/operations', label: 'Operation history' }] : tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname) ? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])] : tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []); + + function secondaryActive(href: string): boolean { + const path = $page.url.pathname; + if (href === '/settings') return path === href; + if (href === '/hosts') return path === href || /^\/hosts\/(?!operations(?:\/|$))/.test(path); + return path === href || path.startsWith(href + '/'); + } @@ -184,7 +193,7 @@ {/if} - {#if sessionReady && secondary.length}{/if} + {#if sessionReady && secondary.length}{/if} {/if}
{#if sessionReady && data.user?.role === 'viewer'}{/if} diff --git a/src/routes/api/hosts/[id]/+server.ts b/src/routes/api/hosts/[id]/+server.ts index 61ebcaa..32041d5 100644 --- a/src/routes/api/hosts/[id]/+server.ts +++ b/src/routes/api/hosts/[id]/+server.ts @@ -1,15 +1,13 @@ import { capacityCalibration } from '$lib/server/hosts/calibration.js'; import { error, json } from '@sveltejs/kit'; -import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js'; +import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost } from '$lib/server/hosts/index.js'; import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { presentHostPreparation } from '$lib/server/hosts/preparation.js'; import { config } from '$lib/server/config.js'; type Event = { request: Request; params: { id: string } }; export function GET({ request, params }: Event) { requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.'); - // Payloads and results can contain RCON keys or staged credentials. History - // exposes metadata only; agent APIs remain the only command delivery path. - return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id, 100, true).map(({ payload: _payload, result: _result, ...operation }) => operation) }); + return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) }); } export async function PATCH({ request, params }: Event) { requireManagementOrigin(request); diff --git a/src/routes/api/hosts/operations/+server.ts b/src/routes/api/hosts/operations/+server.ts new file mode 100644 index 0000000..fc6642d --- /dev/null +++ b/src/routes/api/hosts/operations/+server.ts @@ -0,0 +1,10 @@ +import { error, json } from '@sveltejs/kit'; +import { listHostOperationHistory } from '$lib/server/hosts/index.js'; +import { requireManagementOrigin } from '$lib/server/hosts/http.js'; + +export function GET({ request }: { request: Request }) { + requireManagementOrigin(request); + const page = Number(new URL(request.url).searchParams.get('page') ?? '1'); + if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.'); + return json(listHostOperationHistory(page), { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index cac5ae0..9338e18 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -4,14 +4,14 @@ let requestSequence = 0; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import type { Host, HostOperation, HostMode, CapacityCalibration } from '$lib/hosts.js'; + import type { Host, HostMode, CapacityCalibration } from '$lib/hosts.js'; import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import HostPreparation from '$ui/components/HostPreparation.svelte'; import CapacitySamples from '$ui/components/CapacitySamples.svelte'; - let host = $state(null); let operations = $state([]); + let host = $state(null); let calibration = $state(null); let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state(''); let deleteOpen = $state(false); @@ -24,7 +24,7 @@ async function load(initialize = false, signal?: AbortSignal) { const target = endpoint; const sequence = ++requestSequence; try { - const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; calibration = body.calibration ?? null; error = ''; + const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = ''; if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; } } catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; } } @@ -45,8 +45,8 @@ {host?.name ?? 'Host'} · Electric Cherry -
← All hosts - {#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}{/if}{/snippet} +
← All hosts + {#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}{/if}{/snippet} {#if error}{/if}{#if notice}

{notice}

{/if} {#if loading}

Loading host…

{:else if host} @@ -85,11 +85,10 @@

Agent access

Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.

{#if canManage}
{#if enrollment}

Expires {new Date(enrollment.expiresAt).toLocaleString()}

{enrollment.command}
{/if}{:else}

Administrators manage enrollment and agent credentials.

{/if}
-

Operation history

{#if !operations.length}

No remote operations yet.

{:else}
{#each operations as operation}{/each}
OperationStateUpdatedResult
{operation.kind}{operation.id}{operation.state}{new Date(operation.updatedAt).toLocaleString()}{operation.error ?? '—'}
{/if}
{/if}
{ if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} /> diff --git a/src/routes/hosts/operations/+page.server.ts b/src/routes/hosts/operations/+page.server.ts new file mode 100644 index 0000000..4dac42e --- /dev/null +++ b/src/routes/hosts/operations/+page.server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; +import { listHostOperationHistory } from '$lib/server/hosts/index.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ url }) => { + const requestedPage = url.searchParams.get('page'); + const history = listHostOperationHistory(Number(requestedPage ?? 1)); + if (requestedPage !== null && requestedPage !== String(history.page)) { + redirect(307, history.page === 1 ? '/hosts/operations' : `/hosts/operations?page=${history.page}`); + } + return { history }; +}; diff --git a/src/routes/hosts/operations/+page.svelte b/src/routes/hosts/operations/+page.svelte new file mode 100644 index 0000000..6036e6f --- /dev/null +++ b/src/routes/hosts/operations/+page.svelte @@ -0,0 +1,107 @@ + + +Operation history · Hosts · Electric Cherry + +
+ + {#snippet actions()}{/snippet} + + {#if refreshError}{/if} +
+ {#if !history.operations.length}

No remote operations yet.

+ {:else} + +
+ + + {#each history.operations as operation (operation.id)} + + + + + + + + {/each} +
OperationHostStateUpdatedResult
{operation.kind}{operation.id}{#if operation.hostName !== null}{operation.hostName}{:else}Deleted host{operation.hostId}{/if}{operation.state}{$timeFormat.dateTime(operation.updatedAt)}{operation.error ?? '—'}
+
+ + {/if} +
+
+ + diff --git a/src/routes/provisioning/[id]/+page.svelte b/src/routes/provisioning/[id]/+page.svelte index bc8853c..c94425c 100644 --- a/src/routes/provisioning/[id]/+page.svelte +++ b/src/routes/provisioning/[id]/+page.svelte @@ -125,7 +125,7 @@ {#if detail.agentOperation.error}

{detail.agentOperation.error}

{/if} {#if outcome.active && detail.agentOperation.state === 'completed'}

The agent finished its command. EC is verifying readiness.

{/if} - View host operation history + View host operation history {/if} {#if outcome.active && detail.workerActivity} diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index 712bc42..c9ef968 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -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'); diff --git a/tests/unit/host-operation-history.test.ts b/tests/unit/host-operation-history.test.ts new file mode 100644 index 0000000..073aa90 --- /dev/null +++ b/tests/unit/host-operation-history.test.ts @@ -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'); + }); +});