Files
multi-simadmin/packages/contracts/src/api-v1.contract.test.ts
T

224 lines
9.2 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
API_V1_PREFIX,
CAPABILITY_STATUSES,
FRESHNESS_STATUSES,
JOB_ITEM_TERMINAL_STATES,
JOB_STATUSES,
MAX_PAGE_SIZE,
RISK_LEVELS,
} from './index.js';
type Schema = {
$ref?: string;
properties: Record<string, Schema>;
items?: Schema;
enum: string[];
required: string[];
oneOf: Schema[];
description?: string;
[key: string]: unknown;
};
type Operation = {
operationId?: string;
responses?: Record<
string,
{ headers?: Record<string, unknown>; content?: Record<string, { schema?: Schema }> }
>;
parameters?: Array<{ $ref?: string }>;
requestBody?: { content?: Record<string, { schema?: Schema }> };
};
type Document = {
openapi: string;
paths: Record<string, Record<string, Operation>>;
components: {
schemas: Record<string, Schema>;
responses: Record<string, unknown>;
parameters: Record<string, Schema>;
};
};
const document = JSON.parse(
readFileSync(
fileURLToPath(new URL('../../../openapi/multi-simadmin.v1.json', import.meta.url)),
'utf8',
),
) as Document;
const methods = new Set(['get', 'post', 'put', 'patch', 'delete']);
const operations = Object.entries(document.paths).flatMap(([path, item]) =>
Object.entries(item)
.filter(([method]) => methods.has(method))
.map(([method, operation]) => ({ path, method, operation })),
);
function refs(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap(refs);
if (!value || typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, child]) =>
key === '$ref' ? [String(child)] : refs(child),
);
}
function responseSchema(operation: Operation, status: string): Schema {
const response = operation.responses?.[status];
expect(response).toBeDefined();
return (response?.content?.['application/json']?.schema ?? {}) as Schema;
}
describe('control-plane API v1 OpenAPI contract', () => {
it('is OpenAPI 3.1 with only /api/v1 paths, unique operationIds and no generic proxy', () => {
expect(document.openapi).toMatch(/^3\.1\./);
expect(operations.length).toBeGreaterThanOrEqual(18);
expect(operations.every(({ path }) => path.startsWith(API_V1_PREFIX))).toBe(true);
const ids = operations.map(({ operation }) => operation.operationId);
expect(ids.every(Boolean)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
expect(
Object.keys(document.paths).some((path) => /proxy|workbench|\{method\}|\{path\}/i.test(path)),
).toBe(false);
});
it('has the IA resource/action boundary', () => {
const required = [
'GET /api/v1/instances',
'POST /api/v1/instances',
'GET /api/v1/instances/{instanceId}',
'PATCH /api/v1/instances/{instanceId}',
'DELETE /api/v1/instances/{instanceId}',
'POST /api/v1/instances/{instanceId}/test-connection',
'POST /api/v1/instances/{instanceId}/login',
'POST /api/v1/instances/{instanceId}/logout',
'GET /api/v1/operations',
'POST /api/v1/operations/prepare',
'POST /api/v1/operations/execute',
'GET /api/v1/jobs',
'GET /api/v1/jobs/{jobId}',
'POST /api/v1/jobs/{jobId}/cancel',
'POST /api/v1/jobs/{jobId}/retry',
'GET /api/v1/audit',
'GET /api/v1/audit/{eventId}',
'GET /api/v1/events',
];
const actual = new Set(operations.map(({ method, path }) => `${method.toUpperCase()} ${path}`));
expect(required.every((endpoint) => actual.has(endpoint))).toBe(true);
expect(
document.paths['/api/v1/events']?.get?.responses?.['200']?.content?.['text/event-stream'],
).toBeDefined();
expect(document.paths['/api/v1/operations/execute']?.post?.responses?.['202']).toBeDefined();
});
it('resolves every local component ref', () => {
for (const ref of refs(document)) {
expect(ref.startsWith('#/components/')).toBe(true);
const segments = ref.slice(2).split('/');
let cursor: unknown = document;
for (const segment of segments) cursor = (cursor as Record<string, unknown>)?.[segment];
expect(cursor, `unresolved ${ref}`).toBeDefined();
}
});
it('gives every operation success plus reusable Problem Details errors and request IDs', () => {
const problem = document.components.schemas.ProblemDetails!;
expect(problem.required).toEqual(
expect.arrayContaining(['type', 'title', 'status', 'detail', 'code', 'requestId']),
);
expect(document.components.responses.PreconditionFailed).toBeDefined();
for (const { operation } of operations) {
const statuses = Object.keys(operation.responses ?? {});
expect(statuses.some((status) => /^2\d\d$/.test(status))).toBe(true);
expect(statuses.some((status) => /^4\d\d$|^5\d\d$|default/.test(status))).toBe(true);
for (const [status, response] of Object.entries(operation.responses ?? {})) {
if (/^2\d\d$/.test(status)) expect(response.headers?.['X-Request-Id']).toBeDefined();
if (/^4\d\d$|^5\d\d$|default/.test(status)) {
if ('$ref' in response)
expect(String(response.$ref)).toMatch(/^#\/components\/responses\//);
else expect(response.content?.['application/problem+json']).toBeDefined();
}
}
}
});
it('keeps TS and OpenAPI enums/pagination/revisions aligned', () => {
expect(document.components.schemas.CapabilityStatus!.enum).toEqual(CAPABILITY_STATUSES);
expect(document.components.schemas.SnapshotFreshness!.enum).toEqual(FRESHNESS_STATUSES);
expect(document.components.schemas.JobStatus!.enum).toEqual(JOB_STATUSES);
expect(document.components.schemas.JobItemTerminalState!.enum).toEqual(
JOB_ITEM_TERMINAL_STATES,
);
expect(document.components.schemas.RiskLevel!.enum).toEqual(RISK_LEVELS);
expect(document.components.parameters.PageSize!.schema).toMatchObject({
maximum: MAX_PAGE_SIZE,
minimum: 1,
});
expect(document.components.schemas.Revision).toMatchObject({ type: 'integer', minimum: 1 });
for (const path of ['/api/v1/instances/{instanceId}']) {
for (const method of ['patch', 'delete'])
expect(document.paths[path]?.[method]?.parameters).toContainEqual({
$ref: '#/components/parameters/IfMatch',
});
}
});
it('models ETags, confirmation, partial jobs and immutable retry lineage', () => {
for (const [path, method, status] of [
['/api/v1/instances', 'post', '201'],
['/api/v1/instances/{instanceId}', 'get', '200'],
['/api/v1/instances/{instanceId}', 'patch', '200'],
] as const) {
expect(document.paths[path]?.[method]?.responses?.[status]?.headers?.ETag).toBeDefined();
}
const execute = document.components.schemas.ExecuteOperationRequest!;
expect(execute.required).toEqual(
expect.arrayContaining(['preparationId', 'confirmationToken']),
);
const job = document.components.schemas.Job!;
expect(String(job.description)).toMatch(/terminal.*immutable|immutable.*terminal/i);
expect(job.properties).toHaveProperty('retryOfJobId');
expect(job.properties).toHaveProperty('rootJobId');
expect(job.properties).not.toHaveProperty('parentJobId');
expect(document.components.schemas.JobItem!.properties).toHaveProperty('error');
expect(String(document.components.schemas.JobItem!.description)).toMatch(/independent/i);
expect(document.components.schemas.JobStatus!.enum).toContain('partially-succeeded');
expect(document.components.schemas.JobStatus!.enum).toContain('unknown-result');
expect(document.components.schemas.AttemptStatus!.enum).not.toContain('queued');
expect(String(document.components.schemas.Attempt!.description)).toMatch(
/terminal.*immutable|immutable.*terminal/i,
);
});
it('keeps password input-only and explicitly allows only the prepare confirmation credential in success responses', () => {
const update = document.components.schemas.PasswordUpdate!;
expect(update.oneOf).toHaveLength(3);
expect(JSON.stringify(update)).toContain('preserve');
expect(JSON.stringify(update)).toContain('set');
expect(JSON.stringify(update)).toContain('clear');
expect(JSON.stringify(update)).toContain('writeOnly');
for (const { operation } of operations) {
for (const status of Object.keys(operation.responses ?? {}).filter((value) =>
/^2\d\d$/.test(value),
)) {
const serialized = JSON.stringify(responseSchema(operation, status));
if (operation.operationId === 'prepareOperation') {
expect(serialized).toContain('Preparation');
} else {
expect(serialized).not.toMatch(/password|token|cookie|secret/i);
}
}
}
});
it('binds destructive deletion to prepare confirmation and defines resumable typed SSE', () => {
const deletion = document.paths['/api/v1/instances/{instanceId}']?.delete;
expect(deletion?.parameters).toEqual(
expect.arrayContaining([
{ $ref: '#/components/parameters/PreparationId' },
{ $ref: '#/components/parameters/ConfirmationToken' },
]),
);
expect(document.paths['/api/v1/events']?.get?.parameters).toContainEqual({
$ref: '#/components/parameters/LastEventId',
});
expect(document.components.schemas.EventEnvelope!.oneOf).toHaveLength(5);
});
});