From e45b011bb873125cd0cbfcd39aaa19deed97835d Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 00:23:45 +0800 Subject: [PATCH] feat(api): add contract-safe jobs read endpoints --- .../jobs/job-query-service.test.ts | 296 ++++++++++++++++++ .../src/application/jobs/job-query-service.ts | 279 +++++++++++++++++ apps/api/src/control-plane.test.ts | 3 + apps/api/src/control-plane.ts | 4 + .../api/src/interface/http/job-routes.test.ts | 207 ++++++++++++ apps/api/src/interface/http/job-routes.ts | 133 ++++++++ 6 files changed, 922 insertions(+) create mode 100644 apps/api/src/application/jobs/job-query-service.test.ts create mode 100644 apps/api/src/application/jobs/job-query-service.ts create mode 100644 apps/api/src/interface/http/job-routes.test.ts create mode 100644 apps/api/src/interface/http/job-routes.ts diff --git a/apps/api/src/application/jobs/job-query-service.test.ts b/apps/api/src/application/jobs/job-query-service.test.ts new file mode 100644 index 0000000..8a95917 --- /dev/null +++ b/apps/api/src/application/jobs/job-query-service.test.ts @@ -0,0 +1,296 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; + +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { JobQueryError, JobQueryService } from './job-query-service.js'; + +const t1 = '2026-07-17T10:00:00.000Z'; +const t2 = '2026-07-17T11:00:00.000Z'; + +function setup(): { db: Database.Database; subject: JobQueryService } { + const db = new Database(':memory:'); + migrateDatabase(db); + return { db, subject: new JobQueryService(db) }; +} + +function insertJob( + db: Database.Database, + values: Partial> = {}, +): void { + const id = values.id ?? 'job-1'; + const root = Object.prototype.hasOwnProperty.call(values, 'root') ? values.root : id; + db.prepare( + `INSERT INTO jobs + (id, root_job_id, operation_id, risk_level, status, requested_by, request_id, + parameters_digest, created_at, updated_at) + VALUES (?, ?, ?, 'R1', ?, 'actor', 'request-1', 'digest', ?, ?)`, + ).run( + id, + root, + values.operation ?? 'op.one', + values.status ?? 'succeeded', + values.created ?? t1, + values.created ?? t1, + ); +} + +function insertItem(db: Database.Database, overrides: Record = {}): void { + const row = { + id: 'item-1', + job: 'job-1', + instance: 'instance-1', + status: 'succeeded', + source: null, + error: null, + created: t1, + ...overrides, + }; + db.prepare( + `INSERT INTO job_items + (id, job_id, instance_id, attempt_number, status, error_json, source_job_item_id, + created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`, + ).run(row.id, row.job, row.instance, row.status, row.error, row.source, row.created, row.created); +} + +function insertAttempt(db: Database.Database, overrides: Record = {}): void { + const row = { + id: 'attempt-1', + job: 'job-1', + status: 'succeeded', + started: t1, + finished: t2, + created: t1, + ...overrides, + }; + db.prepare( + `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(row.id, row.job, row.status, row.started, row.finished, row.created); +} + +function expectCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(JobQueryError); + expect((error as JobQueryError).code).toBe(code); + return; + } + throw new Error('expected JobQueryError'); +} + +describe('JobQueryService', () => { + it.each(['queued', 'running', 'cancelling'])( + 'projects only terminal items while a %s job is active and retains its running attempt', + (status) => { + const { db, subject } = setup(); + insertJob(db, { status }); + insertItem(db, { id: 'terminal', instance: 'done', status: 'succeeded' }); + insertItem(db, { id: 'internal', instance: 'pending', status: 'running' }); + insertAttempt(db, { status: 'running', finished: null }); + + expect(subject.get('job-1')).toMatchObject({ + status, + items: [{ id: 'terminal', targetId: 'done', state: 'succeeded' }], + attempts: [{ id: 'attempt-1', state: 'running', startedAt: t1 }], + }); + }, + ); + + it('returns the exact frozen projection with deterministic child ordering', () => { + const { db, subject } = setup(); + insertJob(db); + insertItem(db, { id: 'item-z', instance: 'target-z' }); + insertItem(db, { id: 'item-a', instance: 'target-a', source: 'item-z' }); + insertAttempt(db, { id: 'attempt-z', started: t1 }); + insertAttempt(db, { id: 'attempt-a', started: t1, status: 'running', finished: null }); + + expect(subject.get('job-1')).toEqual({ + id: 'job-1', + operationId: 'op.one', + status: 'succeeded', + rootJobId: 'job-1', + items: [ + { id: 'item-a', targetId: 'target-a', state: 'succeeded', sourceJobItemId: 'item-z' }, + { id: 'item-z', targetId: 'target-z', state: 'succeeded' }, + ], + attempts: [ + { id: 'attempt-a', state: 'running', startedAt: t1 }, + { id: 'attempt-z', state: 'succeeded', startedAt: t1, finishedAt: t2 }, + ], + createdAt: t1, + }); + }); + + it('paginates in SQL with filters and an ascending id tie-break independent of direction', () => { + const { db, subject } = setup(); + insertJob(db, { id: 'z', operation: 'same', created: t2 }); + insertJob(db, { id: 'a', operation: 'same', created: t2 }); + insertJob(db, { id: 'other', operation: 'other', created: t1 }); + insertItem(db, { id: 'z-item', job: 'z', instance: 'wanted' }); + insertItem(db, { id: 'a-item', job: 'a', instance: 'wanted' }); + + expect( + subject.list({ + page: 1, + pageSize: 1, + sort: 'createdAt', + direction: 'desc', + operationId: 'same', + status: 'succeeded', + rootJobId: 'z', + instanceId: 'wanted', + }), + ).toEqual({ + items: [expect.objectContaining({ id: 'z' })], + page: { page: 1, pageSize: 1, total: 1 }, + }); + expect( + subject.list({ sort: 'createdAt', direction: 'desc' }).items.map(({ id }) => id), + ).toEqual(['a', 'z', 'other']); + }); + + it('reports not found without exposing database details', () => { + const { subject } = setup(); + expect(() => subject.get('missing')).toThrow('Job was not found'); + expectCode(() => subject.get('missing'), 'NOT_FOUND'); + }); + + it.each([ + [{ page: 0 }, 'page'], + [{ page: 1.5 }, 'page'], + [{ pageSize: 0 }, 'pageSize'], + [{ pageSize: 101 }, 'pageSize'], + [{ sort: 'id' }, 'sort'], + [{ direction: 'sideways' }, 'direction'], + ])('rejects invalid query input: %j', (query, field) => { + const { subject } = setup(); + expect(() => subject.list(query as never)).toThrow(new RegExp(field, 'i')); + expectCode(() => subject.list(query as never), 'VALIDATION_FAILED'); + }); + + it.each([ + [ + 'job status', + () => { + const x = setup(); + insertJob(x.db, { status: 'invented' }); + return x; + }, + ], + [ + 'missing root', + () => { + const x = setup(); + insertJob(x.db, { root: null }); + return x; + }, + ], + [ + 'running item', + () => { + const x = setup(); + insertJob(x.db); + insertItem(x.db, { status: 'running' }); + return x; + }, + ], + [ + 'attempt state', + () => { + const x = setup(); + insertJob(x.db); + x.db.pragma('ignore_check_constraints = ON'); + insertAttempt(x.db, { status: 'queued' }); + return x; + }, + ], + [ + 'timestamp', + () => { + const x = setup(); + insertJob(x.db, { created: '2026-07-17T10:00:00Z' }); + return x; + }, + ], + ])('fails the whole selection as UNREPRESENTABLE for invalid persisted %s', (_name, make) => { + const { subject } = make(); + expectCode(() => subject.list(), 'UNREPRESENTABLE'); + }); + + it.each([ + [ + 'items', + (db: Database.Database, count: number) => { + const insert = db.prepare( + `INSERT INTO job_items + (id, job_id, instance_id, attempt_number, status, created_at, updated_at) + VALUES (?, 'job-1', 'instance-1', ?, 'succeeded', ?, ?)`, + ); + db.transaction(() => { + for (let index = 1; index <= count; index += 1) + insert.run(`item-${index}`, index, t1, t1); + })(); + }, + ], + [ + 'attempts', + (db: Database.Database, count: number) => { + const insert = db.prepare( + `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) + VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`, + ); + db.transaction(() => { + for (let index = 1; index <= count; index += 1) + insert.run(`attempt-${index}`, t1, t2, t1); + })(); + }, + ], + ])('bounds hydrated child %s at 1000 rows per selected job', (_name, populate) => { + const atBound = setup(); + insertJob(atBound.db); + populate(atBound.db, 1000); + expect(atBound.subject.get('job-1')).toBeDefined(); + + const overBound = setup(); + insertJob(overBound.db); + populate(overBound.db, 1001); + expectCode(() => overBound.subject.get('job-1'), 'UNREPRESENTABLE'); + }); + + it('emits only a closed redacted item error and omits malformed optional errors', () => { + const { db, subject } = setup(); + insertJob(db); + insertItem(db, { + id: 'good', + instance: 'instance-good', + error: JSON.stringify({ + type: 'https://evil/secret', + title: 'database password leaked', + status: 502, + detail: 'password=secret', + code: 'UPSTREAM_FAILED', + requestId: 'req-safe', + extra: 'secret', + }), + }); + insertItem(db, { id: 'bad', instance: 'instance-bad', error: '{bad json' }); + expect(subject.get('job-1').items).toEqual([ + { id: 'bad', targetId: 'instance-bad', state: 'succeeded' }, + { + id: 'good', + targetId: 'instance-good', + state: 'succeeded', + error: { + type: 'about:blank', + title: 'Job item failed', + status: 502, + detail: 'The job item did not complete successfully.', + code: 'UPSTREAM_FAILED', + requestId: 'req-safe', + }, + }, + ]); + }); +}); diff --git a/apps/api/src/application/jobs/job-query-service.ts b/apps/api/src/application/jobs/job-query-service.ts new file mode 100644 index 0000000..b0a7a76 --- /dev/null +++ b/apps/api/src/application/jobs/job-query-service.ts @@ -0,0 +1,279 @@ +import type Database from 'better-sqlite3'; +import { + ATTEMPT_STATUSES, + JOB_ITEM_TERMINAL_STATES, + JOB_STATUSES, + type Attempt, + type Job, + type JobItem, + type JobPage, + type JobPageQuery, + type ProblemDetails, +} from '@multi-simadmin/contracts'; + +export type JobQueryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'UNREPRESENTABLE'; + +export class JobQueryError extends Error { + constructor( + readonly code: JobQueryErrorCode, + message: string, + ) { + super(message); + this.name = 'JobQueryError'; + } +} + +interface JobRow { + id: unknown; + operation_id: unknown; + status: unknown; + retry_of_job_id: unknown; + root_job_id: unknown; + created_at: unknown; +} +interface ItemRow { + id: unknown; + job_id: unknown; + instance_id: unknown; + status: unknown; + source_job_item_id: unknown; + error_json: unknown; +} +interface AttemptRow { + id: unknown; + job_id: unknown; + status: unknown; + started_at: unknown; + finished_at: unknown; +} + +const JOB_COLUMNS = 'id, operation_id, status, retry_of_job_id, root_job_id, created_at'; +const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const SAFE_ERROR_CODES = /^[A-Z][A-Z0-9_]{0,63}$/u; +const SAFE_REQUEST_IDS = /^[^\x00-\x1f\x7f]{1,200}$/u; +const MAX_CHILD_ROWS_PER_JOB = 1000; +const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']); + +function fail(message: string): never { + throw new JobQueryError('UNREPRESENTABLE', message); +} +function validation(message: string): never { + throw new JobQueryError('VALIDATION_FAILED', message); +} +function requiredString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) fail(`Persisted job ${field} is invalid`); + return value; +} +function optionalString(value: unknown, field: string): string | undefined { + if (value === null) return undefined; + return requiredString(value, field); +} +function timestamp(value: unknown, field: string): string { + const result = requiredString(value, field); + if (!CANONICAL_TIMESTAMP.test(result)) fail(`Persisted job ${field} is not canonical`); + const parsed = new Date(result); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result) + fail(`Persisted job ${field} is invalid`); + return result; +} +function positiveInteger(value: unknown, fallback: number, maximum: number, field: string): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || (result as number) < 1 || (result as number) > maximum) + validation(`${field} must be an integer from 1 to ${maximum}`); + return result as number; +} +function freeze(value: T): Readonly { + return Object.freeze(value); +} + +function safeError(serialized: unknown): ProblemDetails | undefined { + if (serialized === null) return undefined; + if (typeof serialized !== 'string') return undefined; + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + return undefined; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const row = value as Record; + if ( + typeof row.status !== 'number' || + !Number.isInteger(row.status) || + row.status < 400 || + row.status > 599 || + typeof row.code !== 'string' || + !SAFE_ERROR_CODES.test(row.code) || + typeof row.requestId !== 'string' || + !SAFE_REQUEST_IDS.test(row.requestId) + ) + return undefined; + return freeze({ + type: 'about:blank', + title: 'Job item failed', + status: row.status, + detail: 'The job item did not complete successfully.', + code: row.code, + requestId: row.requestId, + }); +} + +export class JobQueryService { + constructor(private readonly db: Database.Database) {} + + get(id: string): Job { + if (typeof id !== 'string' || id.length === 0) validation('id must be a non-empty string'); + const row = this.db.prepare(`SELECT ${JOB_COLUMNS} FROM jobs WHERE id = ?`).get(id) as + | JobRow + | undefined; + if (!row) throw new JobQueryError('NOT_FOUND', 'Job was not found'); + return this.project([row])[0]!; + } + + list(query: JobPageQuery = {}): JobPage { + const pageSize = positiveInteger(query.pageSize, 25, 100, 'pageSize'); + const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1; + const page = positiveInteger(query.page, 1, maximumPage, 'page'); + const sort = query.sort ?? 'createdAt'; + if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') + validation('sort is invalid'); + const direction = query.direction ?? 'asc'; + if (direction !== 'asc' && direction !== 'desc') validation('direction is invalid'); + + const clauses: string[] = []; + const parameters: unknown[] = []; + const filter = (column: string, value: unknown): void => { + if (value === undefined) return; + if (typeof value !== 'string' || value.length === 0) + validation(`${column} filter is invalid`); + clauses.push(`${column} = ?`); + parameters.push(value); + }; + filter('j.status', query.status); + filter('j.operation_id', query.operationId); + filter('j.root_job_id', query.rootJobId); + if (query.instanceId !== undefined) { + if (typeof query.instanceId !== 'string' || query.instanceId.length === 0) + validation('instanceId filter is invalid'); + clauses.push( + 'EXISTS (SELECT 1 FROM job_items ji WHERE ji.job_id = j.id AND ji.instance_id = ?)', + ); + parameters.push(query.instanceId); + } + const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : ''; + const totalRow = this.db + .prepare(`SELECT COUNT(*) AS total FROM jobs j${where}`) + .get(...parameters) as { total: unknown } | undefined; + if (!totalRow || typeof totalRow.total !== 'number' || !Number.isSafeInteger(totalRow.total)) + fail('Persisted job count is invalid'); + + const sortColumn = { + createdAt: 'j.created_at', + status: 'j.status', + operationId: 'j.operation_id', + }[sort]; + const offset = (page - 1) * pageSize; + const rows = this.db + .prepare( + `SELECT ${JOB_COLUMNS.split(', ') + .map((column) => `j.${column}`) + .join(', ')} FROM jobs j${where} + ORDER BY ${sortColumn} ${direction.toUpperCase()}, j.id ASC LIMIT ? OFFSET ?`, + ) + .all(...parameters, pageSize, offset) as JobRow[]; + return freeze({ + items: freeze(this.project(rows)), + page: freeze({ page, pageSize, total: totalRow.total }), + }); + } + + private project(rows: JobRow[]): Job[] { + if (rows.length === 0) return []; + const statuses = new Map(); + const ids = rows.map((row) => { + const id = requiredString(row.id, 'id'); + const status = requiredString(row.status, 'status'); + if (!(JOB_STATUSES as readonly string[]).includes(status)) + fail('Persisted job status is invalid'); + statuses.set(id, status as Job['status']); + return id; + }); + const placeholders = ids.map(() => '?').join(', '); + for (const table of ['job_items', 'job_attempts'] as const) { + const counts = this.db + .prepare( + `SELECT job_id, COUNT(*) AS count FROM ${table} + WHERE job_id IN (${placeholders}) GROUP BY job_id HAVING COUNT(*) > ?`, + ) + .all(...ids, MAX_CHILD_ROWS_PER_JOB) as { job_id: unknown; count: unknown }[]; + if (counts.length > 0) fail('Persisted job child collection exceeds the resource bound'); + } + const items = this.db + .prepare( + `SELECT id, job_id, instance_id, status, source_job_item_id, error_json + FROM job_items WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, id ASC`, + ) + .all(...ids) as ItemRow[]; + const attempts = this.db + .prepare( + `SELECT id, job_id, status, started_at, finished_at + FROM job_attempts WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, started_at ASC, id ASC`, + ) + .all(...ids) as AttemptRow[]; + const itemMap = new Map(); + const attemptMap = new Map(); + + for (const row of items) { + const jobId = requiredString(row.job_id, 'item job_id'); + const state = requiredString(row.status, 'item status'); + if (!(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(state)) { + if (ACTIVE_JOB_STATUSES.has(statuses.get(jobId) ?? '')) continue; + fail('Persisted job item is not terminal'); + } + const sourceJobItemId = optionalString(row.source_job_item_id, 'item source_job_item_id'); + const error = safeError(row.error_json); + const item: JobItem = freeze({ + id: requiredString(row.id, 'item id'), + targetId: requiredString(row.instance_id, 'item instance_id'), + state: state as JobItem['state'], + ...(sourceJobItemId === undefined ? {} : { sourceJobItemId }), + ...(error === undefined ? {} : { error }), + }); + const values = itemMap.get(jobId) ?? []; + values.push(item); + itemMap.set(jobId, values); + } + for (const row of attempts) { + const jobId = requiredString(row.job_id, 'attempt job_id'); + const state = requiredString(row.status, 'attempt status'); + if (!(ATTEMPT_STATUSES as readonly string[]).includes(state)) + fail('Persisted attempt status is invalid'); + const finishedAt = + row.finished_at === null ? undefined : timestamp(row.finished_at, 'attempt finished_at'); + const attempt: Attempt = freeze({ + id: requiredString(row.id, 'attempt id'), + state: state as Attempt['state'], + startedAt: timestamp(row.started_at, 'attempt started_at'), + ...(finishedAt === undefined ? {} : { finishedAt }), + }); + const values = attemptMap.get(jobId) ?? []; + values.push(attempt); + attemptMap.set(jobId, values); + } + return rows.map((row) => { + const id = requiredString(row.id, 'id'); + const status = statuses.get(id)!; + const retryOfJobId = optionalString(row.retry_of_job_id, 'retry_of_job_id'); + return freeze({ + id, + operationId: requiredString(row.operation_id, 'operation_id'), + status: status as Job['status'], + ...(retryOfJobId === undefined ? {} : { retryOfJobId }), + rootJobId: requiredString(row.root_job_id, 'root_job_id'), + items: freeze(itemMap.get(id) ?? []), + attempts: freeze(attemptMap.get(id) ?? []), + createdAt: timestamp(row.created_at, 'created_at'), + }); + }); + } +} diff --git a/apps/api/src/control-plane.test.ts b/apps/api/src/control-plane.test.ts index 780ea57..250198e 100644 --- a/apps/api/src/control-plane.test.ts +++ b/apps/api/src/control-plane.test.ts @@ -43,6 +43,9 @@ describe('buildControlPlaneApp', () => { expect(response.statusCode).toBe(401); expect(response.headers['content-type']).toContain('application/problem+json'); expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' }); + const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' }); + expect(jobs.statusCode).toBe(200); + expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } }); await app.close(); }); diff --git a/apps/api/src/control-plane.ts b/apps/api/src/control-plane.ts index c2ea4ed..42fe401 100644 --- a/apps/api/src/control-plane.ts +++ b/apps/api/src/control-plane.ts @@ -24,6 +24,8 @@ import { DeleteInstanceOperation } from './application/operations/delete-instanc import type { SecretStore } from './infrastructure/secrets/secret-store.js'; import { registerInstanceRoutes } from './interface/http/instance-routes.js'; import { registerEventRoutes } from './interface/http/event-routes.js'; +import { JobQueryService } from './application/jobs/job-query-service.js'; +import { registerJobRoutes } from './interface/http/job-routes.js'; export interface SafeControlPlaneUpstream extends ConnectionTransport { request: UpstreamSessionClientOptions['request']; @@ -42,6 +44,7 @@ export interface ControlPlaneApp extends FastifyInstance { } export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp { const eventJournal = new EventJournal(options.db); + const jobs = new JobQueryService(options.db); const instances = options.now ? new InstanceService({ db: options.db, store: options.store, now: options.now }) : new InstanceService({ db: options.db, store: options.store }); @@ -86,6 +89,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane registerDeletionPreparationRoute: false, }); registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion); + registerJobRoutes(app, { jobs }); registerEventRoutes(app, { journal: eventJournal, ...(options.authenticateEventStream diff --git a/apps/api/src/interface/http/job-routes.test.ts b/apps/api/src/interface/http/job-routes.test.ts new file mode 100644 index 0000000..d7b3c86 --- /dev/null +++ b/apps/api/src/interface/http/job-routes.test.ts @@ -0,0 +1,207 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildApp } from '../../app.js'; +import { JobQueryService } from '../../application/jobs/job-query-service.js'; +import { migrateDatabase } from '../../infrastructure/database/migrations.js'; +import { registerJobRoutes } from './job-routes.js'; + +const dbs: Database.Database[] = []; +const timestamp = '2026-07-17T10:00:00.000Z'; + +afterEach(() => { + for (const db of dbs.splice(0)) db.close(); +}); + +function fixture() { + const db = new Database(':memory:'); + db.pragma('foreign_keys=ON'); + migrateDatabase(db); + dbs.push(db); + const jobs = new JobQueryService(db); + const app = buildApp({ registerRoutes: (scope) => registerJobRoutes(scope, { jobs }) }); + return { app, db, jobs }; +} + +function insertJob(db: Database.Database, id = 'job-1', status = 'succeeded'): void { + db.prepare( + `INSERT INTO jobs + (id, root_job_id, operation_id, risk_level, status, requested_by, request_id, + parameters_digest, created_at, updated_at) + VALUES (?, ?, 'network.register-auto', 'R1', ?, 'actor', 'request-1', + 'digest', ?, ?)`, + ).run(id, id, status, timestamp, timestamp); +} + +const exactProblem = ( + response: { headers: Record; json(): unknown }, + values: { title: string; status: number; code: string; detail: string }, +) => { + expect(response.headers['content-type']).toContain('application/problem+json'); + expect(response.headers['x-request-id']).toEqual(expect.any(String)); + expect(response.json()).toEqual({ + type: 'about:blank', + ...values, + requestId: response.headers['x-request-id'], + }); +}; + +describe('job HTTP read routes', () => { + it('serves active jobs without exposing internal nonterminal items', async () => { + const { app, db } = fixture(); + insertJob(db, 'job-1', 'running'); + db.prepare( + `INSERT INTO job_items + (id, job_id, instance_id, attempt_number, status, created_at, updated_at) + VALUES ('item-pending', 'job-1', 'instance-1', 1, 'running', ?, ?)`, + ).run(timestamp, timestamp); + db.prepare( + `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) + VALUES ('attempt-1', 'job-1', 'running', ?, NULL, ?)`, + ).run(timestamp, timestamp); + + const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + status: 'running', + items: [], + attempts: [{ id: 'attempt-1', state: 'running', startedAt: timestamp }], + }); + await app.close(); + }); + + it('maps an overbound child collection to the stable redacted 503 problem', async () => { + const { app, db } = fixture(); + insertJob(db); + const insert = db.prepare( + `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) + VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`, + ); + db.transaction(() => { + for (let index = 1; index <= 1001; index += 1) + insert.run(`attempt-${index}`, timestamp, timestamp, timestamp); + })(); + + const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); + expect(response.statusCode).toBe(503); + exactProblem(response, { + title: 'Service Unavailable', + status: 503, + code: 'UNREPRESENTABLE', + detail: 'The requested job data is temporarily unavailable.', + }); + expect(response.body).not.toContain('1000'); + await app.close(); + }); + + it('lists and gets jobs and supplies the documented list defaults', async () => { + const { app, db, jobs } = fixture(); + insertJob(db); + const listCall = vi.spyOn(jobs, 'list'); + + const list = await app.inject({ method: 'GET', url: '/api/v1/jobs' }); + expect(list.statusCode).toBe(200); + expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' }); + expect(list.json()).toEqual({ + items: [ + { + id: 'job-1', + operationId: 'network.register-auto', + status: 'succeeded', + rootJobId: 'job-1', + items: [], + attempts: [], + createdAt: timestamp, + }, + ], + page: { page: 1, pageSize: 25, total: 1 }, + }); + + const get = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); + expect(get.statusCode).toBe(200); + expect(get.json()).toMatchObject({ id: 'job-1', status: 'succeeded' }); + await app.close(); + }); + + it('parses every allowed scalar query exactly', async () => { + const { app, jobs } = fixture(); + const listCall = vi.spyOn(jobs, 'list'); + const response = await app.inject({ + method: 'GET', + url: '/api/v1/jobs?page=2&pageSize=10&sort=operationId&direction=desc&status=failed&operationId=op.one&rootJobId=root-1&instanceId=instance-1', + }); + expect(response.statusCode).toBe(200); + expect(listCall).toHaveBeenCalledWith({ + page: 2, + pageSize: 10, + sort: 'operationId', + direction: 'desc', + status: 'failed', + operationId: 'op.one', + rootJobId: 'root-1', + instanceId: 'instance-1', + }); + await app.close(); + }); + + it.each([ + 'unknown=value', + 'page=1&page=2', + 'status=failed&status=succeeded', + 'page=0', + 'page=01', + 'page=1.5', + 'page=9007199254740992', + 'pageSize=0', + 'pageSize=101', + 'sort=', + 'sort=id', + 'direction=ASC', + 'status=complete', + 'status=', + 'operationId=', + 'rootJobId=', + 'instanceId=', + ])('rejects invalid, unknown, empty, or repeated query input: %s', async (query) => { + const { app } = fixture(); + const response = await app.inject({ method: 'GET', url: `/api/v1/jobs?${query}` }); + expect(response.statusCode).toBe(400); + exactProblem(response, { + title: 'Bad Request', + status: 400, + code: 'VALIDATION_FAILED', + detail: 'The jobs query is invalid.', + }); + await app.close(); + }); + + it('maps a missing job to an exact redacted 404 problem', async () => { + const { app } = fixture(); + const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/missing' }); + expect(response.statusCode).toBe(404); + exactProblem(response, { + title: 'Not Found', + status: 404, + code: 'NOT_FOUND', + detail: 'The requested job does not exist.', + }); + await app.close(); + }); + + it('maps unrepresentable persisted data to an exact stable redacted 503 problem', async () => { + const { app, db } = fixture(); + insertJob(db); + db.prepare(`UPDATE jobs SET created_at = 'not-a-secret-but-invalid' WHERE id = 'job-1'`).run(); + const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); + expect(response.statusCode).toBe(503); + exactProblem(response, { + title: 'Service Unavailable', + status: 503, + code: 'UNREPRESENTABLE', + detail: 'The requested job data is temporarily unavailable.', + }); + expect(response.body).not.toContain('not-a-secret-but-invalid'); + expect(response.body).not.toContain('canonical'); + await app.close(); + }); +}); diff --git a/apps/api/src/interface/http/job-routes.ts b/apps/api/src/interface/http/job-routes.ts new file mode 100644 index 0000000..0b3d710 --- /dev/null +++ b/apps/api/src/interface/http/job-routes.ts @@ -0,0 +1,133 @@ +import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js'; + +export interface JobRoutesOptions { + readonly jobs: JobQueryService; +} + +const QUERY_KEYS = new Set([ + 'page', + 'pageSize', + 'sort', + 'direction', + 'status', + 'operationId', + 'rootJobId', + 'instanceId', +]); + +const errorResponse = { + VALIDATION_FAILED: { + status: 400, + title: 'Bad Request', + detail: 'The jobs query is invalid.', + }, + NOT_FOUND: { + status: 404, + title: 'Not Found', + detail: 'The requested job does not exist.', + }, + UNREPRESENTABLE: { + status: 503, + title: 'Service Unavailable', + detail: 'The requested job data is temporarily unavailable.', + }, +} as const; + +function invalid(): never { + throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query'); +} + +function parseQuery(query: unknown): JobPageQuery { + if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid(); + const input = query as Record; + for (const key of Object.keys(input)) { + if (!QUERY_KEYS.has(key)) invalid(); + if (typeof input[key] !== 'string') invalid(); + } + const scalar = (key: string): string | undefined => { + const value = input[key]; + return value === undefined ? undefined : (value as string); + }; + const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => { + const value = scalar(key); + if (value === undefined) return fallback; + if (!/^[1-9]\d*$/u.test(value)) invalid(); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid(); + return parsed; + }; + + const pageSize = integer('pageSize', 25, 100); + const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1; + const result: { + page: number; + pageSize: number; + sort?: 'createdAt' | 'status' | 'operationId'; + direction: 'asc' | 'desc'; + status?: JobStatus; + operationId?: string; + rootJobId?: string; + instanceId?: string; + } = { + page: integer('page', 1, maximumPage), + pageSize, + direction: 'asc', + }; + + const sort = scalar('sort'); + if (sort !== undefined) { + if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid(); + result.sort = sort; + } + const direction = scalar('direction'); + if (direction !== undefined) { + if (direction !== 'asc' && direction !== 'desc') invalid(); + result.direction = direction; + } + const status = scalar('status'); + if (status !== undefined) { + if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid(); + result.status = status as JobStatus; + } + for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) { + const value = scalar(key); + if (value !== undefined) { + if (value.length === 0) invalid(); + result[key] = value; + } + } + return result; +} + +export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void { + const handler = + (action: (request: FastifyRequest) => T) => + async (request: FastifyRequest, reply: FastifyReply) => { + try { + return action(request); + } catch (error) { + if (!(error instanceof JobQueryError)) throw error; + const mapped = errorResponse[error.code]; + return reply.code(mapped.status).type('application/problem+json').send({ + type: 'about:blank', + title: mapped.title, + status: mapped.status, + code: error.code, + detail: mapped.detail, + requestId: request.id, + }); + } + }; + + app.get( + '/api/v1/jobs', + handler((request) => options.jobs.list(parseQuery(request.query))), + ); + app.get( + '/api/v1/jobs/:jobId', + handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)), + ); +}