mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -08:00
refactor(core): Auto-register controllers at startup (no-changelog) (#9781)
This commit is contained in:
parent
be2635e50e
commit
3b70330ff6
|
@ -1,8 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Container, Service } from 'typedi';
|
||||
import { exec as callbackExec } from 'child_process';
|
||||
import { access as fsAccess } from 'fs/promises';
|
||||
|
@ -10,68 +5,67 @@ import { promisify } from 'util';
|
|||
import cookieParser from 'cookie-parser';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { type Class, InstanceSettings } from 'n8n-core';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import type { IN8nUISettings } from 'n8n-workflow';
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error missing types
|
||||
import timezones from 'google-timezones-json';
|
||||
|
||||
import config from '@/config';
|
||||
import { Queue } from '@/Queue';
|
||||
|
||||
import { WorkflowsController } from '@/workflows/workflows.controller';
|
||||
import { EDITOR_UI_DIST_DIR, inDevelopment, inE2ETests, N8N_VERSION, Time } from '@/constants';
|
||||
import { CredentialsController } from '@/credentials/credentials.controller';
|
||||
import {
|
||||
EDITOR_UI_DIST_DIR,
|
||||
inDevelopment,
|
||||
inE2ETests,
|
||||
inProduction,
|
||||
N8N_VERSION,
|
||||
Time,
|
||||
} from '@/constants';
|
||||
import type { APIRequest } from '@/requests';
|
||||
import { registerController } from '@/decorators';
|
||||
import { AuthController } from '@/controllers/auth.controller';
|
||||
import { BinaryDataController } from '@/controllers/binaryData.controller';
|
||||
import { CurlController } from '@/controllers/curl.controller';
|
||||
import { DynamicNodeParametersController } from '@/controllers/dynamicNodeParameters.controller';
|
||||
import { MeController } from '@/controllers/me.controller';
|
||||
import { MFAController } from '@/controllers/mfa.controller';
|
||||
import { NodeTypesController } from '@/controllers/nodeTypes.controller';
|
||||
import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller';
|
||||
import { OAuth2CredentialController } from '@/controllers/oauth/oAuth2Credential.controller';
|
||||
import { OwnerController } from '@/controllers/owner.controller';
|
||||
import { PasswordResetController } from '@/controllers/passwordReset.controller';
|
||||
import { TagsController } from '@/controllers/tags.controller';
|
||||
import { TranslationController } from '@/controllers/translation.controller';
|
||||
import { UsersController } from '@/controllers/users.controller';
|
||||
import { WorkflowStatisticsController } from '@/controllers/workflowStatistics.controller';
|
||||
import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee';
|
||||
import { ExecutionsController } from '@/executions/executions.controller';
|
||||
import { ControllerRegistry } from '@/decorators';
|
||||
import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
|
||||
import type { ICredentialsOverwrite } from '@/Interfaces';
|
||||
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
||||
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||
import * as ResponseHelper from '@/ResponseHelper';
|
||||
import { EventBusController } from '@/eventbus/eventBus.controller';
|
||||
import { LicenseController } from '@/license/license.controller';
|
||||
import { setupPushServer, setupPushHandler } from '@/push';
|
||||
import { isLdapEnabled } from './Ldap/helpers';
|
||||
import { AbstractServer } from './AbstractServer';
|
||||
import { PostHogClient } from './posthog';
|
||||
import { isLdapEnabled } from '@/Ldap/helpers';
|
||||
import { AbstractServer } from '@/AbstractServer';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
||||
import { InternalHooks } from './InternalHooks';
|
||||
import { SamlController } from './sso/saml/routes/saml.controller.ee';
|
||||
import { SamlService } from './sso/saml/saml.service.ee';
|
||||
import { VariablesController } from './environments/variables/variables.controller.ee';
|
||||
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
|
||||
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
|
||||
import { AIController } from '@/controllers/ai.controller';
|
||||
|
||||
import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers';
|
||||
import type { FrontendService } from './services/frontend.service';
|
||||
import { ActiveWorkflowsController } from './controllers/activeWorkflows.controller';
|
||||
import { OrchestrationController } from './controllers/orchestration.controller';
|
||||
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
|
||||
import { InvitationController } from './controllers/invitation.controller';
|
||||
// import { CollaborationService } from './collaboration/collaboration.service';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { handleMfaDisable, isMfaFeatureEnabled } from '@/Mfa/helpers';
|
||||
import type { FrontendService } from '@/services/frontend.service';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
import { ProjectController } from './controllers/project.controller';
|
||||
import { RoleController } from './controllers/role.controller';
|
||||
import { UserSettingsController } from './controllers/userSettings.controller';
|
||||
|
||||
import '@/controllers/activeWorkflows.controller';
|
||||
import '@/controllers/ai.controller';
|
||||
import '@/controllers/auth.controller';
|
||||
import '@/controllers/binaryData.controller';
|
||||
import '@/controllers/curl.controller';
|
||||
import '@/controllers/dynamicNodeParameters.controller';
|
||||
import '@/controllers/invitation.controller';
|
||||
import '@/controllers/me.controller';
|
||||
import '@/controllers/nodeTypes.controller';
|
||||
import '@/controllers/oauth/oAuth1Credential.controller';
|
||||
import '@/controllers/oauth/oAuth2Credential.controller';
|
||||
import '@/controllers/orchestration.controller';
|
||||
import '@/controllers/owner.controller';
|
||||
import '@/controllers/passwordReset.controller';
|
||||
import '@/controllers/project.controller';
|
||||
import '@/controllers/role.controller';
|
||||
import '@/controllers/tags.controller';
|
||||
import '@/controllers/translation.controller';
|
||||
import '@/controllers/users.controller';
|
||||
import '@/controllers/userSettings.controller';
|
||||
import '@/controllers/workflowStatistics.controller';
|
||||
import '@/credentials/credentials.controller';
|
||||
import '@/eventbus/eventBus.controller';
|
||||
import '@/executions/executions.controller';
|
||||
import '@/ExternalSecrets/ExternalSecrets.controller.ee';
|
||||
import '@/license/license.controller';
|
||||
import '@/workflows/workflowHistory/workflowHistory.controller.ee';
|
||||
import '@/workflows/workflows.controller';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
|
@ -81,11 +75,13 @@ export class Server extends AbstractServer {
|
|||
|
||||
private presetCredentialsLoaded: boolean;
|
||||
|
||||
private loadNodesAndCredentials: LoadNodesAndCredentials;
|
||||
|
||||
private frontendService?: FrontendService;
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
|
||||
private readonly orchestrationService: OrchestrationService,
|
||||
private readonly postHogClient: PostHogClient,
|
||||
) {
|
||||
super('main');
|
||||
|
||||
this.testWebhooksEnabled = true;
|
||||
|
@ -93,11 +89,9 @@ export class Server extends AbstractServer {
|
|||
}
|
||||
|
||||
async start() {
|
||||
this.loadNodesAndCredentials = Container.get(LoadNodesAndCredentials);
|
||||
|
||||
if (!config.getEnv('endpoints.disableUi')) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
this.frontendService = Container.get(require('@/services/frontend.service').FrontendService);
|
||||
const { FrontendService } = await import('@/services/frontend.service');
|
||||
this.frontendService = Container.get(FrontendService);
|
||||
}
|
||||
|
||||
this.presetCredentialsLoaded = false;
|
||||
|
@ -111,84 +105,62 @@ export class Server extends AbstractServer {
|
|||
}
|
||||
|
||||
void Container.get(InternalHooks).onServerStarted();
|
||||
// Container.get(CollaborationService);
|
||||
}
|
||||
|
||||
private async registerControllers() {
|
||||
const { app } = this;
|
||||
|
||||
const controllers: Array<Class<object>> = [
|
||||
EventBusController,
|
||||
AuthController,
|
||||
LicenseController,
|
||||
OAuth1CredentialController,
|
||||
OAuth2CredentialController,
|
||||
OwnerController,
|
||||
MeController,
|
||||
DynamicNodeParametersController,
|
||||
NodeTypesController,
|
||||
PasswordResetController,
|
||||
TagsController,
|
||||
TranslationController,
|
||||
UsersController,
|
||||
SamlController,
|
||||
SourceControlController,
|
||||
WorkflowStatisticsController,
|
||||
ExternalSecretsController,
|
||||
OrchestrationController,
|
||||
WorkflowHistoryController,
|
||||
BinaryDataController,
|
||||
VariablesController,
|
||||
InvitationController,
|
||||
VariablesController,
|
||||
ActiveWorkflowsController,
|
||||
WorkflowsController,
|
||||
ExecutionsController,
|
||||
CredentialsController,
|
||||
AIController,
|
||||
ProjectController,
|
||||
RoleController,
|
||||
CurlController,
|
||||
UserSettingsController,
|
||||
];
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
Container.get(OrchestrationService).isMultiMainSetupEnabled
|
||||
) {
|
||||
const { DebugController } = await import('@/controllers/debug.controller');
|
||||
controllers.push(DebugController);
|
||||
private async registerAdditionalControllers() {
|
||||
if (!inProduction && this.orchestrationService.isMultiMainSetupEnabled) {
|
||||
await import('@/controllers/debug.controller');
|
||||
}
|
||||
|
||||
if (isLdapEnabled()) {
|
||||
const { LdapService } = await import('@/Ldap/ldap.service');
|
||||
const { LdapController } = await require('@/Ldap/ldap.controller');
|
||||
await import('@/Ldap/ldap.controller');
|
||||
await Container.get(LdapService).init();
|
||||
controllers.push(LdapController);
|
||||
}
|
||||
|
||||
if (config.getEnv('nodes.communityPackages.enabled')) {
|
||||
const { CommunityPackagesController } = await import(
|
||||
'@/controllers/communityPackages.controller'
|
||||
);
|
||||
controllers.push(CommunityPackagesController);
|
||||
await import('@/controllers/communityPackages.controller');
|
||||
}
|
||||
|
||||
if (inE2ETests) {
|
||||
const { E2EController } = await import('./controllers/e2e.controller');
|
||||
controllers.push(E2EController);
|
||||
await import('@/controllers/e2e.controller');
|
||||
}
|
||||
|
||||
if (isMfaFeatureEnabled()) {
|
||||
controllers.push(MFAController);
|
||||
await import('@/controllers/mfa.controller');
|
||||
}
|
||||
|
||||
if (!config.getEnv('endpoints.disableUi')) {
|
||||
const { CtaController } = await import('@/controllers/cta.controller');
|
||||
controllers.push(CtaController);
|
||||
await import('@/controllers/cta.controller');
|
||||
}
|
||||
|
||||
controllers.forEach((controller) => registerController(app, controller));
|
||||
// ----------------------------------------
|
||||
// SAML
|
||||
// ----------------------------------------
|
||||
|
||||
// initialize SamlService if it is licensed, even if not enabled, to
|
||||
// set up the initial environment
|
||||
try {
|
||||
const { SamlService } = await import('@/sso/saml/saml.service.ee');
|
||||
await Container.get(SamlService).init();
|
||||
await import('@/sso/saml/routes/saml.controller.ee');
|
||||
} catch (error) {
|
||||
this.logger.warn(`SAML initialization failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Source Control
|
||||
// ----------------------------------------
|
||||
try {
|
||||
const { SourceControlService } = await import(
|
||||
'@/environments/sourceControl/sourceControl.service.ee'
|
||||
);
|
||||
await Container.get(SourceControlService).init();
|
||||
await import('@/environments/sourceControl/sourceControl.controller.ee');
|
||||
await import('@/environments/variables/variables.controller.ee');
|
||||
} catch (error) {
|
||||
this.logger.warn(`Source Control initialization failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(): Promise<void> {
|
||||
|
@ -209,7 +181,7 @@ export class Server extends AbstractServer {
|
|||
await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]);
|
||||
}
|
||||
|
||||
await Container.get(PostHogClient).init();
|
||||
await this.postHogClient.init();
|
||||
|
||||
const publicApiEndpoint = config.getEnv('publicApi.path');
|
||||
|
||||
|
@ -238,33 +210,16 @@ export class Server extends AbstractServer {
|
|||
setupPushHandler(restEndpoint, app);
|
||||
|
||||
if (config.getEnv('executions.mode') === 'queue') {
|
||||
const { Queue } = await import('@/Queue');
|
||||
await Container.get(Queue).init();
|
||||
}
|
||||
|
||||
await handleMfaDisable();
|
||||
|
||||
await this.registerControllers();
|
||||
await this.registerAdditionalControllers();
|
||||
|
||||
// ----------------------------------------
|
||||
// SAML
|
||||
// ----------------------------------------
|
||||
|
||||
// initialize SamlService if it is licensed, even if not enabled, to
|
||||
// set up the initial environment
|
||||
try {
|
||||
await Container.get(SamlService).init();
|
||||
} catch (error) {
|
||||
this.logger.warn(`SAML initialization failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
// Source Control
|
||||
// ----------------------------------------
|
||||
try {
|
||||
await Container.get(SourceControlService).init();
|
||||
} catch (error) {
|
||||
this.logger.warn(`Source Control initialization failed: ${error.message}`);
|
||||
}
|
||||
// register all known controllers
|
||||
Container.get(ControllerRegistry).activate(app);
|
||||
|
||||
// ----------------------------------------
|
||||
// Options
|
||||
|
@ -273,6 +228,7 @@ export class Server extends AbstractServer {
|
|||
// Returns all the available timezones
|
||||
this.app.get(
|
||||
`/${this.restEndpoint}/options/timezones`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
ResponseHelper.send(async () => timezones),
|
||||
);
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ export class AuthController {
|
|||
) {}
|
||||
|
||||
/** Log in a user */
|
||||
@Post('/login', { skipAuth: true, rateLimit: {} })
|
||||
@Post('/login', { skipAuth: true, rateLimit: true })
|
||||
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
||||
const { email, password, mfaToken, mfaRecoveryCode } = req.body;
|
||||
if (!email) throw new ApplicationError('Email is required to log in');
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||
import type { LicenseMetadata } from './types';
|
||||
import { CONTROLLER_LICENSE_FEATURES } from './constants';
|
||||
import { getRouteMetadata } from './controller.registry';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const Licensed = (features: BooleanLicenseFeature | BooleanLicenseFeature[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return (target: Function | object, handlerName?: string) => {
|
||||
const controllerClass = handlerName ? target.constructor : target;
|
||||
const license = (Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) ??
|
||||
{}) as LicenseMetadata;
|
||||
license[handlerName ?? '*'] = Array.isArray(features) ? features : [features];
|
||||
Reflect.defineMetadata(CONTROLLER_LICENSE_FEATURES, license, controllerClass);
|
||||
export const Licensed =
|
||||
(licenseFeature: BooleanLicenseFeature): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName));
|
||||
routeMetadata.licenseFeature = licenseFeature;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { CONTROLLER_MIDDLEWARES } from './constants';
|
||||
import type { MiddlewareMetadata } from './types';
|
||||
import { getControllerMetadata } from './controller.registry';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const Middleware = (): MethodDecorator => (target, handlerName) => {
|
||||
const controllerClass = target.constructor;
|
||||
const middlewares = (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ??
|
||||
[]) as MiddlewareMetadata[];
|
||||
middlewares.push({ handlerName: String(handlerName) });
|
||||
Reflect.defineMetadata(CONTROLLER_MIDDLEWARES, middlewares, controllerClass);
|
||||
const metadata = getControllerMetadata(target.constructor as Controller);
|
||||
metadata.middlewares.push(String(handlerName));
|
||||
};
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Service } from 'typedi';
|
||||
import { CONTROLLER_BASE_PATH } from './constants';
|
||||
import { getControllerMetadata } from './controller.registry';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const RestController =
|
||||
(basePath: `/${string}` = '/'): ClassDecorator =>
|
||||
(target: object) => {
|
||||
Reflect.defineMetadata(CONTROLLER_BASE_PATH, basePath, target);
|
||||
(target) => {
|
||||
const metadata = getControllerMetadata(target as unknown as Controller);
|
||||
metadata.basePath = basePath;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { RequestHandler } from 'express';
|
||||
import { CONTROLLER_ROUTES } from './constants';
|
||||
import type { Method, RateLimit, RouteMetadata } from './types';
|
||||
import type { Controller, Method, RateLimit } from './types';
|
||||
import { getRouteMetadata } from './controller.registry';
|
||||
|
||||
interface RouteOptions {
|
||||
middlewares?: RequestHandler[];
|
||||
|
@ -8,26 +8,20 @@ interface RouteOptions {
|
|||
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
|
||||
skipAuth?: boolean;
|
||||
/** When these options are set, calls to this endpoint are rate limited using the options */
|
||||
rateLimit?: RateLimit;
|
||||
rateLimit?: boolean | RateLimit;
|
||||
}
|
||||
|
||||
const RouteFactory =
|
||||
(method: Method) =>
|
||||
(path: `/${string}`, options: RouteOptions = {}): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const controllerClass = target.constructor;
|
||||
const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ??
|
||||
[]) as RouteMetadata[];
|
||||
routes.push({
|
||||
method,
|
||||
path,
|
||||
middlewares: options.middlewares ?? [],
|
||||
handlerName: String(handlerName),
|
||||
usesTemplates: options.usesTemplates ?? false,
|
||||
skipAuth: options.skipAuth ?? false,
|
||||
rateLimit: options.rateLimit,
|
||||
});
|
||||
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
|
||||
const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName));
|
||||
routeMetadata.method = method;
|
||||
routeMetadata.path = path;
|
||||
routeMetadata.middlewares = options.middlewares ?? [];
|
||||
routeMetadata.usesTemplates = options.usesTemplates ?? false;
|
||||
routeMetadata.skipAuth = options.skipAuth ?? false;
|
||||
routeMetadata.rateLimit = options.rateLimit;
|
||||
};
|
||||
|
||||
export const Get = RouteFactory('get');
|
||||
|
|
|
@ -1,22 +1,13 @@
|
|||
import type { Scope } from '@n8n/permissions';
|
||||
import type { RouteScopeMetadata } from './types';
|
||||
import { CONTROLLER_ROUTE_SCOPES } from './constants';
|
||||
import { getRouteMetadata } from './controller.registry';
|
||||
import type { Controller } from './types';
|
||||
|
||||
const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => {
|
||||
return (target: Function | object, handlerName?: string) => {
|
||||
const controllerClass = handlerName ? target.constructor : target;
|
||||
const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ??
|
||||
{}) as RouteScopeMetadata;
|
||||
|
||||
const metadata = {
|
||||
scopes: Array.isArray(scope) ? scope : [scope],
|
||||
globalOnly,
|
||||
};
|
||||
|
||||
scopes[handlerName ?? '*'] = metadata;
|
||||
Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass);
|
||||
const Scoped =
|
||||
(scope: Scope, { globalOnly } = { globalOnly: false }): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName));
|
||||
routeMetadata.accessScope = { scope, globalOnly };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator for a controller method to ensure the user has a scope,
|
||||
|
@ -34,9 +25,7 @@ const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false })
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
export const GlobalScope = (scope: Scope | Scope[]) => {
|
||||
return Scoped(scope, { globalOnly: true });
|
||||
};
|
||||
export const GlobalScope = (scope: Scope) => Scoped(scope, { globalOnly: true });
|
||||
|
||||
/**
|
||||
* Decorator for a controller method to ensure the user has a scope,
|
||||
|
@ -55,6 +44,4 @@ export const GlobalScope = (scope: Scope | Scope[]) => {
|
|||
* ```
|
||||
*/
|
||||
|
||||
export const ProjectScope = (scope: Scope | Scope[]) => {
|
||||
return Scoped(scope);
|
||||
};
|
||||
export const ProjectScope = (scope: Scope) => Scoped(scope);
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
|
||||
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';
|
||||
export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES';
|
||||
export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES';
|
||||
export const CONTROLLER_ROUTE_SCOPES = 'CONTROLLER_ROUTE_SCOPES';
|
137
packages/cli/src/decorators/controller.registry.ts
Normal file
137
packages/cli/src/decorators/controller.registry.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { Container, Service } from 'typedi';
|
||||
import { Router } from 'express';
|
||||
import type { Application, Request, Response, RequestHandler } from 'express';
|
||||
import { rateLimit as expressRateLimit } from 'express-rate-limit';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
|
||||
import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||
import { License } from '@/License';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
|
||||
import { userHasScope } from '@/permissions/checkAccess';
|
||||
|
||||
import type {
|
||||
AccessScope,
|
||||
Controller,
|
||||
ControllerMetadata,
|
||||
HandlerName,
|
||||
RateLimit,
|
||||
RouteMetadata,
|
||||
} from './types';
|
||||
|
||||
const registry = new Map<Controller, ControllerMetadata>();
|
||||
|
||||
export const getControllerMetadata = (controllerClass: Controller) => {
|
||||
let metadata = registry.get(controllerClass);
|
||||
if (!metadata) {
|
||||
metadata = {
|
||||
basePath: '/',
|
||||
middlewares: [],
|
||||
routes: new Map(),
|
||||
};
|
||||
registry.set(controllerClass, metadata);
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
export const getRouteMetadata = (controllerClass: Controller, handlerName: HandlerName) => {
|
||||
const metadata = getControllerMetadata(controllerClass);
|
||||
let route = metadata.routes.get(handlerName);
|
||||
if (!route) {
|
||||
route = {} as RouteMetadata;
|
||||
metadata.routes.set(handlerName, route);
|
||||
}
|
||||
return route;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class ControllerRegistry {
|
||||
constructor(
|
||||
private readonly license: License,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
activate(app: Application) {
|
||||
for (const controllerClass of registry.keys()) {
|
||||
this.activateController(app, controllerClass);
|
||||
}
|
||||
}
|
||||
|
||||
private activateController(app: Application, controllerClass: Controller) {
|
||||
const metadata = registry.get(controllerClass)!;
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const prefix = `/${config.getEnv('endpoints.rest')}/${metadata.basePath}`
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
app.use(prefix, router);
|
||||
|
||||
const controller = Container.get<Controller>(controllerClass);
|
||||
const controllerMiddlewares = metadata.middlewares.map(
|
||||
(handlerName) => controller[handlerName].bind(controller) as RequestHandler,
|
||||
);
|
||||
|
||||
for (const [handlerName, route] of metadata.routes) {
|
||||
const handler = async (req: Request, res: Response) =>
|
||||
await controller[handlerName](req, res);
|
||||
|
||||
router[route.method](
|
||||
route.path,
|
||||
...(inProduction && route.rateLimit
|
||||
? [this.createRateLimitMiddleware(route.rateLimit)]
|
||||
: []),
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
...(route.skipAuth ? [] : [this.authService.authMiddleware]),
|
||||
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
|
||||
...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []),
|
||||
...controllerMiddlewares,
|
||||
...route.middlewares,
|
||||
route.usesTemplates ? handler : send(handler),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createRateLimitMiddleware(rateLimit: true | RateLimit): RequestHandler {
|
||||
if (typeof rateLimit === 'boolean') rateLimit = {};
|
||||
return expressRateLimit({
|
||||
windowMs: rateLimit.windowMs,
|
||||
limit: rateLimit.limit,
|
||||
message: { message: 'Too many requests' },
|
||||
});
|
||||
}
|
||||
|
||||
private createLicenseMiddleware(feature: BooleanLicenseFeature): RequestHandler {
|
||||
return (_req, res, next) => {
|
||||
if (!this.license.isFeatureEnabled(feature)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ status: 'error', message: 'Plan lacks license for this feature' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
private createScopedMiddleware(accessScope: AccessScope): RequestHandler {
|
||||
return async (
|
||||
req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
if (!req.user) throw new UnauthenticatedError();
|
||||
|
||||
const { scope, globalOnly } = accessScope;
|
||||
|
||||
if (!(await userHasScope(req.user, [scope], globalOnly, req.params))) {
|
||||
return res.status(403).json({
|
||||
status: 'error',
|
||||
message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
export { RestController } from './RestController';
|
||||
export { Get, Post, Put, Patch, Delete } from './Route';
|
||||
export { Middleware } from './Middleware';
|
||||
export { registerController } from './registerController';
|
||||
export { ControllerRegistry } from './controller.registry';
|
||||
export { Licensed } from './Licensed';
|
||||
export { GlobalScope, ProjectScope } from './Scoped';
|
||||
|
|
|
@ -1,143 +0,0 @@
|
|||
import { Container } from 'typedi';
|
||||
import { Router } from 'express';
|
||||
import type { Application, Request, Response, RequestHandler } from 'express';
|
||||
import { rateLimit as expressRateLimit } from 'express-rate-limit';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
import type { Class } from 'n8n-core';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
|
||||
import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||
import { License } from '@/License';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
|
||||
import {
|
||||
CONTROLLER_BASE_PATH,
|
||||
CONTROLLER_LICENSE_FEATURES,
|
||||
CONTROLLER_MIDDLEWARES,
|
||||
CONTROLLER_ROUTE_SCOPES,
|
||||
CONTROLLER_ROUTES,
|
||||
} from './constants';
|
||||
import type {
|
||||
Controller,
|
||||
LicenseMetadata,
|
||||
MiddlewareMetadata,
|
||||
RateLimit,
|
||||
RouteMetadata,
|
||||
RouteScopeMetadata,
|
||||
} from './types';
|
||||
import { userHasScope } from '@/permissions/checkAccess';
|
||||
|
||||
const createRateLimitMiddleware = (rateLimit: RateLimit): RequestHandler =>
|
||||
expressRateLimit({
|
||||
windowMs: rateLimit.windowMs,
|
||||
limit: rateLimit.limit,
|
||||
message: { message: 'Too many requests' },
|
||||
});
|
||||
|
||||
export const createLicenseMiddleware =
|
||||
(features: BooleanLicenseFeature[]): RequestHandler =>
|
||||
(_req, res, next) => {
|
||||
if (features.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const licenseService = Container.get(License);
|
||||
|
||||
const hasAllFeatures = features.every((feature) => licenseService.isFeatureEnabled(feature));
|
||||
if (!hasAllFeatures) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ status: 'error', message: 'Plan lacks license for this feature' });
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export const createScopedMiddleware =
|
||||
(routeScopeMetadata: RouteScopeMetadata[string]): RequestHandler =>
|
||||
async (
|
||||
req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
if (!req.user) throw new UnauthenticatedError();
|
||||
|
||||
const { scopes, globalOnly } = routeScopeMetadata;
|
||||
|
||||
if (scopes.length === 0) return next();
|
||||
|
||||
if (!(await userHasScope(req.user, scopes, globalOnly, req.params))) {
|
||||
return res.status(403).json({
|
||||
status: 'error',
|
||||
message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export const registerController = (app: Application, controllerClass: Class<object>) => {
|
||||
const controller = Container.get(controllerClass as Class<Controller>);
|
||||
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
|
||||
| string
|
||||
| undefined;
|
||||
if (!controllerBasePath)
|
||||
throw new ApplicationError('Controller is missing the RestController decorator', {
|
||||
extra: { controllerName: controllerClass.name },
|
||||
});
|
||||
|
||||
const routes = Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) as RouteMetadata[];
|
||||
const licenseFeatures = Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) as
|
||||
| LicenseMetadata
|
||||
| undefined;
|
||||
const routeScopes = Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) as
|
||||
| RouteScopeMetadata
|
||||
| undefined;
|
||||
|
||||
if (routes.length > 0) {
|
||||
const router = Router({ mergeParams: true });
|
||||
const restBasePath = config.getEnv('endpoints.rest');
|
||||
const prefix = `/${[restBasePath, controllerBasePath].join('/')}`
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const controllerMiddlewares = (
|
||||
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
|
||||
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
|
||||
|
||||
const authService = Container.get(AuthService);
|
||||
|
||||
routes.forEach(
|
||||
({
|
||||
method,
|
||||
path,
|
||||
middlewares: routeMiddlewares,
|
||||
handlerName,
|
||||
usesTemplates,
|
||||
skipAuth,
|
||||
rateLimit,
|
||||
}) => {
|
||||
const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*'];
|
||||
const scopes = routeScopes?.[handlerName] ?? routeScopes?.['*'];
|
||||
const handler = async (req: Request, res: Response) =>
|
||||
await controller[handlerName](req, res);
|
||||
router[method](
|
||||
path,
|
||||
...(inProduction && rateLimit ? [createRateLimitMiddleware(rateLimit)] : []),
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
...(skipAuth ? [] : [authService.authMiddleware]),
|
||||
...(features ? [createLicenseMiddleware(features)] : []),
|
||||
...(scopes ? [createScopedMiddleware(scopes)] : []),
|
||||
...controllerMiddlewares,
|
||||
...routeMiddlewares,
|
||||
usesTemplates ? handler : send(handler),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(prefix, router);
|
||||
}
|
||||
};
|
|
@ -1,22 +1,10 @@
|
|||
import type { Request, Response, RequestHandler } from 'express';
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { Class } from 'n8n-core';
|
||||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
export type LicenseMetadata = Record<string, BooleanLicenseFeature[]>;
|
||||
|
||||
export type RouteScopeMetadata = {
|
||||
[handlerName: string]: {
|
||||
scopes: Scope[];
|
||||
globalOnly: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface MiddlewareMetadata {
|
||||
handlerName: string;
|
||||
}
|
||||
|
||||
export interface RateLimit {
|
||||
/**
|
||||
* The maximum number of requests to allow during the `window` before rate limiting the client.
|
||||
|
@ -30,17 +18,29 @@ export interface RateLimit {
|
|||
windowMs?: number;
|
||||
}
|
||||
|
||||
export type HandlerName = string;
|
||||
|
||||
export interface AccessScope {
|
||||
scope: Scope;
|
||||
globalOnly: boolean;
|
||||
}
|
||||
|
||||
export interface RouteMetadata {
|
||||
method: Method;
|
||||
path: string;
|
||||
handlerName: string;
|
||||
middlewares: RequestHandler[];
|
||||
usesTemplates: boolean;
|
||||
skipAuth: boolean;
|
||||
rateLimit?: RateLimit;
|
||||
rateLimit?: boolean | RateLimit;
|
||||
licenseFeature?: BooleanLicenseFeature;
|
||||
accessScope?: AccessScope;
|
||||
}
|
||||
|
||||
export type Controller = Record<
|
||||
RouteMetadata['handlerName'],
|
||||
(req?: Request, res?: Response) => Promise<unknown>
|
||||
>;
|
||||
export interface ControllerMetadata {
|
||||
basePath: `/${string}`;
|
||||
middlewares: HandlerName[];
|
||||
routes: Map<HandlerName, RouteMetadata>;
|
||||
}
|
||||
|
||||
export type Controller = Class<object> &
|
||||
Record<HandlerName, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
|
|
@ -8,21 +8,21 @@ import { URL } from 'url';
|
|||
import config from '@/config';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { registerController } from '@/decorators';
|
||||
import { ControllerRegistry } from '@/decorators';
|
||||
import { rawBodyReader, bodyParser } from '@/middlewares';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { Push } from '@/push';
|
||||
import { License } from '@/License';
|
||||
import { Logger } from '@/Logger';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import type { APIRequest } from '@/requests';
|
||||
|
||||
import { mockInstance } from '../../../shared/mocking';
|
||||
import * as testDb from '../../shared/testDb';
|
||||
import { PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
|
||||
import type { SetupProps, TestServer } from '../types';
|
||||
import { LicenseMocker } from '../license';
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import type { APIRequest } from '@/requests';
|
||||
|
||||
/**
|
||||
* Plugin to prefix a path segment into a request URL pathname.
|
||||
|
@ -125,30 +125,23 @@ export const setupTestServer = ({
|
|||
for (const group of endpointGroups) {
|
||||
switch (group) {
|
||||
case 'credentials':
|
||||
const { CredentialsController } = await import('@/credentials/credentials.controller');
|
||||
registerController(app, CredentialsController);
|
||||
await import('@/credentials/credentials.controller');
|
||||
break;
|
||||
|
||||
case 'workflows':
|
||||
const { WorkflowsController } = await import('@/workflows/workflows.controller');
|
||||
registerController(app, WorkflowsController);
|
||||
await import('@/workflows/workflows.controller');
|
||||
break;
|
||||
|
||||
case 'executions':
|
||||
const { ExecutionsController } = await import('@/executions/executions.controller');
|
||||
registerController(app, ExecutionsController);
|
||||
await import('@/executions/executions.controller');
|
||||
break;
|
||||
|
||||
case 'variables':
|
||||
const { VariablesController } = await import(
|
||||
'@/environments/variables/variables.controller.ee'
|
||||
);
|
||||
registerController(app, VariablesController);
|
||||
await import('@/environments/variables/variables.controller.ee');
|
||||
break;
|
||||
|
||||
case 'license':
|
||||
const { LicenseController } = await import('@/license/license.controller');
|
||||
registerController(app, LicenseController);
|
||||
await import('@/license/license.controller');
|
||||
break;
|
||||
|
||||
case 'metrics':
|
||||
|
@ -157,123 +150,93 @@ export const setupTestServer = ({
|
|||
break;
|
||||
|
||||
case 'eventBus':
|
||||
const { EventBusController } = await import('@/eventbus/eventBus.controller');
|
||||
registerController(app, EventBusController);
|
||||
await import('@/eventbus/eventBus.controller');
|
||||
break;
|
||||
|
||||
case 'auth':
|
||||
const { AuthController } = await import('@/controllers/auth.controller');
|
||||
registerController(app, AuthController);
|
||||
await import('@/controllers/auth.controller');
|
||||
break;
|
||||
|
||||
case 'mfa':
|
||||
const { MFAController } = await import('@/controllers/mfa.controller');
|
||||
registerController(app, MFAController);
|
||||
await import('@/controllers/mfa.controller');
|
||||
break;
|
||||
|
||||
case 'ldap':
|
||||
const { LdapService } = await import('@/Ldap/ldap.service');
|
||||
const { LdapController } = await import('@/Ldap/ldap.controller');
|
||||
await import('@/Ldap/ldap.controller');
|
||||
testServer.license.enable('feat:ldap');
|
||||
await Container.get(LdapService).init();
|
||||
registerController(app, LdapController);
|
||||
break;
|
||||
|
||||
case 'saml':
|
||||
const { setSamlLoginEnabled } = await import('@/sso/saml/samlHelpers');
|
||||
const { SamlController } = await import('@/sso/saml/routes/saml.controller.ee');
|
||||
await import('@/sso/saml/routes/saml.controller.ee');
|
||||
await setSamlLoginEnabled(true);
|
||||
registerController(app, SamlController);
|
||||
break;
|
||||
|
||||
case 'sourceControl':
|
||||
const { SourceControlController } = await import(
|
||||
'@/environments/sourceControl/sourceControl.controller.ee'
|
||||
);
|
||||
registerController(app, SourceControlController);
|
||||
await import('@/environments/sourceControl/sourceControl.controller.ee');
|
||||
break;
|
||||
|
||||
case 'community-packages':
|
||||
const { CommunityPackagesController } = await import(
|
||||
'@/controllers/communityPackages.controller'
|
||||
);
|
||||
registerController(app, CommunityPackagesController);
|
||||
await import('@/controllers/communityPackages.controller');
|
||||
break;
|
||||
|
||||
case 'me':
|
||||
const { MeController } = await import('@/controllers/me.controller');
|
||||
registerController(app, MeController);
|
||||
await import('@/controllers/me.controller');
|
||||
break;
|
||||
|
||||
case 'passwordReset':
|
||||
const { PasswordResetController } = await import(
|
||||
'@/controllers/passwordReset.controller'
|
||||
);
|
||||
registerController(app, PasswordResetController);
|
||||
await import('@/controllers/passwordReset.controller');
|
||||
break;
|
||||
|
||||
case 'owner':
|
||||
const { OwnerController } = await import('@/controllers/owner.controller');
|
||||
registerController(app, OwnerController);
|
||||
await import('@/controllers/owner.controller');
|
||||
break;
|
||||
|
||||
case 'users':
|
||||
const { UsersController } = await import('@/controllers/users.controller');
|
||||
registerController(app, UsersController);
|
||||
await import('@/controllers/users.controller');
|
||||
break;
|
||||
|
||||
case 'invitations':
|
||||
const { InvitationController } = await import('@/controllers/invitation.controller');
|
||||
registerController(app, InvitationController);
|
||||
await import('@/controllers/invitation.controller');
|
||||
break;
|
||||
|
||||
case 'tags':
|
||||
const { TagsController } = await import('@/controllers/tags.controller');
|
||||
registerController(app, TagsController);
|
||||
await import('@/controllers/tags.controller');
|
||||
break;
|
||||
|
||||
case 'externalSecrets':
|
||||
const { ExternalSecretsController } = await import(
|
||||
'@/ExternalSecrets/ExternalSecrets.controller.ee'
|
||||
);
|
||||
registerController(app, ExternalSecretsController);
|
||||
await import('@/ExternalSecrets/ExternalSecrets.controller.ee');
|
||||
break;
|
||||
|
||||
case 'workflowHistory':
|
||||
const { WorkflowHistoryController } = await import(
|
||||
'@/workflows/workflowHistory/workflowHistory.controller.ee'
|
||||
);
|
||||
registerController(app, WorkflowHistoryController);
|
||||
await import('@/workflows/workflowHistory/workflowHistory.controller.ee');
|
||||
break;
|
||||
|
||||
case 'binaryData':
|
||||
const { BinaryDataController } = await import('@/controllers/binaryData.controller');
|
||||
registerController(app, BinaryDataController);
|
||||
await import('@/controllers/binaryData.controller');
|
||||
break;
|
||||
|
||||
case 'debug':
|
||||
const { DebugController } = await import('@/controllers/debug.controller');
|
||||
registerController(app, DebugController);
|
||||
await import('@/controllers/debug.controller');
|
||||
break;
|
||||
|
||||
case 'project':
|
||||
const { ProjectController } = await import('@/controllers/project.controller');
|
||||
registerController(app, ProjectController);
|
||||
await import('@/controllers/project.controller');
|
||||
break;
|
||||
|
||||
case 'role':
|
||||
const { RoleController } = await import('@/controllers/role.controller');
|
||||
registerController(app, RoleController);
|
||||
await import('@/controllers/role.controller');
|
||||
break;
|
||||
|
||||
case 'dynamic-node-parameters':
|
||||
const { DynamicNodeParametersController } = await import(
|
||||
'@/controllers/dynamicNodeParameters.controller'
|
||||
);
|
||||
registerController(app, DynamicNodeParametersController);
|
||||
await import('@/controllers/dynamicNodeParameters.controller');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Container.get(ControllerRegistry).activate(app);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
115
packages/cli/test/unit/decorators/controller.registry.test.ts
Normal file
115
packages/cli/test/unit/decorators/controller.registry.test.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
jest.mock('@/constants', () => ({
|
||||
inProduction: true,
|
||||
}));
|
||||
|
||||
import express from 'express';
|
||||
import { agent as testAgent } from 'supertest';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ControllerRegistry, Get, Licensed, RestController } from '@/decorators';
|
||||
import type { AuthService } from '@/auth/auth.service';
|
||||
import type { License } from '@/License';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
|
||||
describe('ControllerRegistry', () => {
|
||||
const license = mock<License>();
|
||||
const authService = mock<AuthService>();
|
||||
let agent: SuperAgentTest;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
const app = express();
|
||||
new ControllerRegistry(license, authService).activate(app);
|
||||
agent = testAgent(app);
|
||||
});
|
||||
|
||||
describe('Rate limiting', () => {
|
||||
@RestController('/test')
|
||||
// @ts-expect-error tsc complains about unused class
|
||||
class TestController {
|
||||
@Get('/unlimited')
|
||||
unlimited() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('/rate-limited', { rateLimit: true })
|
||||
rateLimited() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
||||
});
|
||||
|
||||
it('should not rate-limit by default', async () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await agent.get('/rest/test/unlimited').expect(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should rate-limit when configured', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await agent.get('/rest/test/rate-limited').expect(200);
|
||||
}
|
||||
await agent.get('/rest/test/rate-limited').expect(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization', () => {
|
||||
@RestController('/test')
|
||||
// @ts-expect-error tsc complains about unused class
|
||||
class TestController {
|
||||
@Get('/no-auth', { skipAuth: true })
|
||||
noAuth() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('/auth')
|
||||
auth() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
it('should not require auth if configured to skip', async () => {
|
||||
await agent.get('/rest/test/no-auth').expect(200);
|
||||
expect(authService.authMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require auth by default', async () => {
|
||||
authService.authMiddleware.mockImplementation(async (_req, res) => {
|
||||
res.status(401).send();
|
||||
});
|
||||
await agent.get('/rest/test/auth').expect(401);
|
||||
expect(authService.authMiddleware).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('License checks', () => {
|
||||
@RestController('/test')
|
||||
// @ts-expect-error tsc complains about unused class
|
||||
class TestController {
|
||||
@Get('/with-sharing')
|
||||
@Licensed('feat:sharing')
|
||||
sharing() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
||||
});
|
||||
|
||||
it('should disallow when feature is missing', async () => {
|
||||
license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(false);
|
||||
await agent.get('/rest/test/with-sharing').expect(403);
|
||||
expect(license.isFeatureEnabled).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow when feature is available', async () => {
|
||||
license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(true);
|
||||
await agent.get('/rest/test/with-sharing').expect(200);
|
||||
expect(license.isFeatureEnabled).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
jest.mock('@/constants', () => ({
|
||||
inProduction: true,
|
||||
}));
|
||||
|
||||
import express from 'express';
|
||||
import { agent as testAgent } from 'supertest';
|
||||
|
||||
import { Get, RestController, registerController } from '@/decorators';
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { mockInstance } from '../../shared/mocking';
|
||||
|
||||
describe('registerController', () => {
|
||||
@RestController('/test')
|
||||
class TestController {
|
||||
@Get('/unlimited', { skipAuth: true })
|
||||
@Get('/rate-limited', { skipAuth: true, rateLimit: {} })
|
||||
endpoint() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
mockInstance(AuthService);
|
||||
const app = express();
|
||||
registerController(app, TestController);
|
||||
const agent = testAgent(app);
|
||||
|
||||
it('should not rate-limit by default', async () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await agent.get('/rest/test/unlimited').expect(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should rate-limit when configured', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await agent.get('/rest/test/rate-limited').expect(200);
|
||||
}
|
||||
await agent.get('/rest/test/rate-limited').expect(429);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue