import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { EventEnvelope } from '../../application/events/event-journal.js'; import { buildApp } from '../../app.js'; import { InstanceSessionStore, UpstreamSessionClient, } from '../../application/connections/upstream-session-client.js'; import { InstanceLoginService } from '../../application/connections/instance-login-service.js'; import { ConnectionProbe } from '../../application/connections/connection-probe.js'; import { InstanceService, InstanceServiceError, type InstanceServiceErrorCode, } from '../../application/instances/instance-service.js'; import { DELETE_INSTANCE_PARAMETER_SCHEMA_ID, DeleteInstanceOperation, } from '../../application/operations/delete-instance-operation.js'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import type { SecretStore } from '../../infrastructure/secrets/secret-store.js'; import { registerInstanceRoutes } from './instance-routes.js'; class FakeStore implements SecretStore { readonly provider = 'macos-keychain'; async set(key: { instanceId: string; purpose: string; slot?: string }) { return `keychain://multi-simadmin/${Buffer.from( JSON.stringify([key.instanceId, key.purpose, key.slot]), 'utf8', ).toString('base64url')}`; } async get() { return undefined; } async delete() { return false; } } const dbs: Database.Database[] = []; afterEach(() => { for (const db of dbs.splice(0)) db.close(); }); const fixture = () => { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); migrateDatabase(db); dbs.push(db); let id = 0; const instances = new InstanceService({ db, store: new FakeStore(), idFactory: () => `id-${++id}`, now: () => new Date('2026-07-16T12:00:00.000Z'), }); const connections = new ConnectionProbe({ db, instances, transport: { get: async () => ({ status: 401, headers: {}, body: '' }) }, now: () => new Date('2026-07-16T12:00:00.000Z'), }); const sessionClient = new UpstreamSessionClient({ sessions: new InstanceSessionStore(), request: async () => ({ status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '', }), }); const login = new InstanceLoginService({ db, client: sessionClient, resolver: { resolve: async () => '[REDACTED]' }, now: () => new Date('2026-07-16T12:00:00.000Z'), }); const deletion = new DeleteInstanceOperation({ db, instances, now: () => new Date('2026-07-16T12:00:00.000Z'), idFactory: () => `operation-${++id}`, tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'), }); const events: Array> = []; const onEvent = (envelope: EventEnvelope): void => { events.push(envelope as unknown as Record); }; const app = buildApp({ registerRoutes: (scope) => registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }), }); return { app, instances, events }; }; describe('instance route SSE events', () => { it('emits an instance envelope when a node is created', async () => { const { app, events } = fixture(); const created = await app.inject({ method: 'POST', url: '/api/v1/instances', payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080', password: { action: 'set', password: 'never-return-this' }, }, }); expect(created.statusCode).toBe(201); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) }); expect(String(events[0]?.instanceId)).toBeTruthy(); }); }); describe('instance HTTP routes', () => { it('creates, lists, gets, updates and deletes redacted instance resources', async () => { const { app } = fixture(); const create = await app.inject({ method: 'POST', url: '/api/v1/instances', payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080', tags: ['lab'], password: { action: 'set', password: 'never-return-this' }, }, }); expect(create.statusCode).toBe(201); expect(create.headers.etag).toBe('"rev-1"'); expect(JSON.stringify(create.json())).not.toContain('never-return-this'); expect(create.json()).toMatchObject({ id: 'id-1', revision: 1, credentialConfigured: true }); const list = await app.inject({ method: 'GET', url: '/api/v1/instances?page=1&pageSize=20' }); expect(list.statusCode).toBe(200); expect(list.json()).toMatchObject({ items: [expect.objectContaining({ id: 'id-1' })] }); const get = await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' }); expect(get.statusCode).toBe(200); expect(get.headers.etag).toBe('"rev-1"'); const session = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/test-connection', }); expect(session.statusCode).toBe(200); expect(session.json()).toEqual({ instanceId: 'id-1', authenticated: false, checkedAt: '2026-07-16T12:00:00.000Z', }); const login = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/login', payload: { password: 'one-shot-secret' }, }); expect(login.statusCode).toBe(200); expect(login.json()).toEqual({ instanceId: 'id-1', authenticated: true, checkedAt: '2026-07-16T12:00:00.000Z', }); expect(login.body).not.toContain('one-shot-secret'); const logout = await app.inject({ method: 'POST', url: '/api/v1/instances/id-1/logout' }); expect(logout.statusCode).toBe(200); expect(logout.json()).toMatchObject({ instanceId: 'id-1', authenticated: false }); const stale = await app.inject({ method: 'PATCH', url: '/api/v1/instances/id-1', headers: { 'if-match': '"rev-2"' }, payload: { name: 'Beta' }, }); expect(stale.statusCode).toBe(412); expect(stale.json()).toMatchObject({ code: 'REVISION_CONFLICT' }); const update = await app.inject({ method: 'PATCH', url: '/api/v1/instances/id-1', headers: { 'if-match': '"rev-1"' }, payload: { name: 'Beta', password: { action: 'preserve' } }, }); expect(update.statusCode).toBe(200); expect(update.headers.etag).toBe('"rev-2"'); expect(update.json()).toMatchObject({ name: 'Beta', revision: 2 }); const directRemove = await app.inject({ method: 'DELETE', url: '/api/v1/instances/id-1', headers: { 'if-match': '"rev-2"' }, }); expect(directRemove.statusCode).toBe(400); expect(directRemove.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); const preparation = await app.inject({ method: 'POST', url: '/api/v1/operations/prepare', payload: { operationId: 'deleteInstance', targets: [{ instanceId: 'id-1', revision: 2 }], parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }, }, }); expect(preparation.statusCode).toBe(200); const prepared = preparation.json() as { id: string; confirmationToken: string }; const remove = await app.inject({ method: 'DELETE', url: '/api/v1/instances/id-1', headers: { 'if-match': '"rev-2"', 'x-preparation-id': prepared.id, 'x-confirmation-token': prepared.confirmationToken, }, }); expect(remove.statusCode).toBe(202); expect(remove.json()).toMatchObject({ operationId: 'deleteInstance', status: 'succeeded' }); const replay = await app.inject({ method: 'DELETE', url: '/api/v1/instances/id-1', headers: { 'if-match': '"rev-2"', 'x-preparation-id': prepared.id, 'x-confirmation-token': prepared.confirmationToken, }, }); expect(replay.statusCode).toBe(400); expect(replay.json()).toMatchObject({ code: 'CONFIRMATION_INVALID' }); expect((await app.inject({ method: 'GET', url: '/api/v1/instances/id-1' })).statusCode).toBe( 404, ); await app.close(); }); it('rejects invalid input and maps domain errors to stable RFC problem details', async () => { const { app } = fixture(); const invalid = await app.inject({ method: 'POST', url: '/api/v1/instances', payload: { name: '' }, }); expect(invalid.statusCode).toBe(400); expect(invalid.headers['content-type']).toContain('application/problem+json'); expect(invalid.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); const missing = await app.inject({ method: 'GET', url: '/api/v1/instances/nope' }); expect(missing.statusCode).toBe(404); expect(missing.json()).toMatchObject({ code: 'NOT_FOUND' }); const nullCreate = await app.inject({ method: 'POST', url: '/api/v1/instances', headers: { 'content-type': 'application/json' }, payload: 'null', }); expect(nullCreate.statusCode).toBe(400); expect(nullCreate.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); const arrayPatch = await app.inject({ method: 'PATCH', url: '/api/v1/instances/nope', headers: { 'if-match': '"rev-1"' }, payload: [], }); expect(arrayPatch.statusCode).toBe(400); expect(arrayPatch.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); const strictCreateCases = [ { name: 'Alpha', origin: 'http://192.168.1.10', unexpected: true }, { name: 'Alpha', origin: 'http://192.168.1.10', tags: 'lab' }, { name: 'Alpha', origin: 'http://192.168.1.10', password: { action: 'set' } }, ]; for (const payload of strictCreateCases) { const response = await app.inject({ method: 'POST', url: '/api/v1/instances', payload }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); } const created = await app.inject({ method: 'POST', url: '/api/v1/instances', payload: { name: 'Strict', origin: 'http://192.168.1.11' }, }); expect(created.statusCode).toBe(201); const strictId = (created.json() as { id: string }).id; for (const payload of [{}, { name: 123 }, { unexpected: true }]) { const response = await app.inject({ method: 'PATCH', url: `/api/v1/instances/${strictId}`, headers: { 'if-match': '"rev-1"' }, payload, }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); } const unchanged = await app.inject({ method: 'GET', url: `/api/v1/instances/${strictId}` }); expect(unchanged.json()).toMatchObject({ name: 'Strict', revision: 1 }); for (const payload of [ { password: { not: 'a string' } }, { password: '' }, { unexpected: true }, ]) { const response = await app.inject({ method: 'POST', url: `/api/v1/instances/${strictId}/login`, payload, }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); } const loginMissing = await app.inject({ method: 'POST', url: '/api/v1/instances/nope/login', payload: {}, }); expect(loginMissing.statusCode).toBe(404); expect(loginMissing.json()).toMatchObject({ code: 'NOT_FOUND' }); await app.close(); }); it('requires an exact strong revision ETag in If-Match', async () => { const { app } = fixture(); const created = await app.inject({ method: 'POST', url: '/api/v1/instances', payload: { name: 'Strict ETag', origin: 'http://192.168.1.12' }, }); const id = (created.json() as { id: string }).id; for (const value of ['1', 'rev-1', 'W/"rev-1"', '"1"', '"rev-01"', '"rev-1", "rev-2"']) { const response = await app.inject({ method: 'PATCH', url: `/api/v1/instances/${id}`, headers: { 'if-match': value }, payload: { name: 'Rejected' }, }); expect(response.statusCode, value).toBe(400); expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); } await app.close(); }); it('parses every frozen list filter and defaults pageSize to 25', async () => { const { app, instances } = fixture(); const list = vi.spyOn(instances, 'list'); const response = await app.inject({ method: 'GET', url: '/api/v1/instances?page=2&pageSize=30&sort=status&direction=desc&search=alpha&capabilityStatus=degraded&freshness=stale&tag=lab&credentialConfigured=false', }); expect(response.statusCode).toBe(200); expect(list).toHaveBeenCalledWith({ page: 2, pageSize: 30, sort: 'status', direction: 'desc', search: 'alpha', capabilityStatus: 'degraded', freshness: 'stale', tag: 'lab', credentialConfigured: false, }); const defaults = await app.inject({ method: 'GET', url: '/api/v1/instances' }); expect(defaults.json()).toMatchObject({ page: { page: 1, pageSize: 25, total: 0 } }); await app.close(); }); it('rejects list query values outside the frozen OpenAPI constraints', async () => { const { app } = fixture(); const invalidQueries = [ 'page=0', 'page=1.5', 'pageSize=0', 'pageSize=101', 'pageSize=abc', 'sort=id', 'direction=sideways', `search=${'x'.repeat(201)}`, 'capabilityStatus=online', 'freshness=old', 'credentialConfigured=1', 'credentialConfigured=TRUE', ]; for (const query of invalidQueries) { const response = await app.inject({ method: 'GET', url: `/api/v1/instances?${query}` }); expect(response.statusCode, query).toBe(400); expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED' }); } await app.close(); }); it('exhaustively maps and redacts instance service errors', async () => { const expected: Record = { VALIDATION_FAILED: 400, NOT_FOUND: 404, REVISION_CONFLICT: 412, HAS_JOB_HISTORY: 409, DUPLICATE_ID: 409, DUPLICATE_ORIGIN: 409, SECRET_STORE_FAILED: 500, DATABASE_FAILED: 500, SECRET_CLEANUP_FAILED: 500, COMPENSATION_FAILED: 500, COMPENSATION_PERSISTENCE_FAILED: 500, }; for (const [code, status] of Object.entries(expected) as [InstanceServiceErrorCode, number][]) { const { app, instances } = fixture(); vi.spyOn(instances, 'list').mockRejectedValue( new InstanceServiceError(code, 'sensitive internal failure detail'), ); const response = await app.inject({ method: 'GET', url: '/api/v1/instances' }); expect(response.statusCode, code).toBe(status); expect(response.headers['content-type']).toContain('application/problem+json'); expect(response.json()).toMatchObject({ code, status }); expect(response.body).not.toContain('sensitive internal failure detail'); await app.close(); } }); });