224 lines
9.2 KiB
TypeScript
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);
|
|
});
|
|
});
|