/** * Schema-driven conformance suite: validates the worker's actual responses * against the contract defined in docs/api/worker.openapi.json (OpenAPI 3.1, * JSON Schema 2020-12 dialect). Contract drift becomes a failing test instead * of a review finding. * * Coverage: * 1. Compile all schemas with Ajv 2020 + ajv-formats; assert compilation succeeds. * 2. Test endpoints with required status codes and response bodies against schemas. * 3. Reverse check: the set of response keys documented for every operation * (including `default`, 413, 415) equals the set this suite exercises — * read from the contract, not a hand-kept list. * 4. Validate problem+json content type and that 5xx bodies leak no upstream details. * * [source: brief contract.test.ts v1, verified 2026-09-14] * [source: context7 /fastify/fastify docs/Reference/Errors.md FST_ERR_CTP_BODY_TOO_LARGE (413) / FST_ERR_CTP_INVALID_MEDIA_TYPE (415); docs/Reference/Server.md `bodyLimit`, verified 2026-09-14] */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import Ajv from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; import type { FastifyInstance } from 'fastify'; import { fileURLToPath } from 'node:url'; import { ArenaApiError, ArenaSessionManager } from '../src/arena/session.js'; import { loadConfig } from '../src/config.js'; import { HealthRegistry } from '../src/health/registry.js'; import type { HttpClient, HttpResponse } from '../src/http/types.js'; import { InMemoryEventQueue } from '../src/queue/webhookQueue.js'; import { buildServer } from '../src/server.js'; import { makeNoopExecutors } from '../src/sync/executors.js'; import { InMemoryRunStore } from '../src/sync/runStore.js'; import { SyncRunner } from '../src/sync/runner.js'; import type { SyncRun } from '../src/sync/types.js'; const TOKEN = 'test-token'; const AUTH = { authorization: `Bearer ${TOKEN}` }; // Load OpenAPI contract const __dirname = resolve(fileURLToPath(import.meta.url), '..'); const contractPath = resolve(__dirname, '../../docs/api/worker.openapi.json'); const contract = JSON.parse(readFileSync(contractPath, 'utf-8')); // Compile all schemas with Ajv 2020 const ajv = new Ajv({ strict: true }); addFormats(ajv); interface ValidatorMap { [schemaName: string]: (data: unknown) => boolean; } const schemas: ValidatorMap = {}; let apps: FastifyInstance[] = []; function makeApp(envOverrides: Record = {}, serverOverrides: { bodyLimit?: number } = {}) { const config = loadConfig({ WORKER_BEARER_TOKEN: TOKEN, ...envOverrides }); const health = new HealthRegistry(); const queue = new InMemoryEventQueue({ maxSize: config.webhookQueueMaxSize }); const runStore = new InMemoryRunStore(); const runner = new SyncRunner(runStore, makeNoopExecutors()); const request = vi.fn().mockRejectedValue(new Error('no network calls in tests')); const clock = { nowMs: 1_000_000 }; const arenaSession = new ArenaSessionManager({ http: { request }, baseUrl: 'https://arena.invalid/v1', email: 'worker@example.test', password: 'secret', sessionTtlMs: config.arena.sessionTtlMs, now: () => clock.nowMs, health, }); const app = buildServer({ config, queue, runner, runStore, arenaSession, health, ...serverOverrides }); apps.push(app); return { app, queue, runStore, runner, arenaSession, health, request, clock, config }; } function loginOk(sessionId: string): HttpResponse { return { status: 200, body: { arenaSessionId: sessionId } }; } async function getHealth(app: FastifyInstance) { const res = await app.inject({ method: 'GET', url: '/health' }); expect(res.statusCode).toBe(200); return res.json(); } function validateAgainstSchema( data: unknown, schemaName: string, context: string, ): { valid: boolean; error?: string } { const validate = schemas[schemaName]; if (!validate) { return { valid: false, error: `Schema '${schemaName}' not compiled` }; } const valid = validate(data); if (!valid) { const errors = (validate as { errors?: unknown }).errors; return { valid: false, error: `Validation failed for ${schemaName} at ${context}: ${JSON.stringify(errors)}`, }; } return { valid: true }; } /** A JSON object comfortably over a 64-byte body limit. */ const OVERSIZED_JSON = JSON.stringify({ scope: 'full', pad: 'x'.repeat(512) }); const SMALL_BODY_LIMIT = 64; async function expectProblemStatus( res: { statusCode: number; headers: Record; json: () => unknown; body: string }, status: number, title: string, context: string, ) { expect(res.statusCode).toBe(status); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json() as { title: string; status: number }; expect(body.title).toBe(title); expect(body.status).toBe(status); const validation = validateAgainstSchema(body, 'Problem', context); expect(validation.valid, validation.error).toBe(true); } afterEach(async () => { await Promise.all(apps.map((app) => app.close())); apps = []; }); describe('OpenAPI contract conformance', () => { beforeAll(() => { // Compile all schemas from components.schemas. // Register the entire contract with Ajv so cross-schema references (#/components/schemas/*) // can be resolved. if (!contract.components?.schemas) { throw new Error('No schemas found in contract'); } // Add the contract's schemas to Ajv's schema store for reference resolution for (const [schemaName, schemaObj] of Object.entries(contract.components.schemas)) { ajv.addSchema(schemaObj, `#/components/schemas/${schemaName}`); } // Now compile each schema (references will resolve) for (const [schemaName, schemaObj] of Object.entries(contract.components.schemas)) { try { const validate = ajv.compile(schemaObj); schemas[schemaName] = validate; } catch (err) { throw new Error(`Failed to compile schema '${schemaName}': ${err instanceof Error ? err.message : String(err)}`, { cause: err, }); } } }); describe('1. Schema compilation succeeds for all components.schemas', () => { it('all schemas compile without error (JSON Schema 2020-12 + ajv-formats)', () => { expect(Object.keys(schemas).length).toBeGreaterThan(0); expect(schemas).toHaveProperty('HealthStatus'); expect(schemas).toHaveProperty('ArenaWebhookEvent'); expect(schemas).toHaveProperty('WebhookAccepted'); expect(schemas).toHaveProperty('SyncRunRequest'); expect(schemas).toHaveProperty('SyncRun'); expect(schemas).toHaveProperty('SyncObjectProgress'); expect(schemas).toHaveProperty('Problem'); }); it('8 core schemas are compiled', () => { expect(Object.keys(schemas).length).toBe(8); expect(schemas).toHaveProperty('SyncObjectType'); }); }); describe('2. GET /health — all states validated against HealthStatus schema', () => { it('state="none" before any login', async () => { const { app } = makeApp(); const body = await getHealth(app); expect(body.arenaSession.state).toBe('none'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=none)'); expect(validation.valid).toBe(true); }); it('state="active" while cached session is within TTL', async () => { const { app, arenaSession, request } = makeApp(); request.mockResolvedValueOnce(loginOk('sess-1')); await arenaSession.getSessionId(); const body = await getHealth(app); expect(body.arenaSession.state).toBe('active'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=active)'); expect(validation.valid).toBe(true); }); it('state="expired" once cached session is past TTL', async () => { const { app, arenaSession, request, clock, config } = makeApp(); request.mockResolvedValueOnce(loginOk('sess-1')); await arenaSession.getSessionId(); clock.nowMs += config.arena.sessionTtlMs; const body = await getHealth(app); expect(body.arenaSession.state).toBe('expired'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=expired)'); expect(validation.valid).toBe(true); }); it('state="refreshing" while a login is in flight', async () => { const { app, arenaSession, request } = makeApp(); let resolveLogin!: (res: HttpResponse) => void; request.mockImplementationOnce( () => new Promise((resolve) => { resolveLogin = resolve; }), ); const pending = arenaSession.getSessionId(); const body = await getHealth(app); expect(body.arenaSession.state).toBe('refreshing'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=refreshing)'); expect(validation.valid).toBe(true); resolveLogin(loginOk('sess-1')); await pending; }); it('status="ok" when operational', async () => { const { app, arenaSession, request } = makeApp(); request.mockResolvedValueOnce(loginOk('sess-1')); await arenaSession.getSessionId(); const body = await getHealth(app); expect(body.status).toBe('ok'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=ok)'); expect(validation.valid).toBe(true); }); it('status="degraded" after failed Arena login', async () => { const { app, arenaSession, request } = makeApp(); request.mockResolvedValueOnce({ status: 503, body: 'down' }); await expect(arenaSession.getSessionId()).rejects.toBeInstanceOf(ArenaApiError); const body = await getHealth(app); expect(body.status).toBe('degraded'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=degraded)'); expect(validation.valid).toBe(true); }); it('status="degraded" after failed poll tick', async () => { const { app, health } = makeApp(); health.recordPollFailure(new Date('2026-09-14T08:15:00Z'), new Error('Arena login failed with HTTP 503')); const body = await getHealth(app); expect(body.status).toBe('degraded'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=degraded from poll)'); expect(validation.valid).toBe(true); }); it('poller.lastError is a fixed phrase: an ArenaApiError message/body marker never appears in /health (B5)', async () => { const { app, health } = makeApp(); health.recordPollFailure( new Date('2026-09-14T08:15:00Z'), new ArenaApiError('Arena login failed with HTTP 503 MARKER-MESSAGE', 503, { errors: [{ message: 'MARKER-BODY worker@example.test' }], }), ); const res = await app.inject({ method: 'GET', url: '/health' }); expect(res.statusCode).toBe(200); expect(res.body).not.toContain('MARKER-MESSAGE'); expect(res.body).not.toContain('MARKER-BODY'); expect(res.body).not.toContain('worker@example.test'); const body = res.json(); expect(body.poller.lastError).toBe('Upstream Arena PLM request failed (HTTP 503).'); const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (B5 lastError)'); expect(validation.valid).toBe(true); }); it('default (unmatched method on /health) is 404 problem+json', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'DELETE', url: '/health' }); await expectProblemStatus(res, 404, 'Not Found', 'DELETE /health (default: unmatched method)'); }); }); describe('2. POST /webhooks/arena — all documented response codes with body validation', () => { it('202 Accepted with WebhookAccepted body', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { eventType: 'ITEM_MODIFIED', itemNumber: '900-00042' }, }); expect(res.statusCode).toBe(202); expect(res.headers['content-type']).toContain('application/json'); const body = res.json(); const validation = validateAgainstSchema(body, 'WebhookAccepted', 'POST /webhooks/arena (202)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (invalid JSON)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'application/json' }, payload: '{invalid-json', }); expect(res.statusCode).toBe(400); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (400 invalid JSON)'); expect(validation.valid).toBe(true); expect(body.title).toBe('Bad Request'); }); it('400 Bad Request (non-object body)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'application/json' }, payload: '[1,2,3]', }); expect(res.statusCode).toBe(400); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (400 non-object)'); expect(validation.valid).toBe(true); }); it('401 Unauthorized (missing bearer token)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', payload: { event: 'ITEM.UPDATED' }, }); expect(res.statusCode).toBe(401); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (401)'); expect(validation.valid).toBe(true); expect(body.title).toBe('Unauthorized'); }); it('401 Unauthorized (wrong bearer token)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { authorization: 'Bearer wrong-token' }, payload: { event: 'ITEM.UPDATED' }, }); expect(res.statusCode).toBe(401); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (401 wrong token)'); expect(validation.valid).toBe(true); }); it('503 Service Unavailable (queue full) with Retry-After header', async () => { const { app } = makeApp({ WEBHOOK_QUEUE_MAX_SIZE: '1' }); // Fill queue await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 1 } }); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 2 }, }); expect(res.statusCode).toBe(503); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); expect(res.headers['retry-after']).toBe('30'); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (503)'); expect(validation.valid).toBe(true); expect(body.title).toBe('Service Unavailable'); }); it('415 Unsupported Media Type (text/plain body — built-in parser removed)', async () => { const { app, queue } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'text/plain' }, payload: 'eventType=ITEM_MODIFIED', }); await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /webhooks/arena (415 text/plain)'); expect(queue.size).toBe(0); }); it('415 Unsupported Media Type (application/xml body)', async () => { const { app, queue } = makeApp(); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'application/xml' }, payload: '', }); await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /webhooks/arena (415 xml)'); expect(queue.size).toBe(0); }); it('413 Payload Too Large (body over bodyLimit) — nothing queued', async () => { const { app, queue } = makeApp({}, { bodyLimit: SMALL_BODY_LIMIT }); const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'application/json' }, payload: OVERSIZED_JSON, }); await expectProblemStatus(res, 413, 'Payload Too Large', 'POST /webhooks/arena (413)'); expect(queue.size).toBe(0); }); it('default (unmatched method on /webhooks/arena) is 404 problem+json', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'GET', url: '/webhooks/arena', headers: AUTH }); await expectProblemStatus(res, 404, 'Not Found', 'GET /webhooks/arena (default: unmatched method)'); }); }); describe('2. POST /admin/sync-runs — all documented response codes with body validation', () => { it('201 Created with SyncRun body (full scope, default objects order)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full' }, }); expect(res.statusCode).toBe(201); expect(res.headers['content-type']).toContain('application/json'); const body = res.json(); const validation = validateAgainstSchema(body, 'SyncRun', 'POST /admin/sync-runs (201)'); expect(validation.valid).toBe(true); expect(body.scope).toBe('full'); expect(body.state).toBe('queued'); }); it('201 Created with custom objects order', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'delta', objects: ['BOM', 'PRODUCT'] }, }); expect(res.statusCode).toBe(201); const body = res.json(); const validation = validateAgainstSchema(body, 'SyncRun', 'POST /admin/sync-runs (201 custom order)'); expect(validation.valid).toBe(true); expect(body.objects.map((o: { objectType: string }) => o.objectType)).toEqual(['BOM', 'PRODUCT']); }); it('400 Bad Request (missing scope)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: {}, }); expect(res.statusCode).toBe(400); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 missing scope)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (invalid scope)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'bogus' }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 invalid scope)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (empty objects array)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full', objects: [] }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 empty objects)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (duplicate objects)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full', objects: ['BOM', 'BOM'] }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 duplicates)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (unknown object type)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full', objects: ['WIDGET'] }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 unknown type)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (unknown property additionalProperties: false)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full', unknownProp: 'x' }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 extra property)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (no type coercion: {"scope":["full"]})', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: ['full'] }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 scope as array)'); expect(validation.valid).toBe(true); }); it('400 Bad Request (no type coercion: {"objects":"BOM"})', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full', objects: 'BOM' }, }); expect(res.statusCode).toBe(400); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 objects as scalar)'); expect(validation.valid).toBe(true); }); it('401 Unauthorized (missing bearer token)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', payload: { scope: 'full' }, }); expect(res.statusCode).toBe(401); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (401)'); expect(validation.valid).toBe(true); }); it('415 Unsupported Media Type (text/plain body) — no run created', async () => { const { app, runner } = makeApp(); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: { ...AUTH, 'content-type': 'text/plain' }, payload: 'scope=full', }); await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /admin/sync-runs (415)'); expect(runner.inFlightCount).toBe(0); }); it('413 Payload Too Large (body over bodyLimit) — no run created', async () => { const { app, runner } = makeApp({}, { bodyLimit: SMALL_BODY_LIMIT }); const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: { ...AUTH, 'content-type': 'application/json' }, payload: OVERSIZED_JSON, }); await expectProblemStatus(res, 413, 'Payload Too Large', 'POST /admin/sync-runs (413)'); expect(runner.inFlightCount).toBe(0); }); it('default (unmatched method on /admin/sync-runs) is 404 problem+json', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'DELETE', url: '/admin/sync-runs', headers: AUTH }); await expectProblemStatus(res, 404, 'Not Found', 'DELETE /admin/sync-runs (default: unmatched method)'); }); }); describe('2. GET /admin/sync-runs/{runId} — all documented response codes with body validation', () => { it('200 OK with SyncRun body', async () => { const { app, runner } = makeApp(); const created = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'delta' }, }); const { runId } = created.json() as SyncRun; await runner.waitFor(runId); const res = await app.inject({ method: 'GET', url: `/admin/sync-runs/${runId}`, headers: AUTH, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/json'); const body = res.json(); const validation = validateAgainstSchema(body, 'SyncRun', 'GET /admin/sync-runs/{runId} (200)'); expect(validation.valid).toBe(true); expect(body.runId).toBe(runId); }); it('401 Unauthorized (missing bearer token)', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'GET', url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000', }); expect(res.statusCode).toBe(401); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'GET /admin/sync-runs/{runId} (401)'); expect(validation.valid).toBe(true); }); it('404 Not Found with Problem body', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'GET', url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000', headers: AUTH, }); expect(res.statusCode).toBe(404); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); const validation = validateAgainstSchema(body, 'Problem', 'GET /admin/sync-runs/{runId} (404)'); expect(validation.valid).toBe(true); expect(body.title).toBe('Not Found'); }); it('default (unmatched method on /admin/sync-runs/{runId}) is 404 problem+json', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'PUT', url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000', headers: AUTH, }); await expectProblemStatus(res, 404, 'Not Found', 'PUT /admin/sync-runs/{runId} (default: unmatched method)'); }); it('default (unknown path) is 404 problem+json — x-unmatched-routes', async () => { const { app } = makeApp(); const res = await app.inject({ method: 'GET', url: '/admin/nope' }); await expectProblemStatus(res, 404, 'Not Found', 'GET /admin/nope (x-unmatched-routes)'); expect(contract['x-unmatched-routes']).toMatch(/404/); }); }); describe('3. Reverse check: every documented response code is tested', () => { // The set of response keys THIS SUITE exercises per operation. The test // below compares it against the contract, so adding a code to the contract // without a test here fails, and so does a test-only code that is not // documented. `default` is exercised by the unmatched-method/path 404 // cases per operation plus the operation-independent 500 case in §4. const exercised: Record = { 'get /health': ['200', 'default'], 'post /webhooks/arena': ['202', '400', '401', '413', '415', '503', 'default'], 'post /admin/sync-runs': ['201', '400', '401', '413', '415', 'default'], 'get /admin/sync-runs/{runId}': ['200', '401', '404', 'default'], }; // Reserved (contract-defined, no live route emits it yet): tested at the // error-handler seam in §4, not attached to an operation. const reserved: Record = { BadGateway: '502' }; it('documented response keys per operation equal the exercised set (read from the contract)', () => { const documented: Record = {}; for (const [path, item] of Object.entries(contract.paths as Record>)) { for (const [method, op] of Object.entries(item)) { const responses = (op as { responses: Record }).responses; documented[`${method} ${path}`] = Object.keys(responses).sort(); } } const expected = Object.fromEntries(Object.entries(exercised).map(([k, v]) => [k, [...v].sort()])); expect(documented).toEqual(expected); }); it('every operation documents a `default` problem+json response and the reserved 502 exists', () => { for (const item of Object.values(contract.paths as Record>)) { for (const op of Object.values(item)) { const responses = (op as { responses: Record }).responses; expect(responses.default).toEqual({ $ref: '#/components/responses/Default' }); } } expect(contract.components.responses.Default.content).toHaveProperty('application/problem+json'); expect(contract.components.responses.BadGateway.content['application/problem+json'].example.status).toBe( Number(reserved.BadGateway), ); }); }); describe('4. problem+json and 5xx validation', () => { it('all 4xx/5xx bodies are application/problem+json (contract RFC 9457)', async () => { const { app } = makeApp(); // Test 400 (invalid JSON) const res400 = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: { ...AUTH, 'content-type': 'application/json' }, payload: '{invalid-json', }); expect(res400.statusCode).toBe(400); expect(res400.headers['content-type']).toMatch(/application\/problem\+json/); // Test 401 const res401 = await app.inject({ method: 'POST', url: '/webhooks/arena', payload: { event: 'test' }, }); expect(res401.statusCode).toBe(401); expect(res401.headers['content-type']).toMatch(/application\/problem\+json/); // Test 404 const res404 = await app.inject({ method: 'GET', url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000', headers: AUTH, }); expect(res404.statusCode).toBe(404); expect(res404.headers['content-type']).toMatch(/application\/problem\+json/); // Test 503 const { app: app503 } = makeApp({ WEBHOOK_QUEUE_MAX_SIZE: '1' }); await app503.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 1 } }); const res503 = await app503.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 2 }, }); expect(res503.statusCode).toBe(503); expect(res503.headers['content-type']).toMatch(/application\/problem\+json/); await app503.close(); }); it('5xx bodies contain no detail derived from upstream (ArenaApiError -> 502)', async () => { const { app } = makeApp(); // Throwaway test route to exercise ArenaApiError handling app.get('/__test/upstream', async () => { throw new ArenaApiError('Arena login failed with HTTP 500', 500, { errors: [{ message: 'LEAKED-UPSTREAM-BODY worker@example.test' }], }); }); const res = await app.inject({ method: 'GET', url: '/__test/upstream' }); expect(res.statusCode).toBe(502); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); expect(body.title).toBe('Bad Gateway'); expect(body.detail).toBe('Upstream Arena PLM request failed.'); expect(res.body).not.toContain('LEAKED-UPSTREAM-BODY'); expect(res.body).not.toContain('HTTP 500'); const validation = validateAgainstSchema(body, 'Problem', '502 ArenaApiError'); expect(validation.valid).toBe(true); }); it('other 5xx errors do not leak details', async () => { const { app } = makeApp(); app.get('/__test/boom', async () => { throw new Error('internal secret'); }); const res = await app.inject({ method: 'GET', url: '/__test/boom' }); expect(res.statusCode).toBe(500); expect(res.headers['content-type']).toMatch(/application\/problem\+json/); const body = res.json(); expect(body.title).toBe('Internal Server Error'); expect(res.body).not.toContain('internal secret'); const validation = validateAgainstSchema(body, 'Problem', '500 generic error'); expect(validation.valid).toBe(true); }); }); describe('Contract schema validation compliance', () => { it('ArenaWebhookEvent allows arbitrary properties (provisional schema)', async () => { const { app } = makeApp(); const payload = { eventType: 'ITEM_MODIFIED', itemNumber: '900-00042', unknownField: 'allowed', nestedUnknown: { foo: 'bar' }, }; const res = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload, }); expect(res.statusCode).toBe(202); const validation = validateAgainstSchema(payload, 'ArenaWebhookEvent', 'POST /webhooks/arena with extra properties'); expect(validation.valid).toBe(true); }); it('SyncObjectProgress validates with all states and optional fields', async () => { // The schema defines state: pending|running|completed|failed // and optional processed/error fields. // This is validated implicitly through SyncRun responses. const { app, runner } = makeApp(); const created = await app.inject({ method: 'POST', url: '/admin/sync-runs', headers: AUTH, payload: { scope: 'full' }, }); const { runId } = created.json() as SyncRun; await runner.waitFor(runId); const res = await app.inject({ method: 'GET', url: `/admin/sync-runs/${runId}`, headers: AUTH, }); const run = res.json() as SyncRun; // Verify each object has valid state for (const obj of run.objects) { expect(['pending', 'running', 'completed', 'failed']).toContain(obj.state); } const validation = validateAgainstSchema(run, 'SyncRun', 'SyncRun with SyncObjectProgress'); expect(validation.valid).toBe(true); }); }); });