feat(api): define v1 control-plane contract
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user