Files
multi-simadmin/apps/web/src/operations/operation-client.test.ts
T

267 lines
9.3 KiB
TypeScript

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');
});
});