893 lines
35 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|