Files
arena-plm-fiori/worker/test/contract.test.ts
T
2026-09-16 01:47:28 -07:00

893 lines
35 KiB
TypeScript

/**
* 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<string, string> = {}, 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<HttpClient['request']>().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<string, unknown>; 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<HttpResponse>((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: '<event/>',
});
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<string, string[]> = {
'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<string, string> = { BadGateway: '502' };
it('documented response keys per operation equal the exercised set (read from the contract)', () => {
const documented: Record<string, string[]> = {};
for (const [path, item] of Object.entries(contract.paths as Record<string, Record<string, unknown>>)) {
for (const [method, op] of Object.entries(item)) {
const responses = (op as { responses: Record<string, unknown> }).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<string, Record<string, unknown>>)) {
for (const op of Object.values(item)) {
const responses = (op as { responses: Record<string, { $ref?: string }> }).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);
});
});
});