import { randomBytes } from 'node:crypto'; 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 () => { await fixture.cleanup(); }); const newCredential = () => 'ec_host_' + randomBytes(32).toString('base64url'); async function setup() { const hosts = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { POST } = await import('../../src/routes/api/agent/credentials/+server.js'); const host = hosts.createHost({ name: 'Game host' }); const { credential } = hosts.enrollHost(hosts.createEnrollment(host.id).token, {}); const rotate = (next: string, token = credential) => POST({ request: new Request('https://ec.test/api/agent/credentials', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ credential: next }) }) }); return { ...hosts, db, host, credential, rotate }; } describe('administrator-authorized agent credential rotation', () => { it.each(['absent', 'other host', 'other kind', 'completed', 'failed'])( 'rejects rotation when the authorization is %s without changing credentials', async (authorization) => { const api = await setup(); if (authorization !== 'absent') { const hostId = authorization === 'other host' ? api.createHost({ name: 'Other host' }).id : api.host.id; const operation = api.queueHostOperation( hostId, authorization === 'other kind' ? 'host.inspect' : 'credentials.rotate', {} ); if (authorization === 'completed' || authorization === 'failed') api.reportHostOperationStatus(hostId, operation.id, { state: authorization }); } const next = newCredential(); const response = await api.rotate(next); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'Credential rotation requires an open administrator request.' }); expect(api.authenticateCredential(api.credential)?.id).toBe(api.host.id); expect(api.authenticateCredential(next)).toBeNull(); } ); it.each(['pending', 'accepted', 'running', 'unknown'])( 'consumes a %s rotation request and records completion before the agent receipt', async (state) => { const api = await setup(); const { POST } = await import('../../src/routes/api/hosts/[id]/credentials/+server.js'); const queued = POST({ request: new Request(`https://ec.test/api/hosts/${api.host.id}/credentials`, { method: 'POST' }), params: { id: api.host.id } }); expect(queued.status).toBe(202); const { operationId } = await queued.json(); if (state !== 'pending') api.reportHostOperationStatus(api.host.id, operationId, { state }); const next = newCredential(); expect((await api.rotate(next)).status).toBe(200); expect(api.authenticateCredential(api.credential)).toBeNull(); expect(api.authenticateCredential(next)?.id).toBe(api.host.id); expect(api.getHostOperationStatus(operationId)).toMatchObject({ state: 'completed', result: { rotated: true } }); // A new token cannot reuse the consumed authorization. expect((await api.rotate(newCredential(), next)).status).toBe(400); // Recovery after a lost response only acknowledges the active token. expect((await api.rotate(next, next)).status).toBe(200); api.reportHostOperationStatus(api.host.id, operationId, { state: 'completed', result: { rotated: true } }); expect( api .db() .prepare( "SELECT state FROM ec_host_operation_events WHERE operation_id=? AND state='completed'" ) .all(operationId) ).toEqual([{ state: 'completed' }]); const stored = JSON.stringify( api.db().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(operationId) ); expect(stored).not.toContain(next); } ); it('does not consume a new request when acknowledging the active credential', async () => { const api = await setup(); const first = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); const second = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); const next = newCredential(); expect((await api.rotate(next)).status).toBe(200); expect(api.getHostOperationStatus(first.id)?.state).toBe('completed'); expect(api.getHostOperationStatus(second.id)?.state).toBe('pending'); expect((await api.rotate(next, next)).status).toBe(200); expect(api.getHostOperationStatus(second.id)?.state).toBe('pending'); expect((await api.rotate(newCredential(), next)).status).toBe(200); expect(api.getHostOperationStatus(second.id)?.state).toBe('completed'); }); it('rolls back replacement and revocation if operation completion fails', async () => { const api = await setup(); const operation = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); api.db().exec(`CREATE TRIGGER reject_rotation_completion BEFORE UPDATE OF state ON ec_host_operations WHEN NEW.kind='credentials.rotate' AND NEW.state='completed' BEGIN SELECT RAISE(ABORT, 'completion failed'); END`); const next = newCredential(); expect((await api.rotate(next)).status).toBe(400); expect(api.authenticateCredential(api.credential)?.id).toBe(api.host.id); expect(api.authenticateCredential(next)).toBeNull(); expect(api.getHostOperationStatus(operation.id)?.state).toBe('pending'); }); });