import { describe, expect, it, vi } from 'vitest'; import { OperationClientError, createOperationClient } from './operation-client.js'; const response = (body: unknown, init: ResponseInit = {}) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json', ...init.headers }, ...init, }); const entry = { operationId: 'safeOp', title: 'Safe operation', risk: 'R1', capability: 'command', batchable: false, parameterSchemaId: 'safe.params.v1', }; const page = { items: [entry], page: { page: 1, pageSize: 25, total: 1 } }; const preparation = { id: 'prep-1', status: 'prepared', operationId: 'safeOp', risk: 'R1', expiresAt: '2030-01-01T00:00:00.000Z', confirmationToken: 'opaque-secret', confirmationPrompt: 'Confirm safe operation', targetCount: 1, }; const job = { id: 'job-1', operationId: 'safeOp', status: 'succeeded', rootJobId: 'job-1', items: [{ id: 'item-1', targetId: 'instance-1', state: 'succeeded' }], attempts: [ { id: 'attempt-1', state: 'succeeded', startedAt: '2029-01-01T00:00:00.000Z', finishedAt: '2029-01-01T00:00:01.000Z', }, ], createdAt: '2029-01-01T00:00:00.000Z', }; describe('safe operation client', () => { it('loads a strictly validated catalog using same-origin credentials', async () => { const fetcher = vi.fn(async () => response(page)); const client = createOperationClient(fetcher as typeof fetch); await expect(client.list({ risk: 'R1', search: 'safe value' })).resolves.toEqual(page); expect(fetcher).toHaveBeenCalledWith('/api/v1/operations?risk=R1&search=safe+value', { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, }); }); it.each([ { ...page, leaked: true }, { ...page, items: [{ ...entry, method: 'POST' }] }, { ...page, items: [{ ...entry, risk: 'R9' }] }, { ...page, page: { ...page.page, total: -1 } }, ])('rejects malformed or expanded catalog envelopes', async (body) => { const client = createOperationClient(vi.fn(async () => response(body)) as typeof fetch); await expect(client.list()).rejects.toThrow('Operation catalog response is invalid.'); }); it('only prepares an operation from the fetched catalog and retains its token privately', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(response(page)) .mockResolvedValueOnce(response(preparation)); const client = createOperationClient(fetcher as typeof fetch); await client.list(); const result = await client.prepare({ operationId: 'safeOp', targets: [{ instanceId: 'instance-1', revision: 2 }], parameters: { parameterSchemaId: 'safe.params.v1', fields: [] }, }); expect(result).toEqual({ id: 'prep-1', status: 'prepared', operationId: 'safeOp', risk: 'R1', expiresAt: preparation.expiresAt, confirmationPrompt: 'Confirm safe operation', targetCount: 1, }); expect(JSON.stringify(result)).not.toContain('opaque-secret'); expect(fetcher.mock.calls[1]).toEqual([ '/api/v1/operations/prepare', expect.objectContaining({ method: 'POST', credentials: 'same-origin', body: JSON.stringify({ operationId: 'safeOp', targets: [{ instanceId: 'instance-1', revision: 2 }], parameters: { parameterSchemaId: 'safe.params.v1', fields: [] }, }), }), ]); }); it('refuses uncatalogued operations and mismatched schemas without a prepare request', async () => { const fetcher = vi.fn(async (url: string) => { if (String(url).includes('search=hiddenOp')) { return response({ items: [], page: { page: 1, pageSize: 100, total: 0 } }); } return response(page); }); const client = createOperationClient(fetcher as typeof fetch); await client.list(); await expect( client.prepare({ operationId: 'hiddenOp', targets: [{ instanceId: 'i' }], parameters: { parameterSchemaId: 'hidden.v1', fields: [] }, }), ).rejects.toThrow('Operation is not present in the loaded catalog.'); await expect( client.prepare({ operationId: 'safeOp', targets: [{ instanceId: 'i' }], parameters: { parameterSchemaId: 'wrong', fields: [] }, }), ).rejects.toThrow('Operation parameter schema does not match the catalog.'); // list + one search hydrate for the missing op; no prepare POST. expect(fetcher.mock.calls.every((call) => !String(call[0]).includes('/prepare'))).toBe(true); }); it('hydrates late-sorted restart ops when the first pageSize=100 page omits them', async () => { const restartEntry = { operationId: 'postServiceRestart', title: 'Restart Service', risk: 'R3', capability: 'job', batchable: false, parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', }; const firstPage = { items: [entry], page: { page: 1, pageSize: 100, total: 117 }, }; const searchPage = { items: [restartEntry], page: { page: 1, pageSize: 100, total: 1 }, }; const restartPreparation = { ...preparation, operationId: 'postServiceRestart', risk: 'R3', confirmationPrompt: 'Confirm restart service', }; const fetcher = vi .fn() .mockResolvedValueOnce(response(firstPage)) .mockResolvedValueOnce(response(searchPage)) .mockResolvedValueOnce(response(restartPreparation)); const client = createOperationClient(fetcher as typeof fetch); await client.list({ pageSize: 100 }); const result = await client.prepare({ operationId: 'postServiceRestart', targets: [{ instanceId: 'instance-1', revision: 1 }], parameters: { parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1', fields: [], }, }); expect(result.operationId).toBe('postServiceRestart'); expect(String(fetcher.mock.calls[1]?.[0])).toContain('search=postServiceRestart'); expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/operations/prepare'); }); it('executes with the in-memory token exactly once and validates public Job fields', async () => { const fetcher = vi .fn() .mockResolvedValueOnce(response(page)) .mockResolvedValueOnce(response(preparation)) .mockResolvedValueOnce(response(job, { status: 202 })); const client = createOperationClient(fetcher as typeof fetch); await client.list(); await client.prepare({ operationId: 'safeOp', targets: [{ instanceId: 'instance-1' }], parameters: { parameterSchemaId: 'safe.params.v1', fields: [] }, }); await expect(client.execute('prep-1')).resolves.toEqual(job); expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual({ preparationId: 'prep-1', confirmationToken: 'opaque-secret', }); await expect(client.execute('prep-1')).rejects.toThrow( 'No in-memory confirmation is available for this preparation.', ); expect(fetcher).toHaveBeenCalledTimes(3); }); it('rejects expanded preparation and Job payloads', async () => { const badPreparationClient = createOperationClient( vi .fn() .mockResolvedValueOnce(response(page)) .mockResolvedValueOnce( response({ ...preparation, transportPath: '/private' }), ) as typeof fetch, ); await badPreparationClient.list(); await expect( badPreparationClient.prepare({ operationId: 'safeOp', targets: [{ instanceId: 'i' }], parameters: { parameterSchemaId: 'safe.params.v1', fields: [] }, }), ).rejects.toThrow('Operation preparation response is invalid.'); const badJobClient = createOperationClient( vi .fn() .mockResolvedValueOnce(response(page)) .mockResolvedValueOnce(response(preparation)) .mockResolvedValueOnce( response({ ...job, upstreamResponse: 'secret' }, { status: 202 }), ) as typeof fetch, ); await badJobClient.list(); await badJobClient.prepare({ operationId: 'safeOp', targets: [{ instanceId: 'i' }], parameters: { parameterSchemaId: 'safe.params.v1', fields: [] }, }); await expect(badJobClient.execute('prep-1')).rejects.toThrow( 'Operation execution response is invalid.', ); }); it('redacts Problem Details detail, validation messages, and unknown response fields', async () => { const problem = { type: 'https://example.invalid/problem', title: 'Bad Request', status: 400, detail: 'token=server-secret', code: 'VALIDATION_FAILED', requestId: 'request-1', validation: [{ field: 'parameters', code: 'INVALID', message: 'secret field value' }], debug: 'private stack', }; const client = createOperationClient( vi.fn(async () => response(problem, { status: 400, headers: { 'content-type': 'application/problem+json' } }), ) as typeof fetch, ); const error = await client.list().catch((caught: unknown) => caught); expect(error).toBeInstanceOf(OperationClientError); expect(error).toMatchObject({ status: 400, code: 'VALIDATION_FAILED', requestId: 'request-1' }); expect(JSON.stringify(error)).not.toContain('server-secret'); expect(JSON.stringify(error)).not.toContain('secret field value'); expect(JSON.stringify(error)).not.toContain('private stack'); }); });