267 lines
9.3 KiB
TypeScript
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');
|
|
});
|
|
});
|