Files
electric-cherry/tests/unit/host-request-cost.test.ts
T
2026-10-12 19:13:29 -04:00

251 lines
12 KiB
TypeScript

import { setImmediate } from 'node:timers/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HostInventory } from '../../src/lib/hosts.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const epoch = Date.parse('2026-10-05T12:00:00Z');
const inventory = {
hostname: 'request-cache-host',
protocolVersion: 1,
dockerRunning: true,
directoriesWritable: true,
storageAvailable: 100 * 1024 ** 3,
preparedRuntimeImages: ['plutainer:test'],
libraries: [{ id: 'test-library', state: 'ready' }]
};
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(epoch);
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
await fixture.cleanup();
});
async function enrolled() {
const hosts = await import('../../src/lib/server/hosts/index.js');
const context = await import('../../src/lib/server/hosts/request-context.js');
const host = hosts.createHost({ name: 'Request cache host' });
const { credential } = hosts.enrollHost(hosts.createEnrollment(host.id).token, inventory);
// Finish enrollment's independent availability refresh before counting parses.
await setImmediate();
return { ...hosts, ...context, hostId: host.id, credential };
}
describe('agent preparation admission', () => {
it('skips fleet preparation lookups for empty, health, read and unlimited library polls', async () => {
const api = await enrolled();
const { db } = await import('../../src/lib/server/db/index.js');
const { fleetSettings, saveFleetSettings } =
await import('../../src/lib/server/settings.js');
saveFleetSettings({ ...fleetSettings(), preparationConcurrency: 1 });
const prepare = vi.spyOn(db(), 'prepare');
expect(api.pendingHostOperations(api.hostId)).toEqual([]);
api.queueHostOperation(api.hostId, 'instance.inspect', {});
api.pendingHostOperations(api.hostId, 'health');
api.queueHostOperation(api.hostId, 'files.read', { path: 'server.cfg' });
api.pendingHostOperations(api.hostId, 'read');
saveFleetSettings({ ...fleetSettings(), preparationConcurrency: 0 });
const download = api.queueHostOperation(api.hostId, 'library.download', {});
expect(api.pendingHostOperations(api.hostId, 'write').map((op) => op.id)).toContain(
download.id
);
expect(prepare.mock.calls.some(([sql]) => sql.includes('SELECT DISTINCT host_id'))).toBe(
false
);
});
it('uses an indexed lookup and counts delivered preparation by host until all its jobs finish', async () => {
const api = await enrolled();
const other = api.createHost({ name: 'Other host' });
api.enrollHost(api.createEnrollment(other.id).token, inventory);
const { db } = await import('../../src/lib/server/db/index.js');
const { fleetSettings, saveFleetSettings } =
await import('../../src/lib/server/settings.js');
saveFleetSettings({ ...fleetSettings(), preparationConcurrency: 1 });
const download = api.queueHostOperation(api.hostId, 'library.download', {});
const imported = api.queueHostOperation(api.hostId, 'library.import', {});
const waiting = api.queueHostOperation(other.id, 'library.download', {});
const prepare = vi.spyOn(db(), 'prepare');
expect(api.pendingHostOperations(api.hostId, 'write').map((op) => op.id)).toEqual([
download.id,
imported.id
]);
const sql = prepare.mock.calls.find(([sql]) => sql.includes('SELECT DISTINCT host_id'))![0];
const plan = db().prepare(`EXPLAIN QUERY PLAN ${sql}`).all() as { detail: string }[];
expect(plan.map((row) => row.detail).join('\n')).toContain(
'USING INDEX ec_host_operations_kind_state (kind=? AND state=?)'
);
expect(api.pendingHostOperations(other.id, 'write')).toEqual([]);
api.reportHostOperation(api.hostId, download.id, { state: 'completed' });
expect(api.pendingHostOperations(other.id, 'write')).toEqual([]);
api.reportHostOperation(api.hostId, imported.id, { state: 'failed' });
expect(api.pendingHostOperations(other.id, 'write').map((op) => op.id)).toEqual([
waiting.id
]);
});
});
describe('agent credential usage writes', () => {
it('writes on first use and at most once per minute, including after reopening the database', async () => {
const api = await enrolled();
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().exec(`CREATE TABLE credential_usage_writes (credential_id TEXT, at TEXT);
CREATE TRIGGER count_credential_usage AFTER UPDATE OF last_used_at ON ec_host_credentials
BEGIN INSERT INTO credential_usage_writes VALUES (NEW.id, NEW.last_used_at); END;`);
const writes = () => db().prepare('SELECT at FROM credential_usage_writes').all();
const authenticate = () =>
expect(api.authenticateCredential(api.credential)?.id).toBe(api.hostId);
authenticate();
authenticate();
authenticate();
expect(writes()).toEqual([{ at: new Date(epoch).toISOString() }]);
vi.setSystemTime(epoch + 59_999);
authenticate();
closeDb();
authenticate();
expect(writes()).toHaveLength(1);
vi.setSystemTime(epoch + 60_000);
authenticate();
authenticate();
expect(writes()).toEqual([
{ at: new Date(epoch).toISOString() },
{ at: new Date(epoch + 60_000).toISOString() }
]);
expect(api.authenticateCredential('invalid')).toBeNull();
expect(writes()).toHaveLength(2);
});
it('checks revocation immediately and records first use of a replacement credential', async () => {
const api = await enrolled();
const { db } = await import('../../src/lib/server/db/index.js');
await api.withHostRequestContext(() => {
expect(api.authenticateCredential(api.credential)?.id).toBe(api.hostId);
vi.setSystemTime(epoch + 1000);
api.queueHostOperation(api.hostId, 'credentials.rotate', {});
const replacement = api.rotateCredential(api.hostId);
expect(api.authenticateCredential(api.credential)).toBeNull();
expect(api.authenticateCredential(replacement)?.id).toBe(api.hostId);
expect(
db()
.prepare(
'SELECT last_used_at FROM ec_host_credentials WHERE revoked_at IS NULL'
)
.get()
).toEqual({ last_used_at: new Date(epoch + 1000).toISOString() });
api.revokeCredentials(api.hostId);
expect(api.authenticateCredential(replacement)).toBeNull();
expect(api.getHost(api.hostId)?.credentialActive).toBe(false);
});
});
});
describe('request-scoped host inventory parsing', () => {
it('parses once across authentication, host reads, and operation handling within a request', async () => {
const api = await enrolled();
const serialized = JSON.stringify(inventory);
const parse = vi.spyOn(JSON, 'parse');
await api.withHostRequestContext(async () => {
const authenticated = api.authenticateCredential(api.credential)!;
const operation = api.queueHostOperation(api.hostId, 'instance.inspect', {
name: 'server'
});
api.pendingHostOperationRows(api.hostId, 'read', false);
api.reportHostOperationStatus(api.hostId, operation.id, { state: 'accepted' });
await Promise.resolve();
for (let i = 0; i < 10; i++)
expect(api.getHost(api.hostId)?.inventory).toBe(authenticated.inventory);
expect(api.requireReadyHost(api.hostId).inventory).toBe(authenticated.inventory);
expect(api.listHosts()[0].inventory).toBe(authenticated.inventory);
});
expect(parse.mock.calls.filter(([value]) => value === serialized)).toHaveLength(1);
await api.withHostRequestContext(() => api.getHost(api.hostId));
expect(parse.mock.calls.filter(([value]) => value === serialized)).toHaveLength(2);
});
it('keeps host metadata fresh and reparses changed inventory, including after rollback', async () => {
const api = await enrolled();
const { db } = await import('../../src/lib/server/db/index.js');
await api.withHostRequestContext(() => {
const original = api.getHost(api.hostId)!;
const updated = api.updateHost(api.hostId, { name: 'Renamed host', mode: 'draining' });
expect(updated).toMatchObject({
name: 'Renamed host',
mode: 'draining',
readiness: 'draining'
});
expect(updated.inventory).toBe(original.inventory);
const heartbeat = api.heartbeat(api.hostId, { ...inventory, storageAvailable: 123 });
expect(heartbeat.inventory).not.toBe(original.inventory);
expect(api.getHost(api.hostId)?.inventory).toBe(heartbeat.inventory);
expect(api.getHost(api.hostId)?.inventory.storageAvailable).toBe(123);
expect(() =>
db().transaction(() => {
db()
.prepare('UPDATE ec_hosts SET inventory=? WHERE id=?')
.run(JSON.stringify({ ...inventory, storageAvailable: 456 }), api.hostId);
expect(api.getHost(api.hostId)?.inventory.storageAvailable).toBe(456);
throw new Error('rollback');
})()
).toThrow('rollback');
expect(api.getHost(api.hostId)?.inventory.storageAvailable).toBe(123);
});
});
it('isolates overlapping requests and does not cache reads outside a request', async () => {
const api = await enrolled();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let first!: HostInventory;
const request = api.withHostRequestContext(async () => {
first = api.getHost(api.hostId)!.inventory;
await gate;
expect(api.getHost(api.hostId)!.inventory).toBe(first);
});
try {
await api.withHostRequestContext(async () => {
const second = api.getHost(api.hostId)!.inventory;
expect(second).not.toBe(first);
await Promise.resolve();
expect(api.getHost(api.hostId)!.inventory).toBe(second);
});
expect(api.getHost(api.hostId)!.inventory).not.toBe(api.getHost(api.hostId)!.inventory);
} finally {
release();
await request;
}
});
it.each([false, true])(
'releases cached inventory from inherited work after request completion (failed: %s)',
async (failed) => {
const api = await enrolled();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let detached!: Promise<void>;
let original!: HostInventory;
const request = api.withHostRequestContext(() => {
original = api.getHost(api.hostId)!.inventory;
detached = gate.then(() => {
const later = api.getHost(api.hostId)!.inventory;
expect(later).not.toBe(original);
expect(api.getHost(api.hostId)!.inventory).not.toBe(later);
});
if (failed) throw new Error('request failed');
});
if (failed) await expect(request).rejects.toThrow('request failed');
else await request;
release();
await detached;
}
);
});