147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { readFileSync } from 'fs';
|
||
|
|
import { join, dirname } from 'path';
|
||
|
|
import { fileURLToPath } from 'url';
|
||
|
|
|
||
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const specPath = join(here, '..', 'openapi.yaml');
|
||
|
|
const spec = Bun.YAML.parse(readFileSync(specPath, 'utf-8')) as Record<string, unknown>;
|
||
|
|
|
||
|
|
describe('openapi.yaml lint', () => {
|
||
|
|
test('declares OpenAPI 3.1', () => {
|
||
|
|
expect(typeof spec.openapi).toBe('string');
|
||
|
|
expect(String(spec.openapi).startsWith('3.1')).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('has the required top-level fields', () => {
|
||
|
|
expect(spec).toHaveProperty('info');
|
||
|
|
expect(spec).toHaveProperty('paths');
|
||
|
|
expect(spec).toHaveProperty('components');
|
||
|
|
const info = spec.info as Record<string, unknown>;
|
||
|
|
expect(typeof info.title).toBe('string');
|
||
|
|
expect(typeof info.version).toBe('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('every operation has summary + responses', () => {
|
||
|
|
const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
|
||
|
|
const httpMethods = new Set([
|
||
|
|
'get',
|
||
|
|
'post',
|
||
|
|
'put',
|
||
|
|
'patch',
|
||
|
|
'delete',
|
||
|
|
'options',
|
||
|
|
'head',
|
||
|
|
]);
|
||
|
|
|
||
|
|
for (const [routePath, route] of Object.entries(paths)) {
|
||
|
|
for (const [verb, op] of Object.entries(route)) {
|
||
|
|
if (!httpMethods.has(verb)) continue;
|
||
|
|
const operation = op as Record<string, unknown>;
|
||
|
|
expect(operation.summary, `${verb.toUpperCase()} ${routePath} missing summary`).toBeDefined();
|
||
|
|
const responses = operation.responses as Record<string, unknown> | undefined;
|
||
|
|
expect(responses, `${verb.toUpperCase()} ${routePath} missing responses`).toBeDefined();
|
||
|
|
expect(
|
||
|
|
Object.keys(responses ?? {}).length,
|
||
|
|
`${verb.toUpperCase()} ${routePath} has empty responses`,
|
||
|
|
).toBeGreaterThan(0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('every $ref resolves to a defined component', () => {
|
||
|
|
const refs = collectRefs(spec);
|
||
|
|
expect(refs.length).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
for (const ref of refs) {
|
||
|
|
expect(
|
||
|
|
ref.startsWith('#/'),
|
||
|
|
`non-internal $ref not allowed: ${ref}`,
|
||
|
|
).toBe(true);
|
||
|
|
|
||
|
|
const segments = ref.slice(2).split('/');
|
||
|
|
let cursor: unknown = spec;
|
||
|
|
for (const segment of segments) {
|
||
|
|
expect(
|
||
|
|
isObject(cursor),
|
||
|
|
`dangling $ref: ${ref} (failed at "${segment}")`,
|
||
|
|
).toBe(true);
|
||
|
|
cursor = (cursor as Record<string, unknown>)[segment];
|
||
|
|
}
|
||
|
|
expect(cursor, `unresolved $ref: ${ref}`).toBeDefined();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('every security requirement references a defined scheme', () => {
|
||
|
|
const schemes = (
|
||
|
|
((spec.components ?? {}) as Record<string, Record<string, unknown>>)
|
||
|
|
.securitySchemes ?? {}
|
||
|
|
) as Record<string, unknown>;
|
||
|
|
const definedSchemes = new Set(Object.keys(schemes));
|
||
|
|
|
||
|
|
const requirements = collectSecurityRequirements(spec);
|
||
|
|
for (const name of requirements) {
|
||
|
|
expect(
|
||
|
|
definedSchemes.has(name),
|
||
|
|
`security requirement "${name}" not defined under components.securitySchemes`,
|
||
|
|
).toBe(true);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('declares the V3.1 transfer surface', () => {
|
||
|
|
const paths = (spec.paths ?? {}) as Record<string, unknown>;
|
||
|
|
expect(paths['/v1/transfer/health']).toBeDefined();
|
||
|
|
expect(paths['/v1/transfer/{streamId}/chunk']).toBeDefined();
|
||
|
|
expect(paths['/v1/transfer/{streamId}/state']).toBeDefined();
|
||
|
|
expect(paths['/v1/transfer/control']).toBeDefined();
|
||
|
|
|
||
|
|
const schemes = (
|
||
|
|
((spec.components ?? {}) as Record<string, Record<string, unknown>>)
|
||
|
|
.securitySchemes ?? {}
|
||
|
|
) as Record<string, unknown>;
|
||
|
|
expect(schemes.ShadeTransferAuthenticator).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function collectRefs(node: unknown, out: string[] = []): string[] {
|
||
|
|
if (Array.isArray(node)) {
|
||
|
|
for (const item of node) collectRefs(item, out);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
if (!isObject(node)) return out;
|
||
|
|
for (const [key, value] of Object.entries(node)) {
|
||
|
|
if (key === '$ref' && typeof value === 'string') {
|
||
|
|
out.push(value);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
collectRefs(value, out);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function collectSecurityRequirements(spec: Record<string, unknown>): Set<string> {
|
||
|
|
const names = new Set<string>();
|
||
|
|
const collectFromList = (list: unknown): void => {
|
||
|
|
if (!Array.isArray(list)) return;
|
||
|
|
for (const entry of list) {
|
||
|
|
if (!isObject(entry)) continue;
|
||
|
|
for (const name of Object.keys(entry)) names.add(name);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
collectFromList(spec.security);
|
||
|
|
|
||
|
|
const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
|
||
|
|
for (const route of Object.values(paths)) {
|
||
|
|
for (const value of Object.values(route)) {
|
||
|
|
if (!isObject(value)) continue;
|
||
|
|
collectFromList(value.security);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return names;
|
||
|
|
}
|