From d22c3149c1574715aae4ac5bcd3e305b916a2cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 31 Aug 2022 18:09:05 +0200 Subject: [PATCH] Revert decorators based controllers --- .../controllers/AuthController.ts | 193 ------- .../controllers/MeController.ts | 185 ------- .../controllers/OwnerController.ts | 111 ---- .../controllers/PasswordResetController.ts | 212 -------- .../controllers/UserController.ts | 485 ------------------ .../src/UserManagement/controllers/index.ts | 6 - packages/cli/src/UserManagement/decorators.ts | 78 --- .../cli/src/UserManagement/routes/auth.ts | 11 +- .../cli/src/UserManagement/routes/index.ts | 23 +- packages/cli/src/UserManagement/routes/me.ts | 7 +- .../cli/src/UserManagement/routes/owner.ts | 121 +++++ packages/cli/test/integration/shared/utils.ts | 44 +- 12 files changed, 167 insertions(+), 1309 deletions(-) delete mode 100644 packages/cli/src/UserManagement/controllers/AuthController.ts delete mode 100644 packages/cli/src/UserManagement/controllers/MeController.ts delete mode 100644 packages/cli/src/UserManagement/controllers/OwnerController.ts delete mode 100644 packages/cli/src/UserManagement/controllers/PasswordResetController.ts delete mode 100644 packages/cli/src/UserManagement/controllers/UserController.ts delete mode 100644 packages/cli/src/UserManagement/controllers/index.ts delete mode 100644 packages/cli/src/UserManagement/decorators.ts create mode 100644 packages/cli/src/UserManagement/routes/owner.ts diff --git a/packages/cli/src/UserManagement/controllers/AuthController.ts b/packages/cli/src/UserManagement/controllers/AuthController.ts deleted file mode 100644 index 61ac40e96e8d4..0000000000000 --- a/packages/cli/src/UserManagement/controllers/AuthController.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* eslint-disable no-restricted-syntax */ -/* eslint-disable import/no-cycle */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import type { Request, Response } from 'express'; -import { In } from 'typeorm'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; -import { Db, InternalHooksManager, ResponseHelper } from '../..'; -import { issueCookie, resolveJwt } from '../auth/jwt'; -import { AUTH_COOKIE_NAME } from '../../constants'; -import type { User } from '../../databases/entities/User'; -import { Get, Post, RestController } from '../decorators'; -import type { PublicUser } from '../Interfaces'; -import type { LoginRequest, UserRequest } from '../../requests'; -import { compareHash, sanitizeUser } from '../UserManagementHelper'; -import * as config from '../../../config'; - -@RestController() -export class AuthController { - /** - * Log in a user. - * - * Authless endpoint. - */ - @Post('/login') - async login(req: LoginRequest, res: Response): Promise { - const { email, password } = req.body; - if (!email) { - throw new Error('Email is required to log in'); - } - - if (!password) { - throw new Error('Password is required to log in'); - } - - let user: User | undefined; - try { - user = await Db.collections.User.findOne( - { email }, - { - relations: ['globalRole'], - }, - ); - } catch (error) { - throw new Error('Unable to access database.'); - } - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (!user || !user.password || !(await compareHash(password, user.password))) { - // password is empty until user signs up - const error = new Error('Wrong username or password. Do you have caps lock on?'); - // @ts-ignore - error.httpStatusCode = 401; - throw error; - } - - await issueCookie(res, user); - - return sanitizeUser(user); - } - - /** - * Manually check the `n8n-auth` cookie. - */ - @Get('/login') - async loginCurrentUser(req: Request, res: Response): Promise { - // Manually check the existing cookie. - const cookieContents = req.cookies?.[AUTH_COOKIE_NAME] as string | undefined; - - let user: User; - if (cookieContents) { - // If logged in, return user - try { - user = await resolveJwt(cookieContents); - - if (!config.get('userManagement.isInstanceOwnerSetUp')) { - res.cookie(AUTH_COOKIE_NAME, cookieContents); - } - - return sanitizeUser(user); - } catch (error) { - res.clearCookie(AUTH_COOKIE_NAME); - } - } - - if (config.get('userManagement.isInstanceOwnerSetUp')) { - const error = new Error('Not logged in'); - // @ts-ignore - error.httpStatusCode = 401; - throw error; - } - - try { - user = await Db.collections.User.findOneOrFail({ relations: ['globalRole'] }); - } catch (error) { - throw new Error( - 'No users found in database - did you wipe the users table? Create at least one user.', - ); - } - - if (user.email || user.password) { - throw new Error('Invalid database state - user has password set.'); - } - - await issueCookie(res, user); - - return sanitizeUser(user); - } - - /** - * Validate invite token to enable invitee to set up their account. - * - * Authless endpoint. - */ - @Get('/resolve-signup-token') - async resolveSignupToken(req: UserRequest.ResolveSignUp) { - const { inviterId, inviteeId } = req.query; - - if (!inviterId || !inviteeId) { - Logger.debug( - 'Request to resolve signup token failed because of missing user IDs in query string', - { inviterId, inviteeId }, - ); - throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); - } - - // Postgres validates UUID format - for (const userId of [inviterId, inviteeId]) { - if (!validator.isUUID(userId)) { - Logger.debug('Request to resolve signup token failed because of invalid user ID', { - userId, - }); - throw new ResponseHelper.ResponseError('Invalid userId', undefined, 400); - } - } - - const users = await Db.collections.User.find({ where: { id: In([inviterId, inviteeId]) } }); - - if (users.length !== 2) { - Logger.debug( - 'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database', - { inviterId, inviteeId }, - ); - throw new ResponseHelper.ResponseError('Invalid invite URL', undefined, 400); - } - - const invitee = users.find((user) => user.id === inviteeId); - - if (!invitee || invitee.password) { - Logger.error('Invalid invite URL - invitee already setup', { - inviterId, - inviteeId, - }); - throw new ResponseHelper.ResponseError( - 'The invitation was likely either deleted or already claimed', - undefined, - 400, - ); - } - - const inviter = users.find((user) => user.id === inviterId); - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (!inviter || !inviter.email || !inviter.firstName) { - Logger.error( - 'Request to resolve signup token failed because inviter does not exist or is not set up', - { - inviterId: inviter?.id, - }, - ); - throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); - } - - void InternalHooksManager.getInstance().onUserInviteEmailClick({ - user_id: inviteeId, - }); - - const { firstName, lastName } = inviter; - - return { inviter: { firstName, lastName } }; - } - - /** - * Log out a user. - * - * Authless endpoint. - */ - @Post('/logout') - logout(req: Request, res: Response) { - res.clearCookie(AUTH_COOKIE_NAME); - return { loggedOut: true }; - } -} diff --git a/packages/cli/src/UserManagement/controllers/MeController.ts b/packages/cli/src/UserManagement/controllers/MeController.ts deleted file mode 100644 index 73b90b23d3fd7..0000000000000 --- a/packages/cli/src/UserManagement/controllers/MeController.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* eslint-disable import/no-cycle */ -import type { Response } from 'express'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; -import { randomBytes } from 'crypto'; -import { Delete, Get, Patch, Post, RestController } from '../decorators'; -import type { AuthenticatedRequest, MeRequest } from '../../requests'; -import type { PublicUser } from '../Interfaces'; -import { compareHash, hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper'; -import { Db, InternalHooksManager, ResponseHelper } from '../..'; -import { User } from '../../databases/entities/User'; -import { validateEntity } from '../../GenericHelpers'; -import { issueCookie } from '../auth/jwt'; - -@RestController('/me') -export class MeController { - /** - * Return the logged-in user. - */ - @Get('/') - async getCurrentUser(req: AuthenticatedRequest): Promise { - return sanitizeUser(req.user); - } - - /** - * Update the logged-in user's settings, except password. - */ - @Patch('/') - async updateCurrentUser(req: MeRequest.Settings, res: Response): Promise { - const { email } = req.body; - if (!email) { - Logger.debug('Request to update user email failed because of missing email in payload', { - userId: req.user.id, - payload: req.body, - }); - throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); - } - - if (!validator.isEmail(email)) { - Logger.debug('Request to update user email failed because of invalid email in payload', { - userId: req.user.id, - invalidEmail: email, - }); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - const newUser = new User(); - - Object.assign(newUser, req.user, req.body); - - await validateEntity(newUser); - - const user = await Db.collections.User.save(newUser); - - Logger.info('User updated successfully', { userId: user.id }); - - await issueCookie(res, user); - - const updatedkeys = Object.keys(req.body); - void InternalHooksManager.getInstance().onUserUpdate({ - user_id: req.user.id, - fields_changed: updatedkeys, - }); - - return sanitizeUser(user); - } - - /** - * Update the logged-in user's password. - */ - @Patch('/password') - async updatePassword(req: MeRequest.Password, res: Response) { - const { currentPassword, newPassword } = req.body; - - if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { - throw new ResponseHelper.ResponseError('Invalid payload.', undefined, 400); - } - - if (!req.user.password) { - throw new ResponseHelper.ResponseError('Requesting user not set up.'); - } - - const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password); - if (!isCurrentPwCorrect) { - throw new ResponseHelper.ResponseError( - 'Provided current password is incorrect.', - undefined, - 400, - ); - } - - const validPassword = validatePassword(newPassword); - - req.user.password = await hashPassword(validPassword); - - const user = await Db.collections.User.save(req.user); - Logger.info('Password updated successfully', { userId: user.id }); - - await issueCookie(res, user); - - void InternalHooksManager.getInstance().onUserUpdate({ - user_id: req.user.id, - fields_changed: ['password'], - }); - - return { success: true }; - } - - /** - * Store the logged-in user's survey answers. - */ - @Post('/survey') - async storeSurveyAnswers(req: MeRequest.SurveyAnswers) { - const { body: personalizationAnswers } = req; - - if (!personalizationAnswers) { - Logger.debug('Request to store user personalization survey failed because of empty payload', { - userId: req.user.id, - }); - throw new ResponseHelper.ResponseError( - 'Personalization answers are mandatory', - undefined, - 400, - ); - } - - await Db.collections.User.save({ - id: req.user.id, - personalizationAnswers, - }); - - Logger.info('User survey updated successfully', { userId: req.user.id }); - - void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted( - req.user.id, - personalizationAnswers, - ); - - return { success: true }; - } - - /** - * Creates an API Key - */ - @Post('/api-key') - async createAPIKey(req: AuthenticatedRequest) { - const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; - - await Db.collections.User.update(req.user.id, { apiKey }); - - const telemetryData = { - user_id: req.user.id, - public_api: false, - }; - - void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData); - - return { apiKey }; - } - - /** - * Get an API Key - */ - @Get('/api-key') - async getAPIKey(req: AuthenticatedRequest) { - return { apiKey: req.user.apiKey }; - } - - /** - * Deletes an API Key - */ - @Delete('/api-key') - async deleteAPIKey(req: AuthenticatedRequest) { - await Db.collections.User.update(req.user.id, { apiKey: null }); - - const telemetryData = { - user_id: req.user.id, - public_api: false, - }; - - void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData); - - return { success: true }; - } -} diff --git a/packages/cli/src/UserManagement/controllers/OwnerController.ts b/packages/cli/src/UserManagement/controllers/OwnerController.ts deleted file mode 100644 index 4cff3cab03ac1..0000000000000 --- a/packages/cli/src/UserManagement/controllers/OwnerController.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* eslint-disable import/no-cycle */ -import type { Response } from 'express'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; -import { issueCookie } from '../auth/jwt'; -import { Post, RestController } from '../decorators'; -import { Db, InternalHooksManager, ResponseHelper } from '../..'; -import { hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper'; -import { validateEntity } from '../../GenericHelpers'; -import type { AuthenticatedRequest, OwnerRequest } from '../../requests'; -import * as config from '../../../config'; - -@RestController('/owner') -export class OwnerController { - /** - * Promote a shell into the owner of the n8n instance, - * and enable `isInstanceOwnerSetUp` setting. - */ - @Post('/') - async promoteOwner(req: OwnerRequest.Post, res: Response) { - const { email, firstName, lastName, password } = req.body; - const { id: userId } = req.user; - - if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { - Logger.debug( - 'Request to claim instance ownership failed because instance owner already exists', - { - userId, - }, - ); - throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); - } - - if (!email || !validator.isEmail(email)) { - Logger.debug('Request to claim instance ownership failed because of invalid email', { - userId, - invalidEmail: email, - }); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - const validPassword = validatePassword(password); - - if (!firstName || !lastName) { - Logger.debug( - 'Request to claim instance ownership failed because of missing first name or last name in payload', - { userId, payload: req.body }, - ); - throw new ResponseHelper.ResponseError('First and last names are mandatory', undefined, 400); - } - - let owner = await Db.collections.User.findOne(userId, { - relations: ['globalRole'], - }); - - if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) { - Logger.debug( - 'Request to claim instance ownership failed because user shell does not exist or has wrong role!', - { - userId, - }, - ); - throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); - } - - owner = Object.assign(owner, { - email, - firstName, - lastName, - password: await hashPassword(validPassword), - }); - - await validateEntity(owner); - - owner = await Db.collections.User.save(owner); - - Logger.info('Owner was set up successfully', { userId: req.user.id }); - - await Db.collections.Settings.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(true) }, - ); - - config.set('userManagement.isInstanceOwnerSetUp', true); - - Logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id }); - - await issueCookie(res, owner); - - void InternalHooksManager.getInstance().onInstanceOwnerSetup({ - user_id: userId, - }); - - return sanitizeUser(owner); - } - - /** - * Persist that the instance owner setup has been skipped - */ - @Post('/skip-setup') - async skipSetup(_req: AuthenticatedRequest, _res: Response) { - await Db.collections.Settings.update( - { key: 'userManagement.skipInstanceOwnerSetup' }, - { value: JSON.stringify(true) }, - ); - - config.set('userManagement.skipInstanceOwnerSetup', true); - - return { success: true }; - } -} diff --git a/packages/cli/src/UserManagement/controllers/PasswordResetController.ts b/packages/cli/src/UserManagement/controllers/PasswordResetController.ts deleted file mode 100644 index 04a62e68e52d3..0000000000000 --- a/packages/cli/src/UserManagement/controllers/PasswordResetController.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* eslint-disable import/no-cycle */ -import type { Response } from 'express'; -import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; -import type { PasswordResetRequest } from '../../requests'; -import { Get, Post, RestController } from '../decorators'; -import * as UserManagementMailer from '../email'; -import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper'; -import { Db, ResponseHelper, InternalHooksManager } from '../..'; -import { issueCookie } from '../auth/jwt'; -import * as config from '../../../config'; - -@RestController() -export class PasswordResetController { - /** - * Send a password reset email. - * - * Authless endpoint. - */ - @Post('/forgot-password') - async forgotPassword(req: PasswordResetRequest.Email) { - if (config.getEnv('userManagement.emails.mode') === '') { - Logger.debug('Request to send password reset email failed because emailing was not set up'); - throw new ResponseHelper.ResponseError( - 'Email sending must be set up in order to request a password reset email', - undefined, - 500, - ); - } - - const { email } = req.body; - - if (!email) { - Logger.debug( - 'Request to send password reset email failed because of missing email in payload', - { payload: req.body }, - ); - throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); - } - - if (!validator.isEmail(email)) { - Logger.debug( - 'Request to send password reset email failed because of invalid email in payload', - { invalidEmail: email }, - ); - throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); - } - - // User should just be able to reset password if one is already present - const user = await Db.collections.User.findOne({ email, password: Not(IsNull()) }); - - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (!user || !user.password) { - Logger.debug( - 'Request to send password reset email failed because no user was found for the provided email', - { invalidEmail: email }, - ); - return; - } - - user.resetPasswordToken = uuid(); - - const { id, firstName, lastName, resetPasswordToken } = user; - - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200; - - await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration }); - - const baseUrl = getInstanceBaseUrl(); - const url = new URL(`${baseUrl}/change-password`); - url.searchParams.append('userId', id); - url.searchParams.append('token', resetPasswordToken); - - try { - const mailer = await UserManagementMailer.getInstance(); - await mailer.passwordReset({ - email, - firstName, - lastName, - passwordResetUrl: url.toString(), - domain: baseUrl, - }); - } catch (error) { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: user.id, - message_type: 'Reset password', - public_api: false, - }); - if (error instanceof Error) { - throw new ResponseHelper.ResponseError( - `Please contact your administrator: ${error.message}`, - undefined, - 500, - ); - } - } - - Logger.info('Sent password reset email successfully', { userId: user.id, email }); - void InternalHooksManager.getInstance().onUserTransactionalEmail({ - user_id: id, - message_type: 'Reset password', - public_api: false, - }); - - void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({ - user_id: id, - }); - } - - /** - * Verify password reset token and user ID. - * - * Authless endpoint. - */ - @Get('/resolve-password-token') - async resolvePasswordToken(req: PasswordResetRequest.Credentials) { - const { token: resetPasswordToken, userId: id } = req.query; - - if (!resetPasswordToken || !id) { - Logger.debug( - 'Request to resolve password token failed because of missing password reset token or user ID in query string', - { - queryString: req.query, - }, - ); - throw new ResponseHelper.ResponseError('', undefined, 400); - } - - // Timestamp is saved in seconds - const currentTimestamp = Math.floor(Date.now() / 1000); - - const user = await Db.collections.User.findOne({ - id, - resetPasswordToken, - resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), - }); - - if (!user) { - Logger.debug( - 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', - { - userId: id, - resetPasswordToken, - }, - ); - throw new ResponseHelper.ResponseError('', undefined, 404); - } - - Logger.info('Reset-password token resolved successfully', { userId: id }); - void InternalHooksManager.getInstance().onUserPasswordResetEmailClick({ - user_id: id, - }); - } - - /** - * Verify password reset token and user ID and update password. - * - * Authless endpoint. - */ - @Post('/change-password') - async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { - const { token: resetPasswordToken, userId, password } = req.body; - - if (!resetPasswordToken || !userId || !password) { - Logger.debug( - 'Request to change password failed because of missing user ID or password or reset password token in payload', - { - payload: req.body, - }, - ); - throw new ResponseHelper.ResponseError( - 'Missing user ID or password or reset password token', - undefined, - 400, - ); - } - - const validPassword = validatePassword(password); - - // Timestamp is saved in seconds - const currentTimestamp = Math.floor(Date.now() / 1000); - - const user = await Db.collections.User.findOne({ - id: userId, - resetPasswordToken, - resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), - }); - - if (!user) { - Logger.debug( - 'Request to resolve password token failed because no user was found for the provided user ID and reset password token', - { - userId, - resetPasswordToken, - }, - ); - throw new ResponseHelper.ResponseError('', undefined, 404); - } - - await Db.collections.User.update(userId, { - password: await hashPassword(validPassword), - resetPasswordToken: null, - resetPasswordTokenExpiration: null, - }); - - Logger.info('User password updated successfully', { userId }); - - await issueCookie(res, user); - } -} diff --git a/packages/cli/src/UserManagement/controllers/UserController.ts b/packages/cli/src/UserManagement/controllers/UserController.ts deleted file mode 100644 index 7aa90a117902f..0000000000000 --- a/packages/cli/src/UserManagement/controllers/UserController.ts +++ /dev/null @@ -1,485 +0,0 @@ -/* eslint-disable import/no-cycle */ -import { Response } from 'express'; -import { In } from 'typeorm'; -import validator from 'validator'; -import { LoggerProxy as Logger } from 'n8n-workflow'; -import type { UserRequest } from '../../requests'; -import { Delete, Get, Post, RestController } from '../decorators'; -import { ActiveWorkflowRunner, Db, ITelemetryUserDeletionData, ResponseHelper } from '../..'; -import { User } from '../../databases/entities/User'; -import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; -import { SharedCredentials } from '../../databases/entities/SharedCredentials'; -import { InternalHooksManager } from '../../InternalHooksManager'; -import * as UserManagementMailer from '../email'; -import { - isUserManagementDisabled, - getInstanceBaseUrl, - validatePassword, - hashPassword, - sanitizeUser, - isEmailSetUp, -} from '../UserManagementHelper'; -import * as config from '../../../config'; -import { issueCookie } from '../auth/jwt'; - -@RestController('/users') -export class UserController { - /** - * Send email invite(s) to one or multiple users and create user shell(s). - */ - @Post('/') - async sendEmailInvites(req: UserRequest.Invite) { - if (config.getEnv('userManagement.emails.mode') === '') { - Logger.debug( - 'Request to send email invite(s) to user(s) failed because emailing was not set up', - ); - throw new ResponseHelper.ResponseError( - 'Email sending must be set up in order to request a password reset email', - undefined, - 500, - ); - } - - let mailer: UserManagementMailer.UserManagementMailer | undefined; - try { - mailer = await UserManagementMailer.getInstance(); - } catch (error) { - if (error instanceof Error) { - throw new ResponseHelper.ResponseError( - `There is a problem with your SMTP setup! ${error.message}`, - undefined, - 500, - ); - } - } - - // TODO: this should be checked in the middleware rather than here - if (isUserManagementDisabled()) { - Logger.debug( - 'Request to send email invite(s) to user(s) failed because user management is disabled', - ); - throw new ResponseHelper.ResponseError('User management is disabled'); - } - - if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { - Logger.debug( - 'Request to send email invite(s) to user(s) failed because the owner account is not set up', - ); - throw new ResponseHelper.ResponseError( - 'You must set up your own account before inviting others', - undefined, - 400, - ); - } - - if (!Array.isArray(req.body)) { - Logger.debug( - 'Request to send email invite(s) to user(s) failed because the payload is not an array', - { - payload: req.body, - }, - ); - throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); - } - - if (!req.body.length) return []; - - const createUsers: { [key: string]: string | null } = {}; - // Validate payload - req.body.forEach((invite) => { - if (typeof invite !== 'object' || !invite.email) { - throw new ResponseHelper.ResponseError( - 'Request to send email invite(s) to user(s) failed because the payload is not an array shaped Array<{ email: string }>', - undefined, - 400, - ); - } - - if (!validator.isEmail(invite.email)) { - Logger.debug('Invalid email in payload', { invalidEmail: invite.email }); - throw new ResponseHelper.ResponseError( - `Request to send email invite(s) to user(s) failed because of an invalid email address: ${invite.email}`, - undefined, - 400, - ); - } - createUsers[invite.email.toLowerCase()] = null; - }); - - const role = await Db.collections.Role.findOne({ scope: 'global', name: 'member' }); - - if (!role) { - Logger.error( - 'Request to send email invite(s) to user(s) failed because no global member role was found in database', - ); - throw new ResponseHelper.ResponseError( - 'Members role not found in database - inconsistent state', - undefined, - 500, - ); - } - - // remove/exclude existing users from creation - const existingUsers = await Db.collections.User.find({ - where: { email: In(Object.keys(createUsers)) }, - }); - existingUsers.forEach((user) => { - if (user.password) { - delete createUsers[user.email]; - return; - } - createUsers[user.email] = user.id; - }); - - const usersToSetUp = Object.keys(createUsers).filter((email) => createUsers[email] === null); - const total = usersToSetUp.length; - - Logger.debug(total > 1 ? `Creating ${total} user shells...` : `Creating 1 user shell...`); - - try { - await Db.transaction(async (transactionManager) => { - return Promise.all( - usersToSetUp.map(async (email) => { - const newUser = Object.assign(new User(), { - email, - globalRole: role, - }); - const savedUser = await transactionManager.save(newUser); - createUsers[savedUser.email] = savedUser.id; - return savedUser; - }), - ); - }); - - void InternalHooksManager.getInstance().onUserInvite({ - user_id: req.user.id, - target_user_id: Object.values(createUsers) as string[], - public_api: false, - }); - } catch (error) { - Logger.error('Failed to create user shells', { userShells: createUsers }); - throw new ResponseHelper.ResponseError('An error occurred during user creation'); - } - - Logger.info('Created user shell(s) successfully', { userId: req.user.id }); - Logger.verbose(total > 1 ? `${total} user shells created` : `1 user shell created`, { - userShells: createUsers, - }); - - const baseUrl = getInstanceBaseUrl(); - - const usersPendingSetup = Object.entries(createUsers).filter(([email, id]) => id && email); - - // send invite email to new or not yet setup users - - const emailingResults = await Promise.all( - usersPendingSetup.map(async ([email, id]) => { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${id}`; - const result = await mailer?.invite({ - email, - inviteAcceptUrl, - domain: baseUrl, - }); - const resp: { user: { id: string | null; email: string }; error?: string } = { - user: { - id, - email, - }, - }; - if (result?.success) { - void InternalHooksManager.getInstance().onUserTransactionalEmail({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user_id: id!, - message_type: 'New user invite', - public_api: false, - }); - } else { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: req.user.id, - message_type: 'New user invite', - public_api: false, - }); - Logger.error('Failed to send email', { - userId: req.user.id, - inviteAcceptUrl, - domain: baseUrl, - email, - }); - resp.error = `Email could not be sent`; - } - return resp; - }), - ); - - Logger.debug( - usersPendingSetup.length > 1 - ? `Sent ${usersPendingSetup.length} invite emails successfully` - : `Sent 1 invite email successfully`, - { userShells: createUsers }, - ); - - return emailingResults; - } - - /** - * Fill out user shell with first name, last name, and password. - * - * Authless endpoint. - */ - @Post('/:id') - async updateUser(req: UserRequest.Update, res: Response) { - const { id: inviteeId } = req.params; - - const { inviterId, firstName, lastName, password } = req.body; - - if (!inviterId || !inviteeId || !firstName || !lastName || !password) { - Logger.debug( - 'Request to fill out a user shell failed because of missing properties in payload', - { payload: req.body }, - ); - throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); - } - - const validPassword = validatePassword(password); - - const users = await Db.collections.User.find({ - where: { id: In([inviterId, inviteeId]) }, - relations: ['globalRole'], - }); - - if (users.length !== 2) { - Logger.debug( - 'Request to fill out a user shell failed because the inviter ID and/or invitee ID were not found in database', - { - inviterId, - inviteeId, - }, - ); - throw new ResponseHelper.ResponseError('Invalid payload or URL', undefined, 400); - } - - const invitee = users.find((user) => user.id === inviteeId) as User; - - if (invitee.password) { - Logger.debug( - 'Request to fill out a user shell failed because the invite had already been accepted', - { inviteeId }, - ); - throw new ResponseHelper.ResponseError( - 'This invite has been accepted already', - undefined, - 400, - ); - } - - invitee.firstName = firstName; - invitee.lastName = lastName; - invitee.password = await hashPassword(validPassword); - - const updatedUser = await Db.collections.User.save(invitee); - - await issueCookie(res, updatedUser); - - void InternalHooksManager.getInstance().onUserSignup({ - user_id: invitee.id, - }); - - return sanitizeUser(updatedUser); - } - - @Get('/') - async listUsers() { - const users = await Db.collections.User.find({ relations: ['globalRole'] }); - return users.map((user) => sanitizeUser(user, ['personalizationAnswers'])); - } - - /** - * Delete a user. Optionally, designate a transferee for their workflows and credentials. - */ - @Delete('/:id') - async deleteUser(req: UserRequest.Delete) { - const { id: idToDelete } = req.params; - - if (req.user.id === idToDelete) { - Logger.debug( - 'Request to delete a user failed because it attempted to delete the requesting user', - { userId: req.user.id }, - ); - throw new ResponseHelper.ResponseError('Cannot delete your own user', undefined, 400); - } - - const { transferId } = req.query; - - if (transferId === idToDelete) { - throw new ResponseHelper.ResponseError( - 'Request to delete a user failed because the user to delete and the transferee are the same user', - undefined, - 400, - ); - } - - const users = await Db.collections.User.find({ - where: { id: In([transferId, idToDelete]) }, - }); - - if (!users.length || (transferId && users.length !== 2)) { - throw new ResponseHelper.ResponseError( - 'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB', - undefined, - 404, - ); - } - - const userToDelete = users.find((user) => user.id === req.params.id) as User; - - if (transferId) { - const transferee = users.find((user) => user.id === transferId); - await Db.transaction(async (transactionManager) => { - await transactionManager.update( - SharedWorkflow, - { user: userToDelete }, - { user: transferee }, - ); - await transactionManager.update( - SharedCredentials, - { user: userToDelete }, - { user: transferee }, - ); - await transactionManager.delete(User, { id: userToDelete.id }); - }); - - return { success: true }; - } - - const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([ - Db.collections.SharedWorkflow.find({ - relations: ['workflow'], - where: { user: userToDelete }, - }), - Db.collections.SharedCredentials.find({ - relations: ['credentials'], - where: { user: userToDelete }, - }), - ]); - - await Db.transaction(async (transactionManager) => { - const ownedWorkflows = await Promise.all( - ownedSharedWorkflows.map(async ({ workflow }) => { - if (workflow.active) { - // deactivate before deleting - await ActiveWorkflowRunner.getInstance().remove(workflow.id.toString()); - } - return workflow; - }), - ); - await transactionManager.remove(ownedWorkflows); - await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials)); - await transactionManager.delete(User, { id: userToDelete.id }); - }); - - const telemetryData: ITelemetryUserDeletionData = { - user_id: req.user.id, - target_user_old_status: userToDelete.isPending ? 'invited' : 'active', - target_user_id: idToDelete, - }; - - telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data'; - - if (transferId) { - telemetryData.migration_user_id = transferId; - } - - void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false); - - return { success: true }; - } - - /** - * Resend email invite to user. - */ - @Post('/:id/reinvite') - async reinviteUser(req: UserRequest.Reinvite) { - const { id: idToReinvite } = req.params; - - if (!isEmailSetUp()) { - Logger.error('Request to reinvite a user failed because email sending was not set up'); - throw new ResponseHelper.ResponseError( - 'Email sending must be set up in order to invite other users', - undefined, - 500, - ); - } - - const reinvitee = await Db.collections.User.findOne({ id: idToReinvite }); - - if (!reinvitee) { - Logger.debug( - 'Request to reinvite a user failed because the ID of the reinvitee was not found in database', - ); - throw new ResponseHelper.ResponseError('Could not find user', undefined, 404); - } - - if (reinvitee.password) { - Logger.debug( - 'Request to reinvite a user failed because the invite had already been accepted', - { userId: reinvitee.id }, - ); - throw new ResponseHelper.ResponseError( - 'User has already accepted the invite', - undefined, - 400, - ); - } - - const baseUrl = getInstanceBaseUrl(); - const inviteAcceptUrl = `${baseUrl}/signup?inviterId=${req.user.id}&inviteeId=${reinvitee.id}`; - - let mailer: UserManagementMailer.UserManagementMailer | undefined; - try { - mailer = await UserManagementMailer.getInstance(); - } catch (error) { - if (error instanceof Error) { - throw new ResponseHelper.ResponseError(error.message, undefined, 500); - } - } - - const result = await mailer?.invite({ - email: reinvitee.email, - inviteAcceptUrl, - domain: baseUrl, - }); - - if (!result?.success) { - void InternalHooksManager.getInstance().onEmailFailed({ - user_id: req.user.id, - message_type: 'Resend invite', - public_api: false, - }); - Logger.error('Failed to send email', { - email: reinvitee.email, - inviteAcceptUrl, - domain: baseUrl, - }); - throw new ResponseHelper.ResponseError( - `Failed to send email to ${reinvitee.email}`, - undefined, - 500, - ); - } - - void InternalHooksManager.getInstance().onUserReinvite({ - user_id: req.user.id, - target_user_id: reinvitee.id, - public_api: false, - }); - - void InternalHooksManager.getInstance().onUserTransactionalEmail({ - user_id: reinvitee.id, - message_type: 'Resend invite', - public_api: false, - }); - - return { success: true }; - } -} diff --git a/packages/cli/src/UserManagement/controllers/index.ts b/packages/cli/src/UserManagement/controllers/index.ts deleted file mode 100644 index e5cc9e72dbd5b..0000000000000 --- a/packages/cli/src/UserManagement/controllers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable import/no-cycle */ -export * from './AuthController'; -export * from './MeController'; -export * from './OwnerController'; -export * from './PasswordResetController'; -export * from './UserController'; diff --git a/packages/cli/src/UserManagement/decorators.ts b/packages/cli/src/UserManagement/decorators.ts deleted file mode 100644 index 2d9fe1d87cbf9..0000000000000 --- a/packages/cli/src/UserManagement/decorators.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable import/no-cycle */ -import { Application, Request, RequestHandler as Middleware, Response, Router } from 'express'; -import { ResponseHelper } from '..'; -import config from '../../config'; - -export interface RouteMetadata { - method: Method; - path: string; - handlerName: string | symbol; -} - -const Keys = { - ROUTES: Symbol('routes'), - BASE_PATH: Symbol('base_path'), -} as const; - -type Method = 'get' | 'post' | 'patch' | 'put' | 'delete'; - -export const RestController = - (basePath: `/${string}` = '/'): ClassDecorator => - (target: object) => { - Reflect.defineMetadata(Keys.BASE_PATH, basePath, target); - }; - -const RouteFactory = - (...methods: Method[]) => - (path: `/${string}`): MethodDecorator => - (target, handlerName) => { - const ControllerClass = target.constructor; - const routes: RouteMetadata[] = Reflect.getMetadata(Keys.ROUTES, ControllerClass) ?? []; - methods.forEach((method) => { - routes.push({ method, path, handlerName }); - }); - - Reflect.defineMetadata(Keys.ROUTES, routes, ControllerClass); - }; - -export const Get = RouteFactory('get'); -export const Post = RouteFactory('post'); -export const Patch = RouteFactory('patch'); -export const Put = RouteFactory('put'); -export const Delete = RouteFactory('delete'); - -type RequestHandler = (req: Request, res: Response) => Promise; - -interface Controller { - new (...args: unknown[]): T; -} - -export const registerController = ( - app: Application, - ControllerClass: Controller, - ...middlewares: Middleware[] -): void => { - const instance = new ControllerClass() as unknown as Record; - const routes: RouteMetadata[] = Reflect.getMetadata(Keys.ROUTES, ControllerClass); - if (routes.length) { - const router = Router({ mergeParams: true }); - const restEndpoint: string = config.getEnv('endpoints.rest'); - const basePath: string = Reflect.getMetadata(Keys.BASE_PATH, ControllerClass); - const prefix = `/${[restEndpoint, basePath].join('/')}`.replace(/\/+/g, '/'); - - routes.forEach(({ method, path, handlerName }) => { - router[method]( - path, - ...middlewares, - ResponseHelper.send(async (req: Request, res: Response) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - instance[String(handlerName)].call(instance, req, res), - ), - ); - }); - - app.use(prefix, router); - } -}; diff --git a/packages/cli/src/UserManagement/routes/auth.ts b/packages/cli/src/UserManagement/routes/auth.ts index b808db91022c6..f89fe4af2acc0 100644 --- a/packages/cli/src/UserManagement/routes/auth.ts +++ b/packages/cli/src/UserManagement/routes/auth.ts @@ -21,20 +21,19 @@ export function authenticationMethods(this: N8nApp): void { this.app.post( `/${this.restEndpoint}/login`, ResponseHelper.send(async (req: LoginRequest, res: Response): Promise => { - if (!req.body.email) { + const { email, password } = req.body; + if (!email) { throw new Error('Email is required to log in'); } - if (!req.body.password) { + if (!password) { throw new Error('Password is required to log in'); } - let user; + let user: User | undefined; try { user = await Db.collections.User.findOne( - { - email: req.body.email, - }, + { email }, { relations: ['globalRole'], }, diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts index ea1f114991ec8..8c77ec652ad74 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -15,15 +15,12 @@ import { isUserManagementDisabled, } from '../UserManagementHelper'; import { Db } from '../..'; -import { registerController } from '../decorators'; -import { - AuthController, - MeController, - OwnerController, - PasswordResetController, - UserController, -} from '../controllers'; import { jwtAuth, refreshExpiringCookie } from '../middlewares'; +import { authenticationMethods } from './auth'; +import { meNamespace } from './me'; +import { usersNamespace } from './users'; +import { passwordResetNamespace } from './passwordReset'; +import { ownerNamespace } from './owner'; export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint: string): void { // needed for testing; not adding overhead since it directly returns if req.cookies exists @@ -103,9 +100,9 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint this.app.use(refreshExpiringCookie); - registerController(this.app, AuthController); - registerController(this.app, MeController); - registerController(this.app, OwnerController); - registerController(this.app, PasswordResetController); - registerController(this.app, UserController); + authenticationMethods.apply(this); + ownerNamespace.apply(this); + meNamespace.apply(this); + passwordResetNamespace.apply(this); + usersNamespace.apply(this); } diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts index 78ff4eca491cf..6a4c7bce17846 100644 --- a/packages/cli/src/UserManagement/routes/me.ts +++ b/packages/cli/src/UserManagement/routes/me.ts @@ -32,7 +32,8 @@ export function meNamespace(this: N8nApp): void { `/${this.restEndpoint}/me`, ResponseHelper.send( async (req: MeRequest.Settings, res: express.Response): Promise => { - if (!req.body.email) { + const { email } = req.body; + if (!email) { Logger.debug('Request to update user email failed because of missing email in payload', { userId: req.user.id, payload: req.body, @@ -40,10 +41,10 @@ export function meNamespace(this: N8nApp): void { throw new ResponseHelper.ResponseError('Email is mandatory', undefined, 400); } - if (!validator.isEmail(req.body.email)) { + if (!validator.isEmail(email)) { Logger.debug('Request to update user email failed because of invalid email in payload', { userId: req.user.id, - invalidEmail: req.body.email, + invalidEmail: email, }); throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); } diff --git a/packages/cli/src/UserManagement/routes/owner.ts b/packages/cli/src/UserManagement/routes/owner.ts new file mode 100644 index 0000000000000..f97237a9c691a --- /dev/null +++ b/packages/cli/src/UserManagement/routes/owner.ts @@ -0,0 +1,121 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import express from 'express'; +import validator from 'validator'; +import { LoggerProxy as Logger } from 'n8n-workflow'; + +import { Db, InternalHooksManager, ResponseHelper } from '../..'; +import * as config from '../../../config'; +import { validateEntity } from '../../GenericHelpers'; +import { AuthenticatedRequest, OwnerRequest } from '../../requests'; +import { issueCookie } from '../auth/jwt'; +import { N8nApp } from '../Interfaces'; +import { hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper'; + +export function ownerNamespace(this: N8nApp): void { + /** + * Promote a shell into the owner of the n8n instance, + * and enable `isInstanceOwnerSetUp` setting. + */ + this.app.post( + `/${this.restEndpoint}/owner`, + ResponseHelper.send(async (req: OwnerRequest.Post, res: express.Response) => { + const { email, firstName, lastName, password } = req.body; + const { id: userId } = req.user; + + if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { + Logger.debug( + 'Request to claim instance ownership failed because instance owner already exists', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + if (!email || !validator.isEmail(email)) { + Logger.debug('Request to claim instance ownership failed because of invalid email', { + userId, + invalidEmail: email, + }); + throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); + } + + const validPassword = validatePassword(password); + + if (!firstName || !lastName) { + Logger.debug( + 'Request to claim instance ownership failed because of missing first name or last name in payload', + { userId, payload: req.body }, + ); + throw new ResponseHelper.ResponseError( + 'First and last names are mandatory', + undefined, + 400, + ); + } + + let owner = await Db.collections.User.findOne(userId, { + relations: ['globalRole'], + }); + + if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) { + Logger.debug( + 'Request to claim instance ownership failed because user shell does not exist or has wrong role!', + { + userId, + }, + ); + throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); + } + + owner = Object.assign(owner, { + email, + firstName, + lastName, + password: await hashPassword(validPassword), + }); + + await validateEntity(owner); + + owner = await Db.collections.User.save(owner); + + Logger.info('Owner was set up successfully', { userId: req.user.id }); + + await Db.collections.Settings.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.isInstanceOwnerSetUp', true); + + Logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id }); + + await issueCookie(res, owner); + + void InternalHooksManager.getInstance().onInstanceOwnerSetup({ + user_id: userId, + }); + + return sanitizeUser(owner); + }), + ); + + /** + * Persist that the instance owner setup has been skipped + */ + this.app.post( + `/${this.restEndpoint}/owner/skip-setup`, + // eslint-disable-next-line @typescript-eslint/naming-convention + ResponseHelper.send(async (_req: AuthenticatedRequest, _res: express.Response) => { + await Db.collections.Settings.update( + { key: 'userManagement.skipInstanceOwnerSetup' }, + { value: JSON.stringify(true) }, + ); + + config.set('userManagement.skipInstanceOwnerSetup', true); + + return { success: true }; + }), + ); +} diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 1262f8e6140ab..57c5da8afdab0 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -21,6 +21,7 @@ import { toCronExpression, TriggerTime, } from 'n8n-workflow'; +import type { N8nApp } from '../../../src/UserManagement/Interfaces'; import superagent from 'superagent'; import request from 'supertest'; import { URL } from 'url'; @@ -34,6 +35,11 @@ import { InternalHooksManager, NodeTypes, } from '../../../src'; +import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; +import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; +import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; +import { ownerNamespace as ownerEndpoints } from '../../../src/UserManagement/routes/owner'; +import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/UserManagement/routes/passwordReset'; import { nodesController } from '../../../src/api/nodes.api'; import { workflowsController } from '../../../src/api/workflows.api'; import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants'; @@ -43,14 +49,6 @@ import type { User } from '../../../src/databases/entities/User'; import { getLogger } from '../../../src/Logger'; import { loadPublicApiVersions } from '../../../src/PublicApi/'; import { issueJWT } from '../../../src/UserManagement/auth/jwt'; -import { - AuthController, - MeController, - OwnerController, - PasswordResetController, -} from '../../../src/UserManagement/controllers'; -import { UserController } from '../../../src/UserManagement/controllers/UserController'; -import { registerController } from '../../../src/UserManagement/decorators'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; import { AUTHLESS_ENDPOINTS, @@ -104,7 +102,7 @@ export async function initTestServer({ testServer.externalHooks = ExternalHooks(); } - const [routerEndpoints] = classifyEndpointGroups(endpointGroups); + const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups); if (routerEndpoints.length) { const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); @@ -124,11 +122,19 @@ export async function initTestServer({ } } - registerController(testServer.app, MeController); - registerController(testServer.app, UserController); - registerController(testServer.app, AuthController); - registerController(testServer.app, OwnerController); - registerController(testServer.app, PasswordResetController); + if (functionEndpoints.length) { + const map: Record void> = { + me: meEndpoints, + users: usersEndpoints, + auth: authEndpoints, + owner: ownerEndpoints, + passwordReset: passwordResetEndpoints, + }; + + for (const group of functionEndpoints) { + map[group].apply(testServer); + } + } return testServer.app; } @@ -143,16 +149,20 @@ export function initTestTelemetry() { } /** - * Identify endpoint groups as `routerEndpoints` (newest, using `express.Router`) + * Classify endpoint groups into `routerEndpoints` (newest, using `express.Router`), + * and `functionEndpoints` (legacy, namespaced inside a function). */ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; + const functionEndpoints: string[] = []; const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi']; - endpointGroups.forEach((group) => ROUTER_GROUP.includes(group) && routerEndpoints.push(group)); + endpointGroups.forEach((group) => + (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), + ); - return [routerEndpoints]; + return [routerEndpoints, functionEndpoints]; }; // ----------------------------------