feat(web): connect jobs and audit workspaces
This commit is contained in:
@@ -4,11 +4,15 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { AppShell, type InstanceContext } from './app-shell.js';
|
import { AppShell, type InstanceContext } from './app-shell.js';
|
||||||
|
import type { AuditDataSource } from './audit/audit-page.js';
|
||||||
|
import type { EventStreamClient } from './events/event-stream-client.js';
|
||||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
|
import type { JobsDataSource } from './jobs/jobs-page.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot: FleetSnapshot = {
|
const snapshot: FleetSnapshot = {
|
||||||
@@ -52,6 +56,12 @@ function source(load: FleetDataSource['load']): FleetDataSource {
|
|||||||
return { load };
|
return { load };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quietEventStreamClient: EventStreamClient = {
|
||||||
|
subscribe: () => () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } };
|
||||||
|
|
||||||
describe('React AppShell and Fleet vertical slice', () => {
|
describe('React AppShell and Fleet vertical slice', () => {
|
||||||
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
|
it('loads real injected data and renders canonical origins and owner routes without React key warnings', async () => {
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
@@ -197,4 +207,76 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||||
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
|
expect(screen.getByText(/Instance context is unavailable/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
pathname: '/jobs',
|
||||||
|
endpoint: '/api/v1/jobs?page=1&pageSize=25&sort=createdAt&direction=desc',
|
||||||
|
emptyMessage: /No jobs match the current query/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pathname: '/audit',
|
||||||
|
endpoint: '/api/v1/audit?sort=occurredAt&direction=desc&page=1&pageSize=25',
|
||||||
|
emptyMessage: /No audit events match the current query/i,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'uses the default HTTP data source for $pathname',
|
||||||
|
async ({ pathname, endpoint, emptyMessage }) => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(emptyPage), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetcher);
|
||||||
|
|
||||||
|
render(<AppShell pathname={pathname} eventStreamClient={quietEventStreamClient} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(emptyMessage)).toBeTruthy();
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
pathname: '/jobs',
|
||||||
|
sourceProp: 'jobsDataSource' as const,
|
||||||
|
emptyMessage: /No jobs match the current query/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pathname: '/audit',
|
||||||
|
sourceProp: 'auditDataSource' as const,
|
||||||
|
emptyMessage: /No audit events match the current query/i,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'uses an explicitly injected data source instead of global fetch for $pathname',
|
||||||
|
async ({ pathname, sourceProp, emptyMessage }) => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>();
|
||||||
|
vi.stubGlobal('fetch', fetcher);
|
||||||
|
const load = vi
|
||||||
|
.fn<JobsDataSource['load'] | AuditDataSource['load']>()
|
||||||
|
.mockResolvedValue(emptyPage);
|
||||||
|
const injectedSource = { load };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
pathname={pathname}
|
||||||
|
eventStreamClient={quietEventStreamClient}
|
||||||
|
{...(sourceProp === 'jobsDataSource'
|
||||||
|
? { jobsDataSource: injectedSource as JobsDataSource }
|
||||||
|
: { auditDataSource: injectedSource as AuditDataSource })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText(emptyMessage)).toBeTruthy();
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
||||||
|
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import { EsimModule, type EsimDataSource } from './instances/esim-module.js';
|
|||||||
import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
||||||
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
||||||
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
|
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
|
||||||
|
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
|
||||||
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
|
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
|
||||||
import {
|
import {
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
@@ -408,6 +410,14 @@ export function AppShell({
|
|||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||||
|
const resolvedJobsDataSource = useMemo(
|
||||||
|
() => jobsDataSource ?? createJobsApiDataSource(),
|
||||||
|
[jobsDataSource],
|
||||||
|
);
|
||||||
|
const resolvedAuditDataSource = useMemo(
|
||||||
|
() => auditDataSource ?? createAuditApiDataSource(),
|
||||||
|
[auditDataSource],
|
||||||
|
);
|
||||||
const resolved = resolveRoute(pathname);
|
const resolved = resolveRoute(pathname);
|
||||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
||||||
const routeInstanceId = route.params?.instanceId;
|
const routeInstanceId = route.params?.instanceId;
|
||||||
@@ -465,8 +475,8 @@ export function AppShell({
|
|||||||
notificationsDataSource={notificationsDataSource}
|
notificationsDataSource={notificationsDataSource}
|
||||||
automationDataSource={automationDataSource}
|
automationDataSource={automationDataSource}
|
||||||
otaDataSource={otaDataSource}
|
otaDataSource={otaDataSource}
|
||||||
jobsDataSource={jobsDataSource}
|
jobsDataSource={resolvedJobsDataSource}
|
||||||
auditDataSource={auditDataSource}
|
auditDataSource={resolvedAuditDataSource}
|
||||||
fleetRefreshSignal={refresh.fleet}
|
fleetRefreshSignal={refresh.fleet}
|
||||||
detailRefreshSignal={refresh.detail}
|
detailRefreshSignal={refresh.detail}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { AuditPageQuery } from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
import { AuditApiError, createAuditApiDataSource } from './audit-api-data-source.js';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
id: 'evt-1',
|
||||||
|
occurredAt: '2026-07-17T10:00:00.000Z',
|
||||||
|
actorId: 'operator@example.test',
|
||||||
|
action: 'message.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
requestId: 'req-7',
|
||||||
|
parameterSummary: [{ fieldId: 'destination', displayValue: 'upstream-secret', redacted: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('audit API data source', () => {
|
||||||
|
it('GETs the exact endpoint with frozen URLSearchParams keys and request controls', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(json({ items: [event], page: { page: 2, pageSize: 25, total: 26 } }));
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
const query: AuditPageQuery = {
|
||||||
|
actorId: 'alice+ops@example.test',
|
||||||
|
instanceId: 'instance / one',
|
||||||
|
jobId: 'job-1',
|
||||||
|
operationId: 'message.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
occurredFrom: '2026-07-01T10:00:00.000Z',
|
||||||
|
occurredTo: '2026-07-17T10:00:00.000Z',
|
||||||
|
requestId: 'req & one',
|
||||||
|
sort: 'occurredAt',
|
||||||
|
direction: 'desc',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createAuditApiDataSource(fetcher).load(query, signal);
|
||||||
|
|
||||||
|
const [url, init] = fetcher.mock.calls[0] ?? [];
|
||||||
|
expect(String(url).split('?')[0]).toBe('/api/v1/audit');
|
||||||
|
expect(Object.fromEntries(new URLSearchParams(String(url).split('?')[1]))).toEqual({
|
||||||
|
actorId: 'alice+ops@example.test',
|
||||||
|
instanceId: 'instance / one',
|
||||||
|
jobId: 'job-1',
|
||||||
|
operationId: 'message.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
occurredFrom: '2026-07-01T10:00:00.000Z',
|
||||||
|
occurredTo: '2026-07-17T10:00:00.000Z',
|
||||||
|
requestId: 'req & one',
|
||||||
|
sort: 'occurredAt',
|
||||||
|
direction: 'desc',
|
||||||
|
page: '2',
|
||||||
|
pageSize: '25',
|
||||||
|
});
|
||||||
|
expect(init).toEqual({
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown query keys instead of forwarding them', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>();
|
||||||
|
const query = { page: 1, secret: 'must-not-leak' } as AuditPageQuery;
|
||||||
|
await expect(
|
||||||
|
createAuditApiDataSource(fetcher).load(query, new AbortController().signal),
|
||||||
|
).rejects.toThrow('Audit query is invalid.');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects query identifiers over 200 characters', async () => {
|
||||||
|
for (const key of ['actorId', 'instanceId', 'jobId', 'operationId', 'requestId'] as const) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>();
|
||||||
|
await expect(
|
||||||
|
createAuditApiDataSource(fetcher).load(
|
||||||
|
{ [key]: 'x'.repeat(201) },
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Audit query is invalid.');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires canonical ISO date-times and an ordered occurred range', async () => {
|
||||||
|
const invalidQueries: AuditPageQuery[] = [
|
||||||
|
{ occurredFrom: '2026-07-17T12:00:00+02:00' },
|
||||||
|
{ occurredTo: 'July 17, 2026 10:00:00 UTC' },
|
||||||
|
{
|
||||||
|
occurredFrom: '2026-07-17T10:00:00.001Z',
|
||||||
|
occurredTo: '2026-07-17T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const query of invalidQueries) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>();
|
||||||
|
await expect(
|
||||||
|
createAuditApiDataSource(fetcher).load(query, new AbortController().signal),
|
||||||
|
).rejects.toThrow('Audit query is invalid.');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly validates page, event, summary keys and bounds', async () => {
|
||||||
|
const invalidBodies = [
|
||||||
|
{ items: [event], page: { page: 1, pageSize: 25, total: 1 }, extra: true },
|
||||||
|
{ items: [{ ...event, extra: true }], page: { page: 1, pageSize: 25, total: 1 } },
|
||||||
|
{
|
||||||
|
items: [{ ...event, parameterSummary: [{ ...event.parameterSummary[0], extra: true }] }],
|
||||||
|
page: { page: 1, pageSize: 25, total: 1 },
|
||||||
|
},
|
||||||
|
{ items: [event], page: { page: 1, pageSize: 101, total: 1 } },
|
||||||
|
{
|
||||||
|
items: Array.from({ length: 101 }, (_, id) => ({ ...event, id: `evt-${id}` })),
|
||||||
|
page: { page: 1, pageSize: 100, total: 101 },
|
||||||
|
},
|
||||||
|
{ items: [{ ...event, id: 'x'.repeat(257) }], page: { page: 1, pageSize: 25, total: 1 } },
|
||||||
|
{ items: [event], page: { page: 1, pageSize: 25, total: 1, extra: true } },
|
||||||
|
{ items: [event, { ...event, id: 'evt-2' }], page: { page: 1, pageSize: 1, total: 2 } },
|
||||||
|
{ items: [event], page: { page: 1, pageSize: 25, total: 0 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const body of invalidBodies) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(body));
|
||||||
|
await expect(
|
||||||
|
createAuditApiDataSource(fetcher).load({}, new AbortController().signal),
|
||||||
|
).rejects.toThrow('Audit response is invalid.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires explicit redaction, replaces display values, and canonicalizes timestamps', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
items: [{ ...event, occurredAt: '2026-07-17T12:00:00+02:00' }],
|
||||||
|
page: { page: 1, pageSize: 25, total: 1 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
parameterSummary: [{ fieldId: 'token', displayValue: 'secret', redacted: false }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: { page: 1, pageSize: 25, total: 1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const source = createAuditApiDataSource(fetcher);
|
||||||
|
|
||||||
|
await expect(source.load({}, new AbortController().signal)).resolves.toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
occurredAt: '2026-07-17T10:00:00.000Z',
|
||||||
|
parameterSummary: [
|
||||||
|
{ fieldId: 'destination', displayValue: '[REDACTED]', redacted: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: { page: 1, pageSize: 25, total: 1 },
|
||||||
|
});
|
||||||
|
await expect(source.load({}, new AbortController().signal)).rejects.toThrow(
|
||||||
|
'Audit response is invalid.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects valid HTTP Problems to status, code, and requestId only', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
type: 'private-type',
|
||||||
|
title: 'Private title',
|
||||||
|
status: 403,
|
||||||
|
detail: 'token=secret',
|
||||||
|
code: 'AUDIT_DENIED',
|
||||||
|
requestId: 'req-safe',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await createAuditApiDataSource(fetcher)
|
||||||
|
.load({}, new AbortController().signal)
|
||||||
|
.catch((value: unknown) => value as AuditApiError);
|
||||||
|
expect(error).toBeInstanceOf(AuditApiError);
|
||||||
|
expect(error).toMatchObject({ status: 403, code: 'AUDIT_DENIED', requestId: 'req-safe' });
|
||||||
|
expect(Object.keys(error).sort()).toEqual(['code', 'name', 'requestId', 'status']);
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'Audit request failed (403; code=AUDIT_DENIED; requestId=req-safe).',
|
||||||
|
);
|
||||||
|
expect(error.message).not.toMatch(/private|token|secret/i);
|
||||||
|
expect('detail' in error).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses fixed safe values for malformed or status-mismatched Problems', async () => {
|
||||||
|
const validProblem = {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Denied',
|
||||||
|
status: 403,
|
||||||
|
detail: 'private',
|
||||||
|
code: 'AUDIT_DENIED',
|
||||||
|
requestId: 'req-safe',
|
||||||
|
};
|
||||||
|
const responses = [
|
||||||
|
json({ ...validProblem, extra: 'not allowed' }, 403),
|
||||||
|
json({ ...validProblem, detail: '' }, 403),
|
||||||
|
json({ ...validProblem, status: 401 }, 403),
|
||||||
|
new Response('{not-json', { status: 403 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response);
|
||||||
|
const error = await createAuditApiDataSource(fetcher)
|
||||||
|
.load({}, new AbortController().signal)
|
||||||
|
.catch((value: unknown) => value as AuditApiError);
|
||||||
|
expect(error).toMatchObject({ status: 403, code: 'UNKNOWN', requestId: 'unavailable' });
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'Audit request failed (403; code=UNKNOWN; requestId=unavailable).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import {
|
||||||
|
AUDIT_OUTCOMES,
|
||||||
|
MAX_PAGE_SIZE,
|
||||||
|
SORT_DIRECTIONS,
|
||||||
|
type AuditEvent,
|
||||||
|
type AuditOutcome,
|
||||||
|
type AuditPage,
|
||||||
|
type AuditPageQuery,
|
||||||
|
type ProblemDetails,
|
||||||
|
type ValidationIssue,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
import type { AuditDataSource } from './audit-page.js';
|
||||||
|
|
||||||
|
const ENDPOINT = '/api/v1/audit';
|
||||||
|
const MAX_EVENTS = 100;
|
||||||
|
const MAX_PARAMETERS = 50;
|
||||||
|
const MAX_STRING = 256;
|
||||||
|
const MAX_TIMESTAMP = 64;
|
||||||
|
const MAX_QUERY_IDENTIFIER = 200;
|
||||||
|
const QUERY_KEYS = [
|
||||||
|
'actorId',
|
||||||
|
'instanceId',
|
||||||
|
'jobId',
|
||||||
|
'operationId',
|
||||||
|
'outcome',
|
||||||
|
'occurredFrom',
|
||||||
|
'occurredTo',
|
||||||
|
'requestId',
|
||||||
|
'sort',
|
||||||
|
'direction',
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
] as const;
|
||||||
|
const EVENT_KEYS = [
|
||||||
|
'id',
|
||||||
|
'occurredAt',
|
||||||
|
'actorId',
|
||||||
|
'action',
|
||||||
|
'outcome',
|
||||||
|
'requestId',
|
||||||
|
'instanceId',
|
||||||
|
'jobId',
|
||||||
|
'itemId',
|
||||||
|
'attemptId',
|
||||||
|
'preparationId',
|
||||||
|
'parameterSummary',
|
||||||
|
] as const;
|
||||||
|
const PROBLEM_KEYS = [
|
||||||
|
'type',
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'detail',
|
||||||
|
'code',
|
||||||
|
'requestId',
|
||||||
|
'validation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
required: readonly string[],
|
||||||
|
optional: readonly string[] = [],
|
||||||
|
): boolean {
|
||||||
|
const allowed = new Set([...required, ...optional]);
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return (
|
||||||
|
required.every((key) => Object.hasOwn(value, key)) && keys.every((key) => allowed.has(key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 && value.length <= MAX_STRING ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | undefined | null {
|
||||||
|
return value === undefined ? undefined : requiredString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTimestamp(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TIMESTAMP) return null;
|
||||||
|
const milliseconds = Date.parse(value);
|
||||||
|
return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCanonicalTimestamp(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_TIMESTAMP) return false;
|
||||||
|
const milliseconds = Date.parse(value);
|
||||||
|
return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValidationIssue(value: unknown): ValidationIssue | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
source === null ||
|
||||||
|
!hasExactKeys(source, ['field', 'code', 'message']) ||
|
||||||
|
requiredString(source.field) === null ||
|
||||||
|
requiredString(source.code) === null ||
|
||||||
|
requiredString(source.message) === null
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
field: source.field as string,
|
||||||
|
code: source.code as string,
|
||||||
|
message: source.message as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProblem(value: unknown): ProblemDetails | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
source === null ||
|
||||||
|
!hasExactKeys(source, PROBLEM_KEYS.slice(0, 6), ['validation']) ||
|
||||||
|
requiredString(source.type) === null ||
|
||||||
|
requiredString(source.title) === null ||
|
||||||
|
safeInteger(source.status, 400, 599) === null ||
|
||||||
|
requiredString(source.detail) === null ||
|
||||||
|
requiredString(source.code) === null ||
|
||||||
|
requiredString(source.requestId) === null
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let validation: ValidationIssue[] | undefined;
|
||||||
|
if (source.validation !== undefined) {
|
||||||
|
if (!Array.isArray(source.validation) || source.validation.length > MAX_PAGE_SIZE) return null;
|
||||||
|
validation = [];
|
||||||
|
for (const candidate of source.validation) {
|
||||||
|
const issue = parseValidationIssue(candidate);
|
||||||
|
if (issue === null) return null;
|
||||||
|
validation.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: source.type as string,
|
||||||
|
title: source.title as string,
|
||||||
|
status: source.status as number,
|
||||||
|
detail: source.detail as string,
|
||||||
|
code: source.code as string,
|
||||||
|
requestId: source.requestId as string,
|
||||||
|
...(validation === undefined ? {} : { validation }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSummary(value: unknown): AuditEvent['parameterSummary'] | null {
|
||||||
|
if (!Array.isArray(value) || value.length > MAX_PARAMETERS) return null;
|
||||||
|
const result = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
const item = record(candidate);
|
||||||
|
if (
|
||||||
|
item === null ||
|
||||||
|
!hasExactKeys(item, ['fieldId', 'displayValue', 'redacted']) ||
|
||||||
|
requiredString(item.fieldId) === null ||
|
||||||
|
typeof item.displayValue !== 'string' ||
|
||||||
|
item.displayValue.length > MAX_STRING ||
|
||||||
|
item.redacted !== true
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
result.push({
|
||||||
|
fieldId: item.fieldId as string,
|
||||||
|
displayValue: '[REDACTED]',
|
||||||
|
redacted: true,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvent(value: unknown): AuditEvent | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
source === null ||
|
||||||
|
!hasExactKeys(
|
||||||
|
source,
|
||||||
|
['id', 'occurredAt', 'actorId', 'action', 'outcome', 'requestId'],
|
||||||
|
EVENT_KEYS.slice(6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const id = requiredString(source.id);
|
||||||
|
const occurredAt = canonicalTimestamp(source.occurredAt);
|
||||||
|
const actorId = requiredString(source.actorId);
|
||||||
|
const action = requiredString(source.action);
|
||||||
|
const requestId = requiredString(source.requestId);
|
||||||
|
const outcome = AUDIT_OUTCOMES.includes(source.outcome as AuditOutcome)
|
||||||
|
? (source.outcome as AuditOutcome)
|
||||||
|
: null;
|
||||||
|
const instanceId = optionalString(source.instanceId);
|
||||||
|
const jobId = optionalString(source.jobId);
|
||||||
|
const itemId = optionalString(source.itemId);
|
||||||
|
const attemptId = optionalString(source.attemptId);
|
||||||
|
const preparationId = optionalString(source.preparationId);
|
||||||
|
const parameterSummary =
|
||||||
|
source.parameterSummary === undefined ? undefined : parseSummary(source.parameterSummary);
|
||||||
|
if (
|
||||||
|
id === null ||
|
||||||
|
occurredAt === null ||
|
||||||
|
actorId === null ||
|
||||||
|
action === null ||
|
||||||
|
requestId === null ||
|
||||||
|
outcome === null ||
|
||||||
|
instanceId === null ||
|
||||||
|
jobId === null ||
|
||||||
|
itemId === null ||
|
||||||
|
attemptId === null ||
|
||||||
|
preparationId === null ||
|
||||||
|
parameterSummary === null
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
occurredAt,
|
||||||
|
actorId,
|
||||||
|
action,
|
||||||
|
outcome,
|
||||||
|
requestId,
|
||||||
|
...(instanceId === undefined ? {} : { instanceId }),
|
||||||
|
...(jobId === undefined ? {} : { jobId }),
|
||||||
|
...(itemId === undefined ? {} : { itemId }),
|
||||||
|
...(attemptId === undefined ? {} : { attemptId }),
|
||||||
|
...(preparationId === undefined ? {} : { preparationId }),
|
||||||
|
...(parameterSummary === undefined ? {} : { parameterSummary }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(
|
||||||
|
value: unknown,
|
||||||
|
minimum: number,
|
||||||
|
maximum = Number.MAX_SAFE_INTEGER,
|
||||||
|
): number | null {
|
||||||
|
return typeof value === 'number' &&
|
||||||
|
Number.isSafeInteger(value) &&
|
||||||
|
value >= minimum &&
|
||||||
|
value <= maximum
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePage(value: unknown): AuditPage {
|
||||||
|
const source = record(value);
|
||||||
|
if (source === null || !hasExactKeys(source, ['items', 'page']) || !Array.isArray(source.items))
|
||||||
|
throw new Error('Audit response is invalid.');
|
||||||
|
if (source.items.length > MAX_EVENTS) throw new Error('Audit response is invalid.');
|
||||||
|
const meta = record(source.page);
|
||||||
|
if (meta === null || !hasExactKeys(meta, ['page', 'pageSize', 'total']))
|
||||||
|
throw new Error('Audit response is invalid.');
|
||||||
|
const page = safeInteger(meta.page, 1);
|
||||||
|
const pageSize = safeInteger(meta.pageSize, 1, MAX_PAGE_SIZE);
|
||||||
|
const total = safeInteger(meta.total, 0);
|
||||||
|
if (
|
||||||
|
page === null ||
|
||||||
|
pageSize === null ||
|
||||||
|
total === null ||
|
||||||
|
source.items.length > pageSize ||
|
||||||
|
source.items.length > total
|
||||||
|
)
|
||||||
|
throw new Error('Audit response is invalid.');
|
||||||
|
const items: AuditEvent[] = [];
|
||||||
|
for (const candidate of source.items) {
|
||||||
|
const item = parseEvent(candidate);
|
||||||
|
if (item === null) throw new Error('Audit response is invalid.');
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return { items, page: { page, pageSize, total } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryParameters(query: AuditPageQuery): URLSearchParams {
|
||||||
|
const source = record(query);
|
||||||
|
if (
|
||||||
|
source === null ||
|
||||||
|
Object.keys(source).some((key) => !(QUERY_KEYS as readonly string[]).includes(key))
|
||||||
|
)
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
const identifierKeys = ['actorId', 'instanceId', 'jobId', 'operationId', 'requestId'] as const;
|
||||||
|
const parameters = new URLSearchParams();
|
||||||
|
for (const key of identifierKeys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_QUERY_IDENTIFIER)
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set(key, value);
|
||||||
|
}
|
||||||
|
for (const key of ['occurredFrom', 'occurredTo'] as const) {
|
||||||
|
const value = source[key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (!isCanonicalTimestamp(value)) throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set(key, value);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof source.occurredFrom === 'string' &&
|
||||||
|
typeof source.occurredTo === 'string' &&
|
||||||
|
source.occurredFrom > source.occurredTo
|
||||||
|
)
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
if (source.outcome !== undefined) {
|
||||||
|
if (!AUDIT_OUTCOMES.includes(source.outcome as AuditOutcome))
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set('outcome', source.outcome as string);
|
||||||
|
}
|
||||||
|
if (source.sort !== undefined) {
|
||||||
|
if (!['occurredAt', 'action', 'outcome'].includes(source.sort as string))
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set('sort', source.sort as string);
|
||||||
|
}
|
||||||
|
if (source.direction !== undefined) {
|
||||||
|
if (!SORT_DIRECTIONS.includes(source.direction as (typeof SORT_DIRECTIONS)[number]))
|
||||||
|
throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set('direction', source.direction as string);
|
||||||
|
}
|
||||||
|
for (const [key, maximum] of [
|
||||||
|
['page', Number.MAX_SAFE_INTEGER],
|
||||||
|
['pageSize', MAX_PAGE_SIZE],
|
||||||
|
] as const) {
|
||||||
|
const value = source[key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (safeInteger(value, 1, maximum) === null) throw new Error('Audit query is invalid.');
|
||||||
|
parameters.set(key, String(value));
|
||||||
|
}
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly requestId: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, requestId: string) {
|
||||||
|
super(`Audit request failed (${status}; code=${code}; requestId=${requestId}).`);
|
||||||
|
this.name = 'AuditApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response): Promise<AuditApiError> {
|
||||||
|
let code = 'UNKNOWN';
|
||||||
|
let requestId = 'unavailable';
|
||||||
|
try {
|
||||||
|
const problem = parseProblem(await response.json());
|
||||||
|
if (problem !== null && problem.status === response.status) {
|
||||||
|
code = problem.code;
|
||||||
|
requestId = problem.requestId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Deliberately do not inspect response text or expose arbitrary upstream fields.
|
||||||
|
}
|
||||||
|
return new AuditApiError(response.status, code, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuditApiDataSource(fetcher: typeof fetch = fetch): AuditDataSource {
|
||||||
|
return {
|
||||||
|
async load(query, signal): Promise<AuditPage> {
|
||||||
|
const parameters = queryParameters(query);
|
||||||
|
const url = parameters.size === 0 ? ENDPOINT : `${ENDPOINT}?${parameters.toString()}`;
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await responseError(response);
|
||||||
|
return parsePage(await response.json());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -148,4 +148,7 @@ export {
|
|||||||
type InstanceSettingsPageProps,
|
type InstanceSettingsPageProps,
|
||||||
} from './settings/instance-settings-page.js';
|
} from './settings/instance-settings-page.js';
|
||||||
|
|
||||||
|
export { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
||||||
|
export { JobsApiError, createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
|
||||||
|
|
||||||
export const webWorkspaceReady = true;
|
export const webWorkspaceReady = true;
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { JobPageQuery } from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
import { JobsApiError, createJobsApiDataSource } from './jobs-api-data-source.js';
|
||||||
|
|
||||||
|
const problem = {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Delivery failed',
|
||||||
|
status: 502,
|
||||||
|
detail: 'Upstream detail that must remain data only.',
|
||||||
|
code: 'UPSTREAM_FAILURE',
|
||||||
|
requestId: 'req-item',
|
||||||
|
};
|
||||||
|
const job = {
|
||||||
|
id: 'job-1',
|
||||||
|
operationId: 'message.send',
|
||||||
|
status: 'failed',
|
||||||
|
retryOfJobId: 'job-0',
|
||||||
|
rootJobId: 'job-root',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
targetId: 'instance / one',
|
||||||
|
state: 'failed',
|
||||||
|
sourceJobItemId: 'source-item',
|
||||||
|
error: problem,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
id: 'attempt-1',
|
||||||
|
state: 'failed',
|
||||||
|
startedAt: '2026-07-17T10:00:01.000Z',
|
||||||
|
finishedAt: '2026-07-17T10:00:02.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2026-07-17T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': status >= 400 ? 'application/problem+json' : 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Jobs API data source', () => {
|
||||||
|
it('GETs the exact endpoint with allowlisted URLSearchParams and request controls', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(json({ items: [job], page: { page: 2, pageSize: 25, total: 26 } }));
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
const query: JobPageQuery = {
|
||||||
|
status: 'failed',
|
||||||
|
operationId: 'message.send + retry',
|
||||||
|
rootJobId: 'root & one',
|
||||||
|
instanceId: 'instance / one',
|
||||||
|
sort: 'operationId',
|
||||||
|
direction: 'desc',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createJobsApiDataSource(fetcher).load(query, signal);
|
||||||
|
|
||||||
|
const [url, init] = fetcher.mock.calls[0] ?? [];
|
||||||
|
expect(String(url).split('?')[0]).toBe('/api/v1/jobs');
|
||||||
|
expect(Object.fromEntries(new URLSearchParams(String(url).split('?')[1]))).toEqual({
|
||||||
|
status: 'failed',
|
||||||
|
operationId: 'message.send + retry',
|
||||||
|
rootJobId: 'root & one',
|
||||||
|
instanceId: 'instance / one',
|
||||||
|
sort: 'operationId',
|
||||||
|
direction: 'desc',
|
||||||
|
page: '2',
|
||||||
|
pageSize: '25',
|
||||||
|
});
|
||||||
|
expect(init).toEqual({
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown query keys rather than forwarding them', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>();
|
||||||
|
const query = { page: 1, secret: 'must-not-leak' } as JobPageQuery;
|
||||||
|
await expect(
|
||||||
|
createJobsApiDataSource(fetcher).load(query, new AbortController().signal),
|
||||||
|
).rejects.toThrow('Jobs query is invalid.');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and reconstructs the exact frozen JobPage shape', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(json({ items: [job], page: { page: 1, pageSize: 25, total: 1 } }));
|
||||||
|
await expect(
|
||||||
|
createJobsApiDataSource(fetcher).load({}, new AbortController().signal),
|
||||||
|
).resolves.toEqual({ items: [job], page: { page: 1, pageSize: 25, total: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects extra keys, unbounded counts, non-terminal items, bad attempts, and timestamps', async () => {
|
||||||
|
const envelope = (candidate: unknown, page = { page: 1, pageSize: 25, total: 1 }) => ({
|
||||||
|
items: [candidate],
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
const invalidBodies = [
|
||||||
|
{ ...envelope(job), extra: true },
|
||||||
|
envelope({ ...job, extra: true }),
|
||||||
|
envelope({ ...job, items: [{ ...job.items[0], extra: true }] }),
|
||||||
|
envelope({ ...job, items: [{ ...job.items[0], state: 'running' }] }),
|
||||||
|
envelope({ ...job, attempts: [{ ...job.attempts[0], state: 'queued' }] }),
|
||||||
|
envelope({ ...job, createdAt: '2026-07-17T12:00:00+02:00' }),
|
||||||
|
envelope({ ...job, items: [{ ...job.items[0], error: { ...problem, raw: 'secret' } }] }),
|
||||||
|
envelope({ ...job, items: [{ ...job.items[0], error: { ...problem, status: 399 } }] }),
|
||||||
|
envelope({ ...job, id: 'x'.repeat(257) }),
|
||||||
|
envelope(job, { page: 1, pageSize: 101, total: 1 }),
|
||||||
|
envelope(job, { page: 1, pageSize: 25, total: 0 }),
|
||||||
|
{
|
||||||
|
items: Array.from({ length: 101 }, (_, index) => ({ ...job, id: `job-${index}` })),
|
||||||
|
page: { page: 1, pageSize: 100, total: 101 },
|
||||||
|
},
|
||||||
|
{ ...envelope(job), page: { page: 1, pageSize: 25, total: 1, extra: true } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const body of invalidBodies) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(body));
|
||||||
|
await expect(
|
||||||
|
createJobsApiDataSource(fetcher).load({}, new AbortController().signal),
|
||||||
|
).rejects.toThrow('Jobs response is invalid.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects HTTP Problems to status, code, and requestId only', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
type: 'private-type',
|
||||||
|
title: 'Private title',
|
||||||
|
status: 403,
|
||||||
|
detail: 'token=secret',
|
||||||
|
code: 'JOBS_DENIED',
|
||||||
|
requestId: 'req-safe',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await createJobsApiDataSource(fetcher)
|
||||||
|
.load({}, new AbortController().signal)
|
||||||
|
.catch((value: unknown) => value as JobsApiError);
|
||||||
|
expect(error).toBeInstanceOf(JobsApiError);
|
||||||
|
expect(error).toMatchObject({ status: 403, code: 'JOBS_DENIED', requestId: 'req-safe' });
|
||||||
|
expect(Object.keys(error).sort()).toEqual(['code', 'name', 'requestId', 'status']);
|
||||||
|
expect(error.message).toBe('Jobs request failed (403; code=JOBS_DENIED; requestId=req-safe).');
|
||||||
|
expect(error.message).not.toMatch(/private|title|token|secret/i);
|
||||||
|
expect('detail' in error).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
ATTEMPT_STATUSES,
|
||||||
|
JOB_ITEM_TERMINAL_STATES,
|
||||||
|
JOB_STATUSES,
|
||||||
|
MAX_PAGE_SIZE,
|
||||||
|
SORT_DIRECTIONS,
|
||||||
|
type Attempt,
|
||||||
|
type Job,
|
||||||
|
type JobItem,
|
||||||
|
type JobPage,
|
||||||
|
type JobPageQuery,
|
||||||
|
type ProblemDetails,
|
||||||
|
type ValidationIssue,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
import type { JobsDataSource } from './jobs-page.js';
|
||||||
|
|
||||||
|
const MAX_STRING_LENGTH = 256;
|
||||||
|
const QUERY_KEYS = [
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
'sort',
|
||||||
|
'direction',
|
||||||
|
'status',
|
||||||
|
'operationId',
|
||||||
|
'rootJobId',
|
||||||
|
'instanceId',
|
||||||
|
] as const;
|
||||||
|
const ITEM_KEYS = ['id', 'targetId', 'state', 'sourceJobItemId', 'error'] as const;
|
||||||
|
const ATTEMPT_KEYS = ['id', 'state', 'startedAt', 'finishedAt'] as const;
|
||||||
|
const PROBLEM_KEYS = [
|
||||||
|
'type',
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'detail',
|
||||||
|
'code',
|
||||||
|
'requestId',
|
||||||
|
'validation',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
required: readonly string[],
|
||||||
|
optional: readonly string[] = [],
|
||||||
|
): boolean {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
const allowed = new Set([...required, ...optional]);
|
||||||
|
return (
|
||||||
|
required.every((key) => Object.hasOwn(value, key)) && keys.every((key) => allowed.has(key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length > 0 && value.length <= MAX_STRING_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalTimestamp(value: unknown): value is string {
|
||||||
|
if (!boundedString(value)) return false;
|
||||||
|
const time = Date.parse(value);
|
||||||
|
return Number.isFinite(time) && new Date(time).toISOString() === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePositiveInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValidationIssue(value: unknown): ValidationIssue | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
!hasExactKeys(source, ['field', 'code', 'message']) ||
|
||||||
|
!boundedString(source.field) ||
|
||||||
|
!boundedString(source.code) ||
|
||||||
|
!boundedString(source.message)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return { field: source.field, code: source.code, message: source.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProblem(value: unknown): ProblemDetails | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
!hasExactKeys(source, PROBLEM_KEYS.slice(0, 6), ['validation']) ||
|
||||||
|
!boundedString(source.type) ||
|
||||||
|
!boundedString(source.title) ||
|
||||||
|
!Number.isSafeInteger(source.status) ||
|
||||||
|
(source.status as number) < 400 ||
|
||||||
|
(source.status as number) > 599 ||
|
||||||
|
!boundedString(source.detail) ||
|
||||||
|
!boundedString(source.code) ||
|
||||||
|
!boundedString(source.requestId)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let validation: ValidationIssue[] | undefined;
|
||||||
|
if (source.validation !== undefined) {
|
||||||
|
if (!Array.isArray(source.validation) || source.validation.length > MAX_PAGE_SIZE) return null;
|
||||||
|
validation = [];
|
||||||
|
for (const candidate of source.validation) {
|
||||||
|
const issue = parseValidationIssue(candidate);
|
||||||
|
if (!issue) return null;
|
||||||
|
validation.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: source.type,
|
||||||
|
title: source.title,
|
||||||
|
status: source.status as number,
|
||||||
|
detail: source.detail,
|
||||||
|
code: source.code,
|
||||||
|
requestId: source.requestId,
|
||||||
|
...(validation === undefined ? {} : { validation }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItem(value: unknown): JobItem | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
!hasExactKeys(source, ITEM_KEYS.slice(0, 3), ['sourceJobItemId', 'error']) ||
|
||||||
|
!boundedString(source.id) ||
|
||||||
|
!boundedString(source.targetId) ||
|
||||||
|
typeof source.state !== 'string' ||
|
||||||
|
!(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(source.state)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const sourceJobItemId = source.sourceJobItemId;
|
||||||
|
if (sourceJobItemId !== undefined && !boundedString(sourceJobItemId)) return null;
|
||||||
|
const error = source.error === undefined ? undefined : parseProblem(source.error);
|
||||||
|
if (source.error !== undefined && !error) return null;
|
||||||
|
const result: JobItem = {
|
||||||
|
id: source.id,
|
||||||
|
targetId: source.targetId,
|
||||||
|
state: source.state as JobItem['state'],
|
||||||
|
...(sourceJobItemId === undefined ? {} : { sourceJobItemId }),
|
||||||
|
};
|
||||||
|
return error ? { ...result, error } : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttempt(value: unknown): Attempt | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
!hasExactKeys(source, ATTEMPT_KEYS.slice(0, 3), ['finishedAt']) ||
|
||||||
|
!boundedString(source.id) ||
|
||||||
|
typeof source.state !== 'string' ||
|
||||||
|
!(ATTEMPT_STATUSES as readonly string[]).includes(source.state) ||
|
||||||
|
!canonicalTimestamp(source.startedAt) ||
|
||||||
|
(source.finishedAt !== undefined && !canonicalTimestamp(source.finishedAt))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
state: source.state as Attempt['state'],
|
||||||
|
startedAt: source.startedAt,
|
||||||
|
...(source.finishedAt === undefined ? {} : { finishedAt: source.finishedAt as string }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJob(value: unknown): Job | null {
|
||||||
|
const source = record(value);
|
||||||
|
if (
|
||||||
|
!source ||
|
||||||
|
!hasExactKeys(
|
||||||
|
source,
|
||||||
|
['id', 'operationId', 'status', 'rootJobId', 'items', 'attempts', 'createdAt'],
|
||||||
|
['retryOfJobId'],
|
||||||
|
) ||
|
||||||
|
!boundedString(source.id) ||
|
||||||
|
!boundedString(source.operationId) ||
|
||||||
|
typeof source.status !== 'string' ||
|
||||||
|
!(JOB_STATUSES as readonly string[]).includes(source.status) ||
|
||||||
|
!boundedString(source.rootJobId) ||
|
||||||
|
!Array.isArray(source.items) ||
|
||||||
|
source.items.length > MAX_PAGE_SIZE ||
|
||||||
|
!Array.isArray(source.attempts) ||
|
||||||
|
source.attempts.length > MAX_PAGE_SIZE ||
|
||||||
|
!canonicalTimestamp(source.createdAt) ||
|
||||||
|
(source.retryOfJobId !== undefined && !boundedString(source.retryOfJobId))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const items: JobItem[] = [];
|
||||||
|
for (const candidate of source.items) {
|
||||||
|
const item = parseItem(candidate);
|
||||||
|
if (!item) return null;
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
const attempts: Attempt[] = [];
|
||||||
|
for (const candidate of source.attempts) {
|
||||||
|
const attempt = parseAttempt(candidate);
|
||||||
|
if (!attempt) return null;
|
||||||
|
attempts.push(attempt);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: source.id,
|
||||||
|
operationId: source.operationId,
|
||||||
|
status: source.status as Job['status'],
|
||||||
|
...(source.retryOfJobId === undefined ? {} : { retryOfJobId: source.retryOfJobId as string }),
|
||||||
|
rootJobId: source.rootJobId,
|
||||||
|
items,
|
||||||
|
attempts,
|
||||||
|
createdAt: source.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJobPage(value: unknown): JobPage {
|
||||||
|
const source = record(value);
|
||||||
|
if (!source || !hasExactKeys(source, ['items', 'page']) || !Array.isArray(source.items))
|
||||||
|
throw new Error('Jobs response is invalid.');
|
||||||
|
const page = record(source.page);
|
||||||
|
if (
|
||||||
|
!page ||
|
||||||
|
!hasExactKeys(page, ['page', 'pageSize', 'total']) ||
|
||||||
|
!safePositiveInteger(page.page) ||
|
||||||
|
!safePositiveInteger(page.pageSize, MAX_PAGE_SIZE) ||
|
||||||
|
!Number.isSafeInteger(page.total) ||
|
||||||
|
(page.total as number) < 0 ||
|
||||||
|
source.items.length > MAX_PAGE_SIZE ||
|
||||||
|
source.items.length > (page.pageSize as number) ||
|
||||||
|
source.items.length > (page.total as number)
|
||||||
|
)
|
||||||
|
throw new Error('Jobs response is invalid.');
|
||||||
|
const items: Job[] = [];
|
||||||
|
for (const candidate of source.items) {
|
||||||
|
const item = parseJob(candidate);
|
||||||
|
if (!item) throw new Error('Jobs response is invalid.');
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: {
|
||||||
|
page: page.page as number,
|
||||||
|
pageSize: page.pageSize as number,
|
||||||
|
total: page.total as number,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryString(query: JobPageQuery): string {
|
||||||
|
if (Object.keys(query).some((key) => !(QUERY_KEYS as readonly string[]).includes(key)))
|
||||||
|
throw new Error('Jobs query is invalid.');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of QUERY_KEYS) {
|
||||||
|
const value = query[key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
const valid =
|
||||||
|
(key === 'page' && safePositiveInteger(value)) ||
|
||||||
|
(key === 'pageSize' && safePositiveInteger(value, MAX_PAGE_SIZE)) ||
|
||||||
|
(key === 'sort' && ['createdAt', 'status', 'operationId'].includes(value as string)) ||
|
||||||
|
(key === 'direction' && (SORT_DIRECTIONS as readonly unknown[]).includes(value)) ||
|
||||||
|
(key === 'status' && (JOB_STATUSES as readonly unknown[]).includes(value)) ||
|
||||||
|
(['operationId', 'rootJobId', 'instanceId'].includes(key) && boundedString(value));
|
||||||
|
if (!valid) throw new Error('Jobs query is invalid.');
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
const serialized = params.toString();
|
||||||
|
return serialized ? `?${serialized}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobsApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly requestId: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, requestId: string) {
|
||||||
|
super(`Jobs request failed (${status}; code=${code}; requestId=${requestId}).`);
|
||||||
|
this.name = 'JobsApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response): Promise<JobsApiError> {
|
||||||
|
let code = 'UNKNOWN';
|
||||||
|
let requestId = 'unavailable';
|
||||||
|
try {
|
||||||
|
const problem = parseProblem(await response.json());
|
||||||
|
if (problem && problem.status === response.status) {
|
||||||
|
code = problem.code;
|
||||||
|
requestId = problem.requestId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Never read or expose raw response text or Problem detail/title.
|
||||||
|
}
|
||||||
|
return new JobsApiError(response.status, code, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJobsApiDataSource(fetcher: typeof fetch = fetch): JobsDataSource {
|
||||||
|
return {
|
||||||
|
async load(query, signal): Promise<JobPage> {
|
||||||
|
const response = await fetcher(`/api/v1/jobs${queryString(query)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw await responseError(response);
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error('Jobs response is invalid.');
|
||||||
|
}
|
||||||
|
return parseJobPage(body);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user