diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 73b06f9..57c8602 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -4,11 +4,15 @@ import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; 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 { JobsDataSource } from './jobs/jobs-page.js'; afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); const snapshot: FleetSnapshot = { @@ -52,6 +56,12 @@ function source(load: FleetDataSource['load']): FleetDataSource { return { load }; } +const quietEventStreamClient: EventStreamClient = { + subscribe: () => () => undefined, +}; + +const emptyPage = { items: [], page: { page: 1, pageSize: 25, total: 0 } }; + describe('React AppShell and Fleet vertical slice', () => { 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); @@ -197,4 +207,76 @@ describe('React AppShell and Fleet vertical slice', () => { expect(screen.queryByText('Owner modem')).toBeNull(); 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().mockResolvedValue( + new Response(JSON.stringify(emptyPage), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetcher); + + render(); + + 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(); + vi.stubGlobal('fetch', fetcher); + const load = vi + .fn() + .mockResolvedValue(emptyPage); + const injectedSource = { load }; + + render( + , + ); + + expect(await screen.findByText(emptyMessage)).toBeTruthy(); + expect(load).toHaveBeenCalledTimes(1); + expect(fetcher).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 77a3748..445e20e 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,4 +1,5 @@ import { AuditPage, type AuditDataSource } from './audit/audit-page.js'; +import { createAuditApiDataSource } from './audit/audit-api-data-source.js'; import type { ReactNode } 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 { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.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 { NotificationsModule, @@ -408,6 +410,14 @@ export function AppShell({ }: AppShellProps) { const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []); const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []); + const resolvedJobsDataSource = useMemo( + () => jobsDataSource ?? createJobsApiDataSource(), + [jobsDataSource], + ); + const resolvedAuditDataSource = useMemo( + () => auditDataSource ?? createAuditApiDataSource(), + [auditDataSource], + ); const resolved = resolveRoute(pathname); const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved; const routeInstanceId = route.params?.instanceId; @@ -465,8 +475,8 @@ export function AppShell({ notificationsDataSource={notificationsDataSource} automationDataSource={automationDataSource} otaDataSource={otaDataSource} - jobsDataSource={jobsDataSource} - auditDataSource={auditDataSource} + jobsDataSource={resolvedJobsDataSource} + auditDataSource={resolvedAuditDataSource} fleetRefreshSignal={refresh.fleet} detailRefreshSignal={refresh.detail} /> diff --git a/apps/web/src/audit/audit-api-data-source.test.ts b/apps/web/src/audit/audit-api-data-source.test.ts new file mode 100644 index 0000000..50b9a51 --- /dev/null +++ b/apps/web/src/audit/audit-api-data-source.test.ts @@ -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() + .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(); + 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(); + 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(); + 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().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() + .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().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().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).', + ); + } + }); +}); diff --git a/apps/web/src/audit/audit-api-data-source.ts b/apps/web/src/audit/audit-api-data-source.ts new file mode 100644 index 0000000..5ea9680 --- /dev/null +++ b/apps/web/src/audit/audit-api-data-source.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hasExactKeys( + value: Record, + 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 { + 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 { + 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()); + }, + }; +} diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 696c087..ac80585 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -148,4 +148,7 @@ export { type InstanceSettingsPageProps, } 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; diff --git a/apps/web/src/jobs/jobs-api-data-source.test.ts b/apps/web/src/jobs/jobs-api-data-source.test.ts new file mode 100644 index 0000000..f31f64f --- /dev/null +++ b/apps/web/src/jobs/jobs-api-data-source.test.ts @@ -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() + .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(); + 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() + .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().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().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); + }); +}); diff --git a/apps/web/src/jobs/jobs-api-data-source.ts b/apps/web/src/jobs/jobs-api-data-source.ts new file mode 100644 index 0000000..fbf0224 --- /dev/null +++ b/apps/web/src/jobs/jobs-api-data-source.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hasExactKeys( + value: Record, + 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 { + 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 { + 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); + }, + }; +}