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; items?: Schema; enum: string[]; required: string[]; oneOf: Schema[]; description?: string; [key: string]: unknown; }; type Operation = { operationId?: string; responses?: Record< string, { headers?: Record; content?: Record } >; parameters?: Array<{ $ref?: string }>; requestBody?: { content?: Record }; }; type Document = { openapi: string; paths: Record>; components: { schemas: Record; responses: Record; parameters: Record; }; }; 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)?.[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); }); });