feat(web): connect jobs and audit workspaces
This commit is contained in:
@@ -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