feat(web): add overview system module foundation
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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 request', async () => {
|
||||
const fetcher = vi.fn(async () => 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.');
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user