mirror of https://github.com/OpenVidu/openvidu.git
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
import request from 'supertest';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { Response } from 'supertest';
|
|
|
|
import type { ApiError as ApiErrorBody } from '@openvidu-dashboard/typings';
|
|
import * as z from 'zod';
|
|
|
|
import { docsRouter, openApiDocument } from '../../src/api/openapi.js';
|
|
import { csvParam, limitParam } from '../../src/api/query.js';
|
|
import { type Authenticator, type RouteDefinition, RouteRegistry } from '../../src/api/route-registry.js';
|
|
import { ApiError } from '../../src/http/api-error.js';
|
|
import { createHttpApp } from '../../src/http/app.js';
|
|
import { silentLogger } from '../support/silent-logger.js';
|
|
|
|
/** The error of an API error response, with `details` loosely typed for assertions. */
|
|
function errorOf(response: Response) {
|
|
return (response.body as ApiErrorBody).error as ApiErrorBody['error'] & {
|
|
details: { issues: { path: string[] }[] } & Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
const authenticate: Authenticator = (req, required) => {
|
|
if (req.get('Authorization') === 'Bearer good') {
|
|
req.auth = { username: 'admin', edition: 'ce', expiresAt: new Date() };
|
|
} else if (required || req.get('Authorization') !== undefined) {
|
|
return Promise.reject(new ApiError('UNAUTHORIZED', 'Missing access token', { reason: 'missing' }));
|
|
}
|
|
|
|
return Promise.resolve();
|
|
};
|
|
|
|
function registry(edition: 'ce' | 'pro') {
|
|
const routes = new RouteRegistry(edition);
|
|
|
|
routes.tag('Rooms', 'Room sessions');
|
|
routes.register({
|
|
method: 'get',
|
|
path: '/rooms',
|
|
edition: 'ce',
|
|
operationId: 'listRooms',
|
|
summary: 'List rooms',
|
|
tags: ['Rooms'],
|
|
security: 'bearer',
|
|
request: { query: z.object({ limit: z.coerce.number().int().max(100).default(25) }) },
|
|
responses: { 200: { description: 'Rooms', schema: z.object({ limit: z.number() }) } },
|
|
handler: ({ query }) => query
|
|
});
|
|
routes.register({
|
|
method: 'get',
|
|
path: '/rooms/{sid}',
|
|
edition: 'pro',
|
|
feature: 'room-detail',
|
|
operationId: 'getRoom',
|
|
summary: 'Room detail',
|
|
tags: ['Rooms'],
|
|
security: 'bearer',
|
|
request: { params: z.object({ sid: z.string() }) },
|
|
responses: { 200: { description: 'Room' } }
|
|
});
|
|
|
|
return routes;
|
|
}
|
|
|
|
const SUMMARY: RouteDefinition = {
|
|
method: 'get',
|
|
path: '/rooms/summary',
|
|
edition: 'ce',
|
|
operationId: 'getRoomsSummary',
|
|
summary: 'Rooms summary',
|
|
tags: ['Rooms'],
|
|
security: 'bearer',
|
|
responses: { 200: { description: 'Summary' } },
|
|
handler: () => ({ summary: true })
|
|
};
|
|
|
|
const ROOM_DETAIL: RouteDefinition = {
|
|
method: 'get',
|
|
path: '/rooms/{sid}',
|
|
edition: 'pro',
|
|
feature: 'room-detail',
|
|
operationId: 'getRoom',
|
|
summary: 'Room detail',
|
|
tags: ['Rooms'],
|
|
security: 'bearer',
|
|
responses: { 200: { description: 'Room' } }
|
|
};
|
|
|
|
/** A literal route, the lock it must precede and a CE route after them. */
|
|
function locked(edition: 'ce' | 'pro') {
|
|
const routes = new RouteRegistry(edition);
|
|
|
|
routes.tag('Rooms', 'Room sessions');
|
|
routes.register(SUMMARY);
|
|
routes.lock({ method: 'get', path: '/rooms/{sid}', feature: 'room-detail' });
|
|
routes.register({
|
|
...SUMMARY,
|
|
path: '/rooms/{sid}/events',
|
|
operationId: 'listRoomEvents',
|
|
handler: () => ({ events: [] })
|
|
});
|
|
|
|
return routes;
|
|
}
|
|
|
|
function app(routes: RouteRegistry, docs: boolean) {
|
|
return createHttpApp({
|
|
logger: silentLogger(),
|
|
appRoot: '/nonexistent',
|
|
trustProxy: false,
|
|
apiRouter: routes.router(authenticate),
|
|
...(docs ? { docsRouter: docsRouter(() => openApiDocument(routes, { version: '3.10.0' })) } : {})
|
|
});
|
|
}
|
|
|
|
describe('route registry', () => {
|
|
it('authenticates, validates and answers a CE route', async () => {
|
|
const server = app(registry('ce'), false);
|
|
|
|
expect((await request(server).get('/api/v1/rooms')).status).toBe(401);
|
|
expect((await request(server).get('/api/v1/rooms?limit=500').set('Authorization', 'Bearer good')).status).toBe(
|
|
400
|
|
);
|
|
expect((await request(server).get('/api/v1/rooms').set('Authorization', 'Bearer good')).body).toEqual({
|
|
limit: 25
|
|
});
|
|
});
|
|
|
|
it('answers PRO routes on CE with PRO_FEATURE after authentication', async () => {
|
|
const server = app(registry('ce'), false);
|
|
const anonymous = await request(server).get('/api/v1/rooms/RM_abc');
|
|
const authenticated = await request(server).get('/api/v1/rooms/RM_abc').set('Authorization', 'Bearer good');
|
|
|
|
expect(anonymous.status).toBe(401);
|
|
expect(authenticated.status).toBe(403);
|
|
expect(authenticated.body).toEqual({
|
|
error: {
|
|
code: 'PRO_FEATURE',
|
|
message: 'This feature requires OpenVidu PRO',
|
|
details: { feature: 'room-detail' }
|
|
}
|
|
});
|
|
});
|
|
|
|
it('refuses duplicate routes and PRO routes without a feature', () => {
|
|
const routes = registry('ce');
|
|
|
|
expect(() => {
|
|
routes.register({
|
|
method: 'get',
|
|
path: '/rooms',
|
|
edition: 'ce',
|
|
operationId: 'listRoomsAgain',
|
|
summary: '',
|
|
tags: [],
|
|
security: 'public',
|
|
responses: {}
|
|
});
|
|
}).toThrow();
|
|
expect(() => {
|
|
routes.register({
|
|
method: 'get',
|
|
path: '/participants/{sid}',
|
|
edition: 'pro',
|
|
operationId: 'getParticipant',
|
|
summary: '',
|
|
tags: [],
|
|
security: 'bearer',
|
|
responses: {}
|
|
});
|
|
}).toThrow();
|
|
});
|
|
|
|
it('documents every registered route in OpenAPI 3.1', async () => {
|
|
const routes = registry('ce');
|
|
const response = await request(app(routes, true)).get('/api/v1/docs/openapi.json');
|
|
const document = response.body as { openapi: string; paths: Record<string, Record<string, unknown>> };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(document.openapi).toBe('3.1.0');
|
|
|
|
for (const route of routes.list()) {
|
|
expect(
|
|
document.paths[`/api/v1${route.path}`]?.[route.method],
|
|
`${route.method} ${route.path}`
|
|
).toBeDefined();
|
|
}
|
|
|
|
expect((await request(app(routes, true)).get('/api/v1/docs')).text).toContain('createApiReference');
|
|
});
|
|
|
|
it('answers a lock with PRO_FEATURE after authentication and leaves it out of the document', async () => {
|
|
const routes = locked('ce');
|
|
const server = app(routes, true);
|
|
const authenticated = await request(server).get('/api/v1/rooms/RM_abc').set('Authorization', 'Bearer good');
|
|
const document = (await request(server).get('/api/v1/docs/openapi.json')).body as {
|
|
paths: Record<string, unknown>;
|
|
};
|
|
|
|
expect((await request(server).get('/api/v1/rooms/RM_abc')).status).toBe(401);
|
|
expect(authenticated.status).toBe(403);
|
|
expect(errorOf(authenticated)).toEqual({
|
|
code: 'PRO_FEATURE',
|
|
message: 'This feature requires OpenVidu PRO',
|
|
details: { feature: 'room-detail' }
|
|
});
|
|
expect(Object.keys(document.paths)).toEqual(['/api/v1/rooms/summary', '/api/v1/rooms/{sid}/events']);
|
|
expect(routes.unimplemented()).toEqual(['GET /rooms/{sid}']);
|
|
});
|
|
|
|
it('replaces a lock where it was declared', async () => {
|
|
const routes = locked('pro');
|
|
|
|
routes.register({
|
|
...ROOM_DETAIL,
|
|
request: { params: z.object({ sid: z.string() }) },
|
|
handler: ({ params }) => params
|
|
});
|
|
|
|
const server = app(routes, false);
|
|
|
|
expect(routes.list().map((route) => route.path)).toEqual([
|
|
'/rooms/summary',
|
|
'/rooms/{sid}',
|
|
'/rooms/{sid}/events'
|
|
]);
|
|
expect(routes.unimplemented()).toEqual([]);
|
|
expect((await request(server).get('/api/v1/rooms/summary').set('Authorization', 'Bearer good')).body).toEqual({
|
|
summary: true
|
|
});
|
|
expect((await request(server).get('/api/v1/rooms/RM_abc').set('Authorization', 'Bearer good')).body).toEqual({
|
|
sid: 'RM_abc'
|
|
});
|
|
});
|
|
|
|
it('refuses to lock a registered route and to replace a lock with another feature', () => {
|
|
const routes = locked('pro');
|
|
|
|
expect(() => {
|
|
routes.lock({ method: 'get', path: '/rooms/summary', feature: 'room-detail' });
|
|
}).toThrow('already registered');
|
|
expect(() => {
|
|
routes.register({ ...ROOM_DETAIL, feature: 'participant-detail' });
|
|
}).toThrow('locked for the feature room-detail');
|
|
routes.register(ROOM_DETAIL);
|
|
expect(() => {
|
|
routes.register(ROOM_DETAIL);
|
|
}).toThrow('already registered');
|
|
});
|
|
|
|
it('replaces a registered route in place and refuses unknown routes and locks', async () => {
|
|
const routes = locked('pro');
|
|
|
|
routes.replace({ ...SUMMARY, handler: () => ({ summary: 'extended' }) });
|
|
|
|
expect(routes.list().map((route) => route.path)).toEqual(['/rooms/summary', '/rooms/{sid}/events']);
|
|
expect(
|
|
(await request(app(routes, false)).get('/api/v1/rooms/summary').set('Authorization', 'Bearer good')).body
|
|
).toEqual({ summary: 'extended' });
|
|
expect(() => {
|
|
routes.replace({ ...SUMMARY, path: '/rooms/unknown', operationId: 'getUnknown' });
|
|
}).toThrow('not registered');
|
|
expect(() => {
|
|
routes.replace(ROOM_DETAIL);
|
|
}).toThrow('not registered');
|
|
});
|
|
|
|
it('serves no documentation in production', async () => {
|
|
const server = app(registry('ce'), false);
|
|
|
|
for (const path of ['/api/v1/docs', '/api/v1/docs/openapi.json']) {
|
|
const response = await request(server).get(path);
|
|
|
|
expect(response.status, path).toBe(404);
|
|
expect(errorOf(response).code).toBe('NOT_FOUND');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('OpenAPI document', () => {
|
|
interface Operation {
|
|
operationId: string;
|
|
tags: string[];
|
|
security: Record<string, string[]>[];
|
|
parameters?: {
|
|
name: string;
|
|
in: string;
|
|
description?: string;
|
|
style?: string;
|
|
explode?: boolean;
|
|
schema: { type?: string; items?: { enum?: string[] } };
|
|
}[];
|
|
responses: Record<string, { content?: Record<string, { schema: { $ref?: string } }>; headers?: object }>;
|
|
}
|
|
interface Document {
|
|
info: { description: string; license: { name: string; identifier?: string } };
|
|
servers: { url: string }[];
|
|
tags: { name: string; description: string }[];
|
|
paths: Record<string, Record<string, Operation>>;
|
|
components: { securitySchemes: Record<string, { in?: string; name?: string }> };
|
|
}
|
|
|
|
function documentOf(routes: RouteRegistry, options: Partial<Parameters<typeof openApiDocument>[1]> = {}): Document {
|
|
return openApiDocument(routes, { version: '3.10.0', ...options }) as unknown as Document;
|
|
}
|
|
|
|
it('refuses an undeclared tag and a repeated operationId', () => {
|
|
const routes = registry('ce');
|
|
|
|
expect(() => {
|
|
routes.register({ ...SUMMARY, tags: ['Unknown'] });
|
|
}).toThrow('undeclared tag Unknown');
|
|
expect(() => {
|
|
routes.register({ ...SUMMARY, operationId: 'listRooms' });
|
|
}).toThrow('reuses the operationId listRooms');
|
|
expect(() => {
|
|
routes.tag('Rooms', 'Another description');
|
|
}).toThrow('already declared');
|
|
});
|
|
|
|
it('carries the conventions, the license, the base path and the used tags', () => {
|
|
const routes = registry('pro');
|
|
|
|
routes.tag('Unused', 'No operation uses it');
|
|
|
|
const document = documentOf(routes, { basePath: '/dashboard/' });
|
|
|
|
expect(document.servers).toEqual([{ url: '/dashboard' }]);
|
|
expect(document.info.license).toEqual({ name: 'Apache 2.0', identifier: 'Apache-2.0' });
|
|
expect(document.info.description).toContain('If-None-Match');
|
|
expect(document.tags).toEqual([{ name: 'Rooms', description: 'Room sessions' }]);
|
|
expect(documentOf(routes).servers).toEqual([{ url: '/' }]);
|
|
expect(documentOf(routes, { license: { name: 'Commercial' } }).info.license).toEqual({ name: 'Commercial' });
|
|
});
|
|
|
|
it('declares the errors each operation can answer, with the ApiError body', () => {
|
|
const routes = registry('pro');
|
|
|
|
routes.register({
|
|
...SUMMARY,
|
|
responses: {
|
|
200: { description: 'Summary' },
|
|
403: { description: 'Locked on CE', edition: 'ce' },
|
|
429: { description: 'Too many requests' }
|
|
}
|
|
});
|
|
|
|
const document = documentOf(routes);
|
|
const list = document.paths['/api/v1/rooms']?.get;
|
|
const detail = document.paths['/api/v1/rooms/{sid}']?.get;
|
|
const summary = document.paths['/api/v1/rooms/summary']?.get;
|
|
|
|
expect(list?.operationId).toBe('listRooms');
|
|
expect(Object.keys(list?.responses ?? {})).toEqual(['200', '400', '401']);
|
|
expect(Object.keys(detail?.responses ?? {})).toEqual(['200', '400', '401']);
|
|
expect(Object.keys(summary?.responses ?? {})).toEqual(['200', '401', '429']);
|
|
expect(summary?.responses['429']?.content?.['application/json']?.schema.$ref).toBe(
|
|
'#/components/schemas/ApiError'
|
|
);
|
|
expect(
|
|
Object.keys(documentOf(routesWithCeError('ce')).paths['/api/v1/rooms/summary']?.get?.responses ?? {})
|
|
).toContain('403');
|
|
});
|
|
|
|
it('declares If-None-Match on conditional operations and the refresh cookie of the auth routes', () => {
|
|
const routes = registry('ce');
|
|
|
|
routes.register({
|
|
...SUMMARY,
|
|
security: 'public',
|
|
refreshCookie: 'optional',
|
|
responses: { 200: { description: 'Summary' }, 304: { description: 'Unchanged' } }
|
|
});
|
|
|
|
const document = documentOf(routes);
|
|
const summary = document.paths['/api/v1/rooms/summary']?.get;
|
|
|
|
expect(summary?.parameters?.map((parameter) => `${parameter.in}:${parameter.name}`)).toEqual([
|
|
'header:If-None-Match'
|
|
]);
|
|
expect(summary?.security).toEqual([{}, { refreshCookie: [] }]);
|
|
expect(document.components.securitySchemes.refreshCookie).toMatchObject({
|
|
in: 'cookie',
|
|
name: 'ov_dashboard_refresh'
|
|
});
|
|
});
|
|
|
|
it('describes every parameter and renders comma-separated lists as arrays', () => {
|
|
const routes = new RouteRegistry('ce');
|
|
|
|
routes.tag('Rooms', 'Room sessions');
|
|
routes.register({
|
|
...SUMMARY,
|
|
request: {
|
|
query: z.object({
|
|
kind: csvParam(['STANDARD', 'AGENT']).optional().meta({ description: 'Kinds to keep' }),
|
|
limit: limitParam
|
|
})
|
|
}
|
|
});
|
|
|
|
const parameters = documentOf(routes).paths['/api/v1/rooms/summary']?.get?.parameters ?? [];
|
|
|
|
expect(parameters.find((parameter) => parameter.name === 'kind')).toMatchObject({
|
|
description: 'Kinds to keep',
|
|
style: 'form',
|
|
explode: false,
|
|
schema: { type: 'array', items: { enum: ['STANDARD', 'AGENT'] } }
|
|
});
|
|
expect(parameters.find((parameter) => parameter.name === 'limit')?.description).toBe(
|
|
'Page size, from 1 to 100'
|
|
);
|
|
});
|
|
});
|
|
|
|
/** A registry whose summary declares an error only CE answers. */
|
|
function routesWithCeError(edition: 'ce' | 'pro') {
|
|
const routes = registry(edition);
|
|
|
|
routes.register({
|
|
...SUMMARY,
|
|
responses: { 200: { description: 'Summary' }, 403: { description: 'Locked on CE', edition: 'ce' } }
|
|
});
|
|
|
|
return routes;
|
|
}
|