706 lines
31 KiB
TypeScript
706 lines
31 KiB
TypeScript
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 };
|
|
}
|
|
|
|
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('maintains exact scoped totals through receipts, expiration, rollback, deletion and reopening without recounting history', async () => {
|
|
const api = await operationHistory();
|
|
const { importantOperationSql } =
|
|
await import('../../src/lib/server/hosts/observations.js');
|
|
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
|
|
const ids = Array.from({ length: 35 }, (_, i) =>
|
|
api.add(i % 2 ? 'instance.inspect' : 'instance.restart', { state: 'pending' })
|
|
);
|
|
const verify = () => {
|
|
for (const host of [undefined, api.first.id, api.second.id])
|
|
for (const server of [undefined, 'server-one']) {
|
|
for (const filter of ['all', 'routine', 'important'] as const) {
|
|
const where = ['o.history_expired_at IS NULL'];
|
|
const args: string[] = [];
|
|
if (host) {
|
|
where.push('o.host_id=?');
|
|
args.push(host);
|
|
}
|
|
if (server) {
|
|
where.push('o.server_name=?');
|
|
args.push(server);
|
|
}
|
|
if (filter !== 'all')
|
|
where.push(
|
|
filter === 'routine' ? 'o.routine=1' : importantOperationSql
|
|
);
|
|
const expected = db()
|
|
.prepare(
|
|
`SELECT COUNT(*) total FROM ec_host_operations o WHERE ${where.join(' AND ')}`
|
|
)
|
|
.get(...args) as { total: number };
|
|
expect(
|
|
api.listHostOperationHistory(1, filter, { host, server }).total
|
|
).toBe(expected.total);
|
|
}
|
|
}
|
|
};
|
|
verify();
|
|
for (const [index, id] of ids.entries())
|
|
api.reportHostOperationStatus(
|
|
api.first.id,
|
|
id,
|
|
index % 3
|
|
? { state: 'completed', result: { healthy: index % 5 !== 0 } }
|
|
: { state: 'failed', error: 'failure' }
|
|
);
|
|
api.handle
|
|
.prepare(
|
|
"UPDATE ec_host_operations SET server_name='server-one',host_id=? WHERE rowid%2=0"
|
|
)
|
|
.run(api.second.id);
|
|
api.handle
|
|
.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE rowid%4=0')
|
|
.run(new Date().toISOString());
|
|
api.handle.exec(
|
|
'DELETE FROM ec_host_operation_events WHERE operation_id IN (SELECT id FROM ec_host_operations WHERE rowid%7=0); DELETE FROM ec_host_operations WHERE rowid%7=0'
|
|
);
|
|
expect(() =>
|
|
api.handle.transaction(() => {
|
|
api.handle.exec(
|
|
'DELETE FROM ec_host_operation_events; DELETE FROM ec_host_operations'
|
|
);
|
|
throw new Error('rollback');
|
|
})()
|
|
).toThrow('rollback');
|
|
verify();
|
|
closeDb();
|
|
verify();
|
|
const prepare = vi.spyOn(db(), 'prepare');
|
|
api.listHostOperationHistory(1);
|
|
api.listHostOperationHistory(2);
|
|
expect(prepare.mock.calls.some(([sql]) => /COUNT\(/i.test(sql))).toBe(false);
|
|
const count = prepare.mock.calls.find(([sql]) =>
|
|
sql.includes('FROM ec_host_operation_history_counts')
|
|
)![0];
|
|
expect(
|
|
db()
|
|
.prepare('EXPLAIN QUERY PLAN ' + count)
|
|
.all('', '')
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ detail: expect.stringContaining('USING PRIMARY KEY') })
|
|
])
|
|
);
|
|
});
|
|
|
|
it('scopes counts and pages to one host or server and preserves deleted-host history', async () => {
|
|
const api = await seedHistory(90);
|
|
api.handle
|
|
.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE host_id=?")
|
|
.run(api.first.id);
|
|
const first = api.listHostOperationHistory(1, 'all', { host: api.first.id });
|
|
const second = api.listHostOperationHistory(2, 'all', { host: api.first.id });
|
|
expect(first).toMatchObject({ total: 30, totalPages: 2 });
|
|
expect([...first.operations, ...second.operations]).toHaveLength(30);
|
|
expect(first.operations.every((operation) => operation.hostId === api.first.id)).toBe(true);
|
|
expect(api.listHostOperationHistory(1, 'all', { server: 'server-one' })).toEqual(first);
|
|
expect(
|
|
api.listHostOperationHistory(1, 'all', { host: api.second.id, server: 'server-one' })
|
|
.total
|
|
).toBe(0);
|
|
expect(
|
|
api
|
|
.listHostOperationHistory(1, 'all', { host: api.deleted.id })
|
|
.operations.every((operation) => operation.hostName === null)
|
|
).toBe(true);
|
|
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&host=${api.first.id}&page=2`
|
|
)
|
|
});
|
|
expect(await response.json()).toEqual(second);
|
|
});
|
|
|
|
it('applies host scope to failures as well as ordinary actions', async () => {
|
|
const api = await operationHistory();
|
|
const first = api.add('instance.restart');
|
|
const other = api.queueHostOperation(api.second.id, 'instance.inspect', {});
|
|
api.reportHostOperationStatus(api.second.id, other.id, {
|
|
state: 'failed',
|
|
error: 'Unreachable'
|
|
});
|
|
expect(
|
|
api
|
|
.listHostOperationHistory(1, 'important', { host: api.first.id })
|
|
.operations.map((operation) => operation.id)
|
|
).toEqual([first]);
|
|
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'));
|
|
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,
|
|
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, '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, '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');
|
|
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('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,
|
|
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 TRIGGER operation_history_count_insert;
|
|
DROP TRIGGER operation_history_count_delete;
|
|
DROP TRIGGER operation_history_count_update;
|
|
DROP TABLE ec_host_operation_history_counts;
|
|
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1';
|
|
DROP INDEX ec_host_operations_important_history;
|
|
DROP INDEX ec_host_operations_server_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?filter=all')
|
|
});
|
|
expect(first.headers.get('cache-control')).toBe('no-store');
|
|
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, '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&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('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';
|
|
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 })
|
|
);
|
|
}
|
|
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' },
|
|
{ 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');
|
|
});
|
|
});
|