diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 51f40183ae71d..8bafde341b3a7 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -2,14 +2,19 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable import/no-cycle */ import { IsNull, Not } from 'typeorm'; -import { Db, ResponseHelper } from '..'; +import { Db, GenericHelpers, ResponseHelper } from '..'; import config = require('../../config'); -import { User } from '../databases/entities/User'; +import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '../databases/entities/User'; import { PublicUser } from './Interfaces'; -export function isEmailSetup(): boolean { - const emailMode = config.get('userManagement.emails.mode') as string; - return !!emailMode; +export const isEmailSetUp = Boolean(config.get('userManagement.emails.mode')); + +/** + * Return the n8n instance base URL without trailing slash. + */ +export function getInstanceBaseUrl(): string { + const baseUrl = GenericHelpers.getBaseUrl(); + return baseUrl.endsWith('/') ? baseUrl.slice(0, baseUrl.length - 1) : baseUrl; } export async function isInstanceOwnerSetup(): Promise { @@ -17,14 +22,15 @@ export async function isInstanceOwnerSetup(): Promise { return users.length !== 0; } +// TODO: Enforce at model level export function validatePassword(password?: string): string { if (!password) { throw new ResponseHelper.ResponseError('Password is mandatory', undefined, 400); } - if (password.length < 8 || password.length > 64) { + if (password.length < MIN_PASSWORD_LENGTH || password.length > MAX_PASSWORD_LENGTH) { throw new ResponseHelper.ResponseError( - 'Password must be 8 to 64 characters long', + `Password must be ${MIN_PASSWORD_LENGTH} to ${MAX_PASSWORD_LENGTH} characters long`, undefined, 400, ); diff --git a/packages/cli/src/UserManagement/routes/index.ts b/packages/cli/src/UserManagement/routes/index.ts index 0201fd91b296b..148991aefb5a2 100644 --- a/packages/cli/src/UserManagement/routes/index.ts +++ b/packages/cli/src/UserManagement/routes/index.ts @@ -56,6 +56,7 @@ export function addRoutes(this: N8nApp, ignoredEndpoints: string[], restEndpoint req.url.startsWith(`/${restEndpoint}/settings`) || req.url === `/${restEndpoint}/user` || req.url.startsWith(`/${restEndpoint}/resolve-signup-token`) || + (req.method === 'POST' && new RegExp(`/${restEndpoint}/users/[\\w\\d-]*`).test(req.url)) || req.url.startsWith(`/${restEndpoint}/forgot-password`) || req.url.startsWith(`/${restEndpoint}/resolve-password-token`) || req.url.startsWith(`/${restEndpoint}/change-password`) diff --git a/packages/cli/src/UserManagement/routes/owner.ts b/packages/cli/src/UserManagement/routes/owner.ts index 00d5323c6386a..7bb66076054f1 100644 --- a/packages/cli/src/UserManagement/routes/owner.ts +++ b/packages/cli/src/UserManagement/routes/owner.ts @@ -11,7 +11,7 @@ import { validateEntity } from '../../GenericHelpers'; import { OwnerRequest } from '../../requests'; import { issueCookie } from '../auth/jwt'; import { N8nApp } from '../Interfaces'; -import { sanitizeUser } from '../UserManagementHelper'; +import { sanitizeUser, validatePassword } from '../UserManagementHelper'; export function ownerNamespace(this: N8nApp): void { /** @@ -32,13 +32,7 @@ export function ownerNamespace(this: N8nApp): void { throw new ResponseHelper.ResponseError('Invalid email address', undefined, 400); } - if (!password) { - throw new ResponseHelper.ResponseError( - 'Password does not comply to security standards', - undefined, - 400, - ); - } + const validPassword = validatePassword(password); if (!firstName || !lastName) { throw new ResponseHelper.ResponseError( @@ -59,7 +53,7 @@ export function ownerNamespace(this: N8nApp): void { email, firstName, lastName, - password: hashSync(password, genSaltSync(10)), + password: hashSync(validPassword, genSaltSync(10)), globalRole, id: userId, }); diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts index 77db45b73b4f7..b576c48e23340 100644 --- a/packages/cli/src/UserManagement/routes/users.ts +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -1,26 +1,36 @@ /* eslint-disable import/no-cycle */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { Request, Response } from 'express'; +import { Response } from 'express'; import { getConnection, In } from 'typeorm'; -import { LoggerProxy } from 'n8n-workflow'; import { genSaltSync, hashSync } from 'bcryptjs'; import validator from 'validator'; -import { Db, GenericHelpers, ResponseHelper } from '../..'; +import { Db, ResponseHelper } from '../..'; import { N8nApp } from '../Interfaces'; -import { AuthenticatedRequest, UserRequest } from '../../requests'; -import { isEmailSetup, sanitizeUser } from '../UserManagementHelper'; +import { UserRequest } from '../../requests'; +import { + getInstanceBaseUrl, + isEmailSetUp, + sanitizeUser, + validatePassword, +} from '../UserManagementHelper'; import { User } from '../../databases/entities/User'; import { SharedWorkflow } from '../../databases/entities/SharedWorkflow'; import { SharedCredentials } from '../../databases/entities/SharedCredentials'; import { getInstance } from '../email/UserManagementMailer'; + +import config = require('../../../config'); +import { LoggerProxy } from '../../../../workflow/dist/src'; import { issueCookie } from '../auth/jwt'; export function usersNamespace(this: N8nApp): void { + /** + * Send email invite(s) to one or multiple users and create user shell(s). + */ this.app.post( `/${this.restEndpoint}/users`, ResponseHelper.send(async (req: UserRequest.Invite) => { - if (!isEmailSetup()) { + if (config.get('userManagement.emails.mode') === '') { throw new ResponseHelper.ResponseError( 'Email sending must be set up in order to invite other users', undefined, @@ -28,35 +38,31 @@ export function usersNamespace(this: N8nApp): void { ); } - const invitations = req.body; - - if (!Array.isArray(invitations)) { + if (!Array.isArray(req.body)) { throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); } + if (!req.body.length) return []; + const createUsers: { [key: string]: string | null } = {}; // Validate payload - invitations.forEach((invitation) => { - if (!validator.isEmail(invitation.email)) { + req.body.forEach((invite) => { + if (typeof invite !== 'object' || !invite.email) { + throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); + } + + if (!validator.isEmail(invite.email)) { throw new ResponseHelper.ResponseError( - `Invalid email address ${invitation.email}`, + `Invalid email address ${invite.email}`, undefined, 400, ); } - createUsers[invitation.email] = null; + createUsers[invite.email] = null; }); const role = await Db.collections.Role!.findOne({ scope: 'global', name: 'member' }); - if (!role) { - 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)) }, @@ -90,23 +96,21 @@ export function usersNamespace(this: N8nApp): void { throw new ResponseHelper.ResponseError(`An error occurred during user creation`); } - let domain = GenericHelpers.getBaseUrl(); - if (domain.endsWith('/')) { - domain = domain.slice(0, domain.length - 1); - } + const baseUrl = getInstanceBaseUrl(); // send invite email to new or not yet setup users const mailer = getInstance(); + return Promise.all( Object.entries(createUsers) .filter(([email, id]) => id && email) .map(async ([email, id]) => { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const inviteAcceptUrl = `${domain}/signup/inviterId=${req.user.id}&inviteeId=${id}`; + const inviteAcceptUrl = `${baseUrl}/signup/inviterId=${req.user.id}&inviteeId=${id}`; const result = await mailer.invite({ email, inviteAcceptUrl, - domain, + domain: baseUrl, }); const resp: { id: string | null; email: string; error?: string } = { id, @@ -122,11 +126,13 @@ export function usersNamespace(this: N8nApp): void { }), ); + /** + * Validate invite token to enable invitee to set up their account. + */ this.app.get( `/${this.restEndpoint}/resolve-signup-token`, - ResponseHelper.send(async (req: Request) => { - const inviterId = req.query.inviterId as string; - const inviteeId = req.query.inviteeId as string; + ResponseHelper.send(async (req: UserRequest.ResolveSignUp) => { + const { inviterId, inviteeId } = req.query; if (!inviterId || !inviteeId) { LoggerProxy.error('Invalid invite URL - did not receive user IDs', { @@ -152,46 +158,42 @@ export function usersNamespace(this: N8nApp): void { }); throw new ResponseHelper.ResponseError('Invalid request', undefined, 400); } + const { firstName, lastName } = inviter; return { inviter: { firstName, lastName } }; }), ); + /** + * Fill out user shell with first name, last name, and password. + * + * Authless endpoint. + */ this.app.post( - `/${this.restEndpoint}/user`, - ResponseHelper.send(async (req: AuthenticatedRequest, res: Response) => { - if (req.user) { - throw new ResponseHelper.ResponseError( - 'Please logout before accepting another invite.', - undefined, - 500, - ); - } + `/${this.restEndpoint}/users/:id`, + ResponseHelper.send(async (req: UserRequest.Update, res: Response) => { + const { id: inviteeId } = req.params; - const { inviterId, inviteeId, firstName, lastName, password } = req.body as { - inviterId: string; - inviteeId: string; - firstName: string; - lastName: string; - password: string; - }; + const { inviterId, firstName, lastName, password } = req.body; if (!inviterId || !inviteeId || !firstName || !lastName || !password) { throw new ResponseHelper.ResponseError('Invalid payload', undefined, 400); } + const validPassword = validatePassword(password); + const users = await Db.collections.User!.find({ where: { id: In([inviterId, inviteeId]) }, }); if (users.length !== 2) { - throw new ResponseHelper.ResponseError('Invalid invite URL', undefined, 400); + throw new ResponseHelper.ResponseError('Invalid payload or URL', undefined, 400); } - const invitee = users.find((user) => user.id === inviteeId); + const invitee = users.find((user) => user.id === inviteeId) as User; - if (!invitee || invitee.password) { + if (invitee.password) { throw new ResponseHelper.ResponseError( 'This invite has been accepted already', undefined, @@ -201,7 +203,7 @@ export function usersNamespace(this: N8nApp): void { invitee.firstName = firstName; invitee.lastName = lastName; - invitee.password = hashSync(password, genSaltSync(10)); + invitee.password = hashSync(validPassword, genSaltSync(10)); const updatedUser = await Db.collections.User!.save(invitee); @@ -216,78 +218,91 @@ export function usersNamespace(this: N8nApp): void { ResponseHelper.send(async () => { const users = await Db.collections.User!.find({ relations: ['globalRole'] }); - return users.map((user) => sanitizeUser(user)); + return users.map(sanitizeUser); }), ); + /** + * Delete a user. Optionally, designate a transferee for their workflows and credentials. + */ this.app.delete( `/${this.restEndpoint}/users/:id`, ResponseHelper.send(async (req: UserRequest.Delete) => { - if (req.user.id === req.params.id) { - throw new ResponseHelper.ResponseError('You cannot delete your own user', undefined, 400); + const { id: idToDelete } = req.params; + + if (req.user.id === idToDelete) { + throw new ResponseHelper.ResponseError('Cannot delete your own user', undefined, 400); } const { transferId } = req.query; - const searchIds = [req.params.id]; - if (transferId) { - if (transferId === req.params.id) { - throw new ResponseHelper.ResponseError( - 'Removed user and transferred user cannot be the same', - undefined, - 400, - ); - } - searchIds.push(transferId); + if (transferId === idToDelete) { + throw new ResponseHelper.ResponseError( + 'User to delete and transferee cannot be the same', + undefined, + 400, + ); } - const users = await Db.collections.User!.find({ where: { id: In(searchIds) } }); - if ((transferId && users.length !== 2) || users.length === 0) { + const users = await Db.collections.User!.find({ + where: { id: In([transferId, idToDelete]) }, + }); + + if (!users.length || (transferId && users.length !== 2)) { throw new ResponseHelper.ResponseError('Could not find user', undefined, 404); } - const deleteUser = users.find((user) => user.id === req.params.id) as User; + const userToDelete = users.find((user) => user.id === req.params.id) as User; if (transferId) { - const transferUser = users.find((user) => user.id === transferId) as User; + const transferee = users.find((user) => user.id === transferId); await getConnection().transaction(async (transactionManager) => { await transactionManager.update( SharedWorkflow, - { user: deleteUser }, - { user: transferUser }, + { user: userToDelete }, + { user: transferee }, ); await transactionManager.update( SharedCredentials, - { user: deleteUser }, - { user: transferUser }, + { user: userToDelete }, + { user: transferee }, ); - await transactionManager.delete(User, { id: deleteUser.id }); - }); - } else { - const [ownedWorkflows, ownedCredentials] = await Promise.all([ - Db.collections.SharedWorkflow!.find({ - relations: ['workflow'], - where: { user: deleteUser }, - }), - Db.collections.SharedCredentials!.find({ - relations: ['credentials'], - where: { user: deleteUser }, - }), - ]); - await getConnection().transaction(async (transactionManager) => { - await transactionManager.remove(ownedWorkflows.map(({ workflow }) => workflow)); - await transactionManager.remove(ownedCredentials.map(({ credentials }) => credentials)); - await transactionManager.delete(User, { id: deleteUser.id }); + await transactionManager.delete(User, { id: userToDelete.id }); }); + + return { success: true }; } + + const [ownedWorkflows, ownedCredentials] = await Promise.all([ + Db.collections.SharedWorkflow!.find({ + relations: ['workflow'], + where: { user: userToDelete }, + }), + Db.collections.SharedCredentials!.find({ + relations: ['credentials'], + where: { user: userToDelete }, + }), + ]); + + await getConnection().transaction(async (transactionManager) => { + await transactionManager.remove(ownedWorkflows.map(({ workflow }) => workflow)); + await transactionManager.remove(ownedCredentials.map(({ credentials }) => credentials)); + await transactionManager.delete(User, { id: userToDelete.id }); + }); + return { success: true }; }), ); + /** + * Resend email invite to user. + */ this.app.post( `/${this.restEndpoint}/users/:id/reinvite`, ResponseHelper.send(async (req: UserRequest.Reinvite) => { - if (!isEmailSetup()) { + const { id: idToReinvite } = req.params; + + if (!isEmailSetUp) { throw new ResponseHelper.ResponseError( 'Email sending must be set up in order to invite other users', undefined, @@ -295,13 +310,13 @@ export function usersNamespace(this: N8nApp): void { ); } - const user = await Db.collections.User!.findOne({ id: req.params.id }); + const reinvitee = await Db.collections.User!.findOne({ id: idToReinvite }); - if (!user) { - throw new ResponseHelper.ResponseError('User not found', undefined, 404); + if (!reinvitee) { + throw new ResponseHelper.ResponseError('Could not find user', undefined, 404); } - if (user.password) { + if (reinvitee.password) { throw new ResponseHelper.ResponseError( 'User has already accepted the invite', undefined, @@ -309,27 +324,22 @@ export function usersNamespace(this: N8nApp): void { ); } - let domain = GenericHelpers.getBaseUrl(); - if (domain.endsWith('/')) { - domain = domain.slice(0, domain.length - 1); - } - - const inviteAcceptUrl = `${domain}/signup/inviterId=${req.user.id}&inviteeId=${user.id}`; + const baseUrl = getInstanceBaseUrl(); - const mailer = getInstance(); - const result = await mailer.invite({ - email: user.email, - inviteAcceptUrl, - domain, + const result = await getInstance().invite({ + email: reinvitee.email, + inviteAcceptUrl: `${baseUrl}/signup/inviterId=${req.user.id}&inviteeId=${reinvitee.id}`, + domain: baseUrl, }); if (!result.success) { throw new ResponseHelper.ResponseError( - `Failed to send email to ${user.email}`, + `Failed to send email to ${reinvitee.email}`, undefined, 500, ); } + return { success: true }; }), ); diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 2efbabd9a9244..3dbed1ad817ab 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -78,9 +78,6 @@ export class User { @Column({ nullable: true }) @IsString({ message: 'Password must be of type string.' }) - @Length(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH, { - message: 'Password does not comply to security standards.', - }) password?: string; @Column({ type: String, nullable: true }) diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 4f932a58db77e..1fffb7475de7d 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -157,6 +157,13 @@ export declare namespace PasswordResetRequest { export declare namespace UserRequest { export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; + export type ResolveSignUp = AuthenticatedRequest< + {}, + {}, + {}, + { inviterId?: string; inviteeId?: string } + >; + export type SignUp = AuthenticatedRequest< { id: string }, { inviterId?: string; inviteeId?: string } @@ -165,6 +172,17 @@ export declare namespace UserRequest { export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>; export type Reinvite = AuthenticatedRequest<{ id: string }>; + + export type Update = AuthenticatedRequest< + { id: string }, + {}, + { + inviterId: string; + firstName: string; + lastName: string; + password: string; + } + >; } // ---------------------------------- diff --git a/packages/cli/test/integration/auth.endpoints.test.ts b/packages/cli/test/integration/auth.endpoints.test.ts index b989bced009bc..784a1d2a16127 100644 --- a/packages/cli/test/integration/auth.endpoints.test.ts +++ b/packages/cli/test/integration/auth.endpoints.test.ts @@ -3,40 +3,40 @@ import express = require('express'); import { getConnection } from 'typeorm'; import validator from 'validator'; import { v4 as uuid } from 'uuid'; -import * as request from 'supertest'; import config = require('../../config'); import * as utils from './shared/utils'; -import { LOGGED_OUT_RESPONSE_BODY, REST_PATH_SEGMENT } from './shared/constants'; +import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { Db } from '../../src'; import { User } from '../../src/databases/entities/User'; +import { Role } from '../../src/databases/entities/Role'; +import { randomEmail, randomValidPassword, randomName } from './shared/random'; +import { getGlobalOwnerRole } from './shared/utils'; + +let globalOwnerRole: Role; describe('auth endpoints', () => { describe('Owner requests', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({ auth: true }, { applyAuth: true }); + app = utils.initTestServer({ namespaces: ['auth'], applyAuth: true }); await utils.initTestDb(); - await utils.truncateUserTable(); + await utils.truncate(['User']); + + globalOwnerRole = await getGlobalOwnerRole(); }); beforeEach(async () => { - const role = await Db.collections.Role!.findOneOrFail({ name: 'owner', scope: 'global' }); - - const newOwner = new User(); - - Object.assign(newOwner, { + await utils.createUser({ id: uuid(), email: TEST_USER.email, firstName: TEST_USER.firstName, lastName: TEST_USER.lastName, password: hashSync(TEST_USER.password, genSaltSync(10)), - globalRole: role, + role: globalOwnerRole, }); - await Db.collections.User!.save(newOwner); - config.set('userManagement.hasOwner', true); await Db.collections.Settings!.update( @@ -46,7 +46,7 @@ describe('auth endpoints', () => { }); afterEach(async () => { - await utils.truncateUserTable(); + await utils.truncate(['User']); }); afterAll(() => { @@ -54,10 +54,9 @@ describe('auth endpoints', () => { }); test('POST /login should log user in', async () => { - const cookieLessAgent = request.agent(app); - cookieLessAgent.use(utils.prefix(REST_PATH_SEGMENT)); + const authlessAgent = await utils.createAgent(app, { auth: false }); - const response = await cookieLessAgent.post('/login').send({ + const response = await authlessAgent.post('/login').send({ email: TEST_USER.email, password: TEST_USER.password, }); @@ -88,14 +87,14 @@ describe('auth endpoints', () => { expect(globalRole.scope).toBe('global'); const authToken = utils.getAuthToken(response); - expect(authToken).not.toBeUndefined(); + expect(authToken).toBeDefined(); }); test('GET /login should receive logged in user', async () => { const owner = await Db.collections.User!.findOneOrFail(); - const ownerAgent = await utils.createAuthAgent(app, owner); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); - const response = await ownerAgent.get('/login'); + const response = await authOwnerAgent.get('/login'); expect(response.statusCode).toBe(200); @@ -127,9 +126,9 @@ describe('auth endpoints', () => { test('POST /logout should log user out', async () => { const owner = await Db.collections.User!.findOneOrFail(); - const ownerAgent = await utils.createAuthAgent(app, owner); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); - const response = await ownerAgent.post('/logout'); + const response = await authOwnerAgent.post('/logout'); expect(response.statusCode).toBe(200); expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); @@ -141,8 +140,8 @@ describe('auth endpoints', () => { }); const TEST_USER = { - email: utils.randomEmail(), - password: utils.randomValidPassword(), - firstName: utils.randomName(), - lastName: utils.randomName(), + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), }; diff --git a/packages/cli/test/integration/auth.middleware.test.ts b/packages/cli/test/integration/auth.middleware.test.ts index 77601a475787e..0e193f596079c 100644 --- a/packages/cli/test/integration/auth.middleware.test.ts +++ b/packages/cli/test/integration/auth.middleware.test.ts @@ -7,14 +7,12 @@ import * as utils from './shared/utils'; describe('/me endpoints', () => { let app: express.Application; - const meRoutes = ['GET /me', 'PATCH /me', 'PATCH /me/password', 'POST /me/survey']; - beforeAll(async () => { - app = utils.initTestServer({}, { applyAuth: true }); + app = utils.initTestServer({ applyAuth: true }); }); describe('Unauthorized requests', () => { - meRoutes.forEach((route) => { + ['GET /me', 'PATCH /me', 'PATCH /me/password', 'POST /me/survey'].forEach((route) => { const [method, endpoint] = route.split(' ').map((i) => i.toLowerCase()); test(`${route} should return 401 Unauthorized`, async () => { @@ -30,7 +28,7 @@ describe('/owner endpoint', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({}, { applyAuth: true }); + app = utils.initTestServer({ applyAuth: true }); }); describe('Unauthorized requests', () => { diff --git a/packages/cli/test/integration/me.endpoints.test.ts b/packages/cli/test/integration/me.endpoints.test.ts index 929d2275e5141..3b7640be65d70 100644 --- a/packages/cli/test/integration/me.endpoints.test.ts +++ b/packages/cli/test/integration/me.endpoints.test.ts @@ -9,30 +9,35 @@ import * as utils from './shared/utils'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { Db } from '../../src'; import { User } from '../../src/databases/entities/User'; +import { Role } from '../../src/databases/entities/Role'; +import { + randomValidPassword, + randomInvalidPassword, + randomEmail, + randomName, + randomString, +} from './shared/random'; +import { getGlobalOwnerRole } from './shared/utils'; + +let globalOwnerRole: Role; describe('/me endpoints', () => { describe('Shell requests', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({ me: true }, { applyAuth: true }); + app = utils.initTestServer({ namespaces: ['me'], applyAuth: true }); await utils.initTestDb(); - await utils.truncateUserTable(); + + globalOwnerRole = await getGlobalOwnerRole(); }); beforeEach(async () => { - const role = await Db.collections.Role!.findOneOrFail({ name: 'owner', scope: 'global' }); - - await Db.collections.User!.save({ - id: uuid(), - createdAt: new Date(), - updatedAt: new Date(), - globalRole: role, - }); + await utils.createOwnerShell(); }); afterEach(async () => { - await utils.truncateUserTable(); + await utils.truncate(['User']); }); afterAll(() => { @@ -41,9 +46,9 @@ describe('/me endpoints', () => { test('GET /me should return sanitized shell', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); - const response = await shellAgent.get('/me'); + const response = await authShellAgent.get('/me'); expect(response.statusCode).toBe(200); @@ -71,10 +76,10 @@ describe('/me endpoints', () => { test('PATCH /me should succeed with valid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { - const response = await shellAgent.patch('/me').send(validPayload); + const response = await authShellAgent.patch('/me').send(validPayload); expect(response.statusCode).toBe(200); @@ -103,24 +108,24 @@ describe('/me endpoints', () => { test('PATCH /me should fail with invalid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { - const response = await shellAgent.patch('/me').send(invalidPayload); + const response = await authShellAgent.patch('/me').send(invalidPayload); expect(response.statusCode).toBe(400); } }); test('PATCH /me/password should succeed with valid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); const validPayloads = Array.from({ length: 3 }, () => ({ - password: utils.randomValidPassword(), + password: randomValidPassword(), })); for (const validPayload of validPayloads) { - const response = await shellAgent.patch('/me/password').send(validPayload); + const response = await authShellAgent.patch('/me/password').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); } @@ -128,29 +133,29 @@ describe('/me endpoints', () => { test('PATCH /me/password should fail with invalid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); const invalidPayloads = [ - ...Array.from({ length: 3 }, () => ({ password: utils.randomInvalidPassword() })), + ...Array.from({ length: 3 }, () => ({ password: randomInvalidPassword() })), {}, undefined, '', ]; for (const invalidPayload of invalidPayloads) { - const response = await shellAgent.patch('/me/password').send(invalidPayload); + const response = await authShellAgent.patch('/me/password').send(invalidPayload); expect(response.statusCode).toBe(400); } }); test('POST /me/survey should succeed with valid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); const validPayloads = [SURVEY, {}]; for (const validPayload of validPayloads) { - const response = await shellAgent.post('/me/survey').send(validPayload); + const response = await authShellAgent.post('/me/survey').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); } @@ -161,13 +166,16 @@ describe('/me endpoints', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({ me: true }, { applyAuth: true }); + app = utils.initTestServer({ namespaces: ['me'], applyAuth: true }); await utils.initTestDb(); - await utils.truncateUserTable(); + await utils.truncate(['User']); }); beforeEach(async () => { - const role = await Db.collections.Role!.findOneOrFail({ name: 'member', scope: 'global' }); + const globalMemberRole = await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); const newMember = new User(); @@ -176,8 +184,8 @@ describe('/me endpoints', () => { email: TEST_USER.email, firstName: TEST_USER.firstName, lastName: TEST_USER.lastName, - password: hashSync(utils.randomValidPassword(), genSaltSync(10)), - globalRole: role, + password: hashSync(randomValidPassword(), genSaltSync(10)), + globalRole: globalMemberRole, }); await Db.collections.User!.save(newMember); @@ -191,7 +199,7 @@ describe('/me endpoints', () => { }); afterEach(async () => { - await utils.truncateUserTable(); + await utils.truncate(['User']); }); afterAll(() => { @@ -200,9 +208,9 @@ describe('/me endpoints', () => { test('GET /me should return sanitized member', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); - const response = await memberAgent.get('/me'); + const response = await authMemberAgent.get('/me'); expect(response.statusCode).toBe(200); @@ -230,10 +238,10 @@ describe('/me endpoints', () => { test('PATCH /me should succeed with valid inputs', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { - const response = await memberAgent.patch('/me').send(validPayload); + const response = await authMemberAgent.patch('/me').send(validPayload); expect(response.statusCode).toBe(200); @@ -262,24 +270,24 @@ describe('/me endpoints', () => { test('PATCH /me should fail with invalid inputs', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { - const response = await memberAgent.patch('/me').send(invalidPayload); + const response = await authMemberAgent.patch('/me').send(invalidPayload); expect(response.statusCode).toBe(400); } }); test('PATCH /me/password should succeed with valid inputs', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); const validPayloads = Array.from({ length: 3 }, () => ({ - password: utils.randomValidPassword(), + password: randomValidPassword(), })); for (const validPayload of validPayloads) { - const response = await memberAgent.patch('/me/password').send(validPayload); + const response = await authMemberAgent.patch('/me/password').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); } @@ -287,29 +295,29 @@ describe('/me endpoints', () => { test('PATCH /me/password should fail with invalid inputs', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); const invalidPayloads = [ - ...Array.from({ length: 3 }, () => ({ password: utils.randomInvalidPassword() })), + ...Array.from({ length: 3 }, () => ({ password: randomInvalidPassword() })), {}, undefined, '', ]; for (const invalidPayload of invalidPayloads) { - const response = await memberAgent.patch('/me/password').send(invalidPayload); + const response = await authMemberAgent.patch('/me/password').send(invalidPayload); expect(response.statusCode).toBe(400); } }); test('POST /me/survey should succeed with valid inputs', async () => { const member = await Db.collections.User!.findOneOrFail(); - const memberAgent = await utils.createAuthAgent(app, member); + const authMemberAgent = await utils.createAgent(app, { auth: true, user: member }); const validPayloads = [SURVEY, {}]; for (const validPayload of validPayloads) { - const response = await memberAgent.post('/me/survey').send(validPayload); + const response = await authMemberAgent.post('/me/survey').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); } @@ -320,37 +328,26 @@ describe('/me endpoints', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({ me: true }, { applyAuth: true }); + app = utils.initTestServer({ namespaces: ['me'], applyAuth: true }); await utils.initTestDb(); - await utils.truncateUserTable(); + await utils.truncate(['User']); }); beforeEach(async () => { - const role = await Db.collections.Role!.findOneOrFail({ name: 'owner', scope: 'global' }); - - const newOwner = new User(); - - Object.assign(newOwner, { + await Db.collections.User!.save({ id: uuid(), email: TEST_USER.email, firstName: TEST_USER.firstName, lastName: TEST_USER.lastName, - password: hashSync(utils.randomValidPassword(), genSaltSync(10)), - globalRole: role, + password: hashSync(randomValidPassword(), genSaltSync(10)), + globalRole: globalOwnerRole, }); - await Db.collections.User!.save(newOwner); - config.set('userManagement.hasOwner', true); - - await Db.collections.Settings!.update( - { key: 'userManagement.hasOwner' }, - { value: JSON.stringify(true) }, - ); }); afterEach(async () => { - await utils.truncateUserTable(); + await utils.truncate(['User']); }); afterAll(() => { @@ -359,9 +356,9 @@ describe('/me endpoints', () => { test('GET /me should return sanitized owner', async () => { const owner = await Db.collections.User!.findOneOrFail(); - const ownerAgent = await utils.createAuthAgent(app, owner); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); - const response = await ownerAgent.get('/me'); + const response = await authOwnerAgent.get('/me'); expect(response.statusCode).toBe(200); @@ -389,10 +386,10 @@ describe('/me endpoints', () => { test('PATCH /me should succeed with valid inputs', async () => { const owner = await Db.collections.User!.findOneOrFail(); - const ownerAgent = await utils.createAuthAgent(app, owner); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { - const response = await ownerAgent.patch('/me').send(validPayload); + const response = await authOwnerAgent.patch('/me').send(validPayload); expect(response.statusCode).toBe(200); @@ -422,9 +419,9 @@ describe('/me endpoints', () => { }); const TEST_USER = { - email: utils.randomEmail(), - firstName: utils.randomName(), - lastName: utils.randomName(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), }; const SURVEY = [ @@ -435,63 +432,63 @@ const SURVEY = [ 'otherWorkArea', 'workArea', ].reduce>((acc, cur) => { - return (acc[cur] = utils.randomString(1, 10)), acc; + return (acc[cur] = randomString(1, 10)), acc; }, {}); const VALID_PATCH_ME_PAYLOADS = [ { - email: utils.randomEmail(), - firstName: utils.randomName(), - lastName: utils.randomName(), - password: utils.randomValidPassword(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), }, { - email: utils.randomEmail(), - firstName: utils.randomName(), - lastName: utils.randomName(), - password: utils.randomValidPassword(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), }, ]; const INVALID_PATCH_ME_PAYLOADS = [ { email: 'invalid', - firstName: utils.randomName(), - lastName: utils.randomName(), + firstName: randomName(), + lastName: randomName(), }, { - email: utils.randomEmail(), + email: randomEmail(), firstName: '', - lastName: utils.randomName(), + lastName: randomName(), }, { - email: utils.randomEmail(), - firstName: utils.randomName(), + email: randomEmail(), + firstName: randomName(), lastName: '', }, { - email: utils.randomEmail(), + email: randomEmail(), firstName: 123, - lastName: utils.randomName(), + lastName: randomName(), }, { - firstName: utils.randomName(), - lastName: utils.randomName(), + firstName: randomName(), + lastName: randomName(), }, { - firstName: utils.randomName(), + firstName: randomName(), }, { - lastName: utils.randomName(), + lastName: randomName(), }, { - email: utils.randomEmail(), + email: randomEmail(), firstName: 'John { describe('Shell requests', () => { let app: express.Application; beforeAll(async () => { - app = utils.initTestServer({ owner: true }, { applyAuth: true }); + app = utils.initTestServer({ namespaces: ['owner'], applyAuth: true }); await utils.initTestDb(); - await utils.truncateUserTable(); + + globalOwnerRole = await getGlobalOwnerRole(); }); beforeEach(async () => { - const role = await Db.collections.Role!.findOneOrFail({ name: 'owner', scope: 'global' }); - - await Db.collections.User!.save({ - id: uuid(), - createdAt: new Date(), - updatedAt: new Date(), - globalRole: role, - }); + await utils.createOwnerShell(); }); afterEach(async () => { - await utils.truncateUserTable(); + await utils.truncate(['User']); }); afterAll(() => { @@ -38,9 +37,9 @@ describe('/owner endpoints', () => { test('POST /owner should create owner and enable hasOwner setting', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); - const response = await shellAgent.post('/owner').send(TEST_USER); + const response = await authShellAgent.post('/owner').send(TEST_USER); expect(response.statusCode).toBe(200); @@ -77,10 +76,10 @@ describe('/owner endpoints', () => { test('POST /owner should fail with invalid inputs', async () => { const shell = await Db.collections.User!.findOneOrFail(); - const shellAgent = await utils.createAuthAgent(app, shell); + const authShellAgent = await utils.createAgent(app, { auth: true, user: shell }); for (const invalidPayload of INVALID_POST_OWNER_PAYLOADS) { - const response = await shellAgent.post('/owner').send(invalidPayload); + const response = await authShellAgent.post('/owner').send(invalidPayload); expect(response.statusCode).toBe(400); } }); @@ -88,55 +87,55 @@ describe('/owner endpoints', () => { }); const TEST_USER = { - email: utils.randomEmail(), - firstName: utils.randomName(), - lastName: utils.randomName(), - password: utils.randomValidPassword(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), }; const INVALID_POST_OWNER_PAYLOADS = [ { email: '', - firstName: utils.randomName(), - lastName: utils.randomName(), - password: utils.randomValidPassword(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), }, { - email: utils.randomEmail(), + email: randomEmail(), firstName: '', - lastName: utils.randomName(), - password: utils.randomValidPassword(), + lastName: randomName(), + password: randomValidPassword(), }, { - email: utils.randomEmail(), - firstName: utils.randomName(), + email: randomEmail(), + firstName: randomName(), lastName: '', - password: utils.randomValidPassword(), + password: randomValidPassword(), }, { - email: utils.randomEmail(), - firstName: utils.randomName(), - lastName: utils.randomName(), - password: utils.randomInvalidPassword(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), }, { - firstName: utils.randomName(), - lastName: utils.randomName(), + firstName: randomName(), + lastName: randomName(), }, { - firstName: utils.randomName(), + firstName: randomName(), }, { - lastName: utils.randomName(), + lastName: randomName(), }, { - email: utils.randomEmail(), + email: randomEmail(), firstName: 'John (array: T[]) => array[Math.floor(Math.random() * array.length)]; + +export const randomValidPassword = () => randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH); + +export const randomInvalidPassword = () => + chooseRandomly([ + randomString(1, MIN_PASSWORD_LENGTH - 1), + randomString(MAX_PASSWORD_LENGTH + 1, 100), + ]); + +export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLevelDomain()}`; + +const POPULAR_TOP_LEVEL_DOMAINS = ['com', 'org', 'net', 'io', 'edu']; + +const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); + +export const randomName = () => randomString(3, 7); diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts new file mode 100644 index 0000000000000..ad70b3eef01a2 --- /dev/null +++ b/packages/cli/test/integration/shared/types.d.ts @@ -0,0 +1,15 @@ +import type { N8nApp } from "../../../src/UserManagement/Interfaces"; + +export type SmtpTestAccount = { + user: string; + pass: string; + smtp: { + host: string; + port: number; + secure: boolean; + }; +}; + +export type EndpointNamespace = 'me' | 'users' | 'auth' | 'owner'; + +export type NamespacesMap = Readonly void>>; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index ccd3a9f544b70..65f6eeaa58769 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -1,34 +1,51 @@ -import { randomBytes } from 'crypto'; import express = require('express'); import * as superagent from 'superagent'; import * as request from 'supertest'; import { URL } from 'url'; import bodyParser = require('body-parser'); +import * as util from 'util'; +import { createTestAccount } from 'nodemailer'; +import { v4 as uuid } from 'uuid'; +import { LoggerProxy } from 'n8n-workflow'; import config = require('../../../config'); import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; -import { Db } from '../../../src'; -import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, User } from '../../../src/databases/entities/User'; +import { Db, IDatabaseCollections } from '../../../src'; +import { User } from '../../../src/databases/entities/User'; 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 { getConnection } from 'typeorm'; import { issueJWT } from '../../../src/UserManagement/auth/jwt'; - -export const isTestRun = process.argv[1].split('/').includes('jest'); - -const POPULAR_TOP_LEVEL_DOMAINS = ['com', 'org', 'net', 'io', 'edu']; +import { randomEmail, randomValidPassword, randomName } from './random'; +import type { EndpointNamespace, NamespacesMap, SmtpTestAccount } from './types'; +import { Role } from '../../../src/databases/entities/Role'; +import { getLogger } from '../../../src/Logger'; + +// ---------------------------------- +// test server +// ---------------------------------- + +export const initLogger = () => { + config.set('logs.output', 'file'); // declutter console output during tests + LoggerProxy.init(getLogger()); +}; /** - * Initialize a test server to make requests from, - * passing in endpoints to enable in the test server. + * Initialize a test server to make requests to. + * + * @param applyAuth Whether to apply auth middleware to the test server. + * @param namespaces Namespaces of endpoints to apply to the test server. */ -export const initTestServer = ( - endpointNamespaces: { [K in 'me' | 'users' | 'auth' | 'owner']?: true } = {}, - { applyAuth } = { applyAuth: false }, -) => { +export function initTestServer({ + applyAuth, + namespaces, +}: { + applyAuth: boolean; + namespaces?: EndpointNamespace[]; +}) { const testServer = { app: express(), restEndpoint: REST_PATH_SEGMENT, @@ -44,47 +61,148 @@ export const initTestServer = ( authMiddleware.apply(testServer, [AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT]); } - if (endpointNamespaces.me) meEndpoints.apply(testServer); - if (endpointNamespaces.users) usersEndpoints.apply(testServer); - if (endpointNamespaces.auth) authEndpoints.apply(testServer); - if (endpointNamespaces.owner) ownerEndpoints.apply(testServer); + if (namespaces) { + const map: NamespacesMap = { + me: meEndpoints, + users: usersEndpoints, + auth: authEndpoints, + owner: ownerEndpoints, + }; + + for (const namespace of namespaces) { + map[namespace].apply(testServer); + } + } return testServer.app; -}; +} + +// ---------------------------------- +// test DB +// ---------------------------------- export async function initTestDb() { await Db.init(); await getConnection().runMigrations({ transaction: 'none' }); } -export async function truncateUserTable() { +export async function truncate(entities: Array) { await getConnection().query('PRAGMA foreign_keys=OFF'); - await Db.collections.User!.clear(); + await Promise.all(entities.map((entity) => Db.collections[entity]!.clear())); await getConnection().query('PRAGMA foreign_keys=ON'); } -export async function createAuthAgent(app: express.Application, user: User) { - const agent = request.agent(app); - agent.use(prefix(REST_PATH_SEGMENT)); +/** + * Store a user in the DB, defaulting to a `member`. + */ +export async function createUser( + { + id, + email, + password, + firstName, + lastName, + role, + }: { + id: string; + email: string; + password: string; + firstName: string; + lastName: string; + role?: Role; + } = { + id: uuid(), + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), + }, +) { + return await Db.collections.User!.save({ + id, + email, + password, + firstName, + lastName, + createdAt: new Date(), + updatedAt: new Date(), + globalRole: role ?? (await getGlobalMemberRole()), + }); +} - const { token } = await issueJWT(user); - agent.jar.setCookie(`n8n-auth=${token}`); +export async function createOwnerShell() { + await Db.collections.User!.save({ + id: uuid(), + createdAt: new Date(), + updatedAt: new Date(), + globalRole: await getGlobalOwnerRole(), + }); +} - return agent; +export async function getGlobalOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'global', + }); +} + +export async function getGlobalMemberRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); } -export async function createAgent(app: express.Application, user: User) { +export async function getWorkflowOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); +} + +export async function getCredentialOwnerRole() { + return await Db.collections.Role!.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); +} + +export function getAllRoles() { + return Promise.all([ + getGlobalOwnerRole(), + getGlobalMemberRole(), + getWorkflowOwnerRole(), + getCredentialOwnerRole(), + ]); +} + +// ---------------------------------- +// request agent +// ---------------------------------- + +export async function createAgent( + app: express.Application, + { auth, user }: { auth: boolean; user?: User } = { auth: false }, +) { const agent = request.agent(app); agent.use(prefix(REST_PATH_SEGMENT)); + if (auth && !user) { + throw new Error('User required for auth agent creation'); + } + + if (auth && user) { + const { token } = await issueJWT(user); + agent.jar.setCookie(`n8n-auth=${token}`); + } + return agent; } /** * Plugin to prefix a path segment into a request URL pathname. * - * Example: - * http://127.0.0.1:62100/me/password → http://127.0.0.1:62100/rest/me/password + * Example: http://127.0.0.1:62100/me/password → http://127.0.0.1:62100/rest/me/password */ export function prefix(pathSegment: string) { return function (request: superagent.SuperAgentRequest) { @@ -102,19 +220,16 @@ export function prefix(pathSegment: string) { }; } -export async function getHasOwnerSetting() { - const { value } = await Db.collections.Settings!.findOneOrFail({ - key: 'userManagement.hasOwner', - }); - - return Boolean(value); -} - /** * Extract the value (token) of the auth cookie in a response. */ export function getAuthToken(response: request.Response, authCookieName = 'n8n-auth') { const cookies: string[] = response.headers['set-cookie']; + + if (!cookies) { + throw new Error("No 'set-cookie' header found in response"); + } + const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`)); if (!authCookie) return undefined; @@ -126,26 +241,26 @@ export function getAuthToken(response: request.Response, authCookieName = 'n8n-a return match.groups.token; } -/** - * Create a random string of random length between two limits, both inclusive. - */ -export function randomString(min: number, max: number) { - const randomInteger = Math.floor(Math.random() * (max - min) + min) + 1; - return randomBytes(randomInteger / 2).toString('hex'); -} - -const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; +// ---------------------------------- +// settings +// ---------------------------------- -export const randomValidPassword = () => randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH); +export async function getHasOwnerSetting() { + const { value } = await Db.collections.Settings!.findOneOrFail({ + key: 'userManagement.hasOwner', + }); -export const randomInvalidPassword = () => - chooseRandomly([ - randomString(1, MIN_PASSWORD_LENGTH - 1), - randomString(MAX_PASSWORD_LENGTH + 1, 100), - ]); + return Boolean(value); +} -export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLevelDomain()}`; +// ---------------------------------- +// SMTP +// ---------------------------------- -const randomTopLevelDomain = () => chooseRandomly(POPULAR_TOP_LEVEL_DOMAINS); +/** + * Get an SMTP test account from https://ethereal.email to test sending emails. + */ +export const getSmtpTestAccount = util.promisify(createTestAccount); -export const randomName = () => randomString(3, 7); +// TODO: Phase out +export const isTestRun = process.argv[1].split('/').includes('jest'); diff --git a/packages/cli/test/integration/users.endpoints.test.ts b/packages/cli/test/integration/users.endpoints.test.ts new file mode 100644 index 0000000000000..1f46d55497e72 --- /dev/null +++ b/packages/cli/test/integration/users.endpoints.test.ts @@ -0,0 +1,570 @@ +import express = require('express'); +import { getConnection } from 'typeorm'; +import validator from 'validator'; +import { v4 as uuid } from 'uuid'; + +import * as utils from './shared/utils'; +import { Db } from '../../src'; +import config = require('../../config'); +import { SUCCESS_RESPONSE_BODY } from './shared/constants'; +import { getLogger } from '../../src/Logger'; +import { LoggerProxy } from 'n8n-workflow'; +import { Role } from '../../src/databases/entities/Role'; +import { + randomEmail, + randomValidPassword, + randomName, + randomInvalidPassword, +} from './shared/random'; +import { createUser } from './shared/utils'; +import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity'; +import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity'; +import { compare } from 'bcryptjs'; + +let app: express.Application; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; + +beforeAll(async () => { + app = utils.initTestServer({ namespaces: ['users'], applyAuth: true }); + await utils.initTestDb(); + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await utils.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + config.set('logs.output', 'file'); // declutter console output + utils.initLogger(); +}); + +beforeEach(async () => { + await utils.truncate(['User']); + + jest.isolateModules(() => { + jest.mock('../../config'); + }); + + await createUser({ + id: INITIAL_TEST_USER.id, + email: INITIAL_TEST_USER.email, + password: INITIAL_TEST_USER.password, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + role: globalOwnerRole, + }); + + config.set('userManagement.hasOwner', true); + config.set('userManagement.emails.mode', ''); +}); + +afterEach(async () => { + await utils.truncate(['User']); +}); + +afterAll(() => { + return getConnection().close(); +}); + +test('GET /users should return all users', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + await createUser(); + + const response = await authOwnerAgent.get('/users'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + for (const user of response.body.data) { + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + } = user; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeDefined(); + expect(lastName).toBeDefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + } +}); + +test('DELETE /users/:id should delete the user', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await createUser(); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const user = await Db.collections.User!.findOne(userToDelete.id); + expect(user).toBeUndefined(); + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedWorkflow).toBeUndefined(); + + const sharedCredential = await Db.collections.SharedCredentials!.findOne({ + relations: ['user'], + where: { user: userToDelete }, + }); + expect(sharedCredential).toBeUndefined(); + + const workflow = await Db.collections.Workflow!.findOne(savedWorkflow.id); + expect(workflow).toBeUndefined(); + + const credential = await Db.collections.Credentials!.findOne(savedCredential.id); + expect(credential).toBeUndefined(); +}); + +test('DELETE /users/:id should fail to delete self', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.delete(`/users/${owner.id}`); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(owner.id); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id should fail if user to delete is transferee', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const { id: idToDelete } = await createUser(); + + const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({ + transferId: idToDelete, + }); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOne(idToDelete); + expect(user).toBeDefined(); +}); + +test('DELETE /users/:id with transferId should perform transfer', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const userToDelete = await Db.collections.User!.save({ + id: uuid(), + email: randomEmail(), + password: randomValidPassword(), + firstName: randomName(), + lastName: randomName(), + createdAt: new Date(), + updatedAt: new Date(), + globalRole: workflowOwnerRole, + }); + + const newWorkflow = new WorkflowEntity(); + + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + }); + + const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow); + + await Db.collections.SharedWorkflow!.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials!.save(newCredential); + + await Db.collections.SharedCredentials!.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`).query({ + transferId: owner.id, + }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflow = await Db.collections.SharedWorkflow!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user'], + where: { user: owner }, + }); + + const deletedUser = await Db.collections.User!.findOne(userToDelete); + + expect(sharedWorkflow.user.id).toBe(owner.id); + expect(sharedCredential.user.id).toBe(owner.id); + expect(deletedUser).toBeUndefined(); + + await utils.truncate(['Credentials', 'Workflow']); +}); + +test('GET /resolve-signup-token should validate invite token', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await createUser(); + + const response = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + data: { + inviter: { + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + }, + }, + }); +}); + +test('GET /resolve-signup-token should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const { id: inviteeId } = await createUser(); + + const first = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }); + + const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); + + const third = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: '123', inviteeId: '456' }); + + await Db.collections.User!.update(owner.id, { email: '' }); // cause inconsistent DB state + + const fourth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: INITIAL_TEST_USER.id }) + .query({ inviteeId }); + + for (const response of [first, second, third, fourth]) { + expect(response.statusCode).toBe(400); + } +}); + +test('POST /users/:id should fill out a user shell', async () => { + const authlessAgent = await utils.createAgent(app, { auth: false }); + + const userToFillOut = await Db.collections.User!.save({ + email: randomEmail(), + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: INITIAL_TEST_USER.firstName, + lastName: INITIAL_TEST_USER.lastName, + password: newPassword, + }); + + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + password, + resetPasswordToken, + globalRole, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBe(INITIAL_TEST_USER.firstName); + expect(lastName).toBe(INITIAL_TEST_USER.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const filledOutUser = await Db.collections.User!.findOneOrFail(userToFillOut.id); + expect(filledOutUser.firstName).toBe(INITIAL_TEST_USER.firstName); + expect(filledOutUser.lastName).toBe(INITIAL_TEST_USER.lastName); + expect(filledOutUser.password).not.toBe(newPassword); +}); + +test('POST /users/:id should fail with invalid inputs', async () => { + const authlessAgent = await utils.createAgent(app, { auth: false }); + + const emailToStore = randomEmail(); + + const userToFillOut = await Db.collections.User!.save({ + email: emailToStore, + globalRole: globalMemberRole, + }); + + for (const invalidPayload of INVALID_FILL_OUT_USER_PAYLOADS) { + const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send(invalidPayload); + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User!.findOneOrFail({ where: { email: emailToStore } }); + expect(user.firstName).toBeNull(); + expect(user.lastName).toBeNull(); + expect(user.password).toBeNull(); + } +}); + +test('POST /users/:id should fail with already accepted invite', async () => { + const authlessAgent = await utils.createAgent(app, { auth: false }); + + const globalMemberRole = await Db.collections.Role!.findOneOrFail({ + name: 'member', + scope: 'global', + }); + + const shell = await Db.collections.User!.save({ + email: randomEmail(), + password: randomValidPassword(), // simulate accepted invite + globalRole: globalMemberRole, + }); + + const newPassword = randomValidPassword(); + + const response = await authlessAgent.post(`/users/${shell.id}`).send({ + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: newPassword, + }); + + expect(response.statusCode).toBe(400); + + const fetchedShell = await Db.collections.User!.findOneOrFail({ where: { email: shell.email } }); + expect(fetchedShell.firstName).toBeNull(); + expect(fetchedShell.lastName).toBeNull(); + + const comparisonResult = await compare(shell.password, newPassword); + expect(comparisonResult).toBe(false); + expect(newPassword).not.toBe(fetchedShell.password); +}); + +test('POST /users should fail if emailing is not set up', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(500); +}); + +test('POST /users should email invites and create user shells', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + const { + user, + pass, + smtp: { host, port, secure }, + } = await utils.getSmtpTestAccount(); + + config.set('userManagement.emails.mode', 'smtp'); + config.set('userManagement.emails.smtp.host', host); + config.set('userManagement.emails.smtp.port', port); + config.set('userManagement.emails.smtp.secure', secure); + config.set('userManagement.emails.smtp.auth.user', user); + config.set('userManagement.emails.smtp.auth.pass', pass); + + const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + + const response = await authOwnerAgent.post('/users').send(payload); + + expect(response.statusCode).toBe(200); + + for (const { id, email: receivedEmail } of response.body.data) { + expect(validator.isUUID(id)).toBe(true); + expect(TEST_EMAILS_TO_CREATE_USER_SHELLS.some((e) => e === receivedEmail)).toBe(true); + + const user = await Db.collections.User!.findOneOrFail(id); + const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = user; + + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeNull(); + expect(resetPasswordToken).toBeNull(); + } +}); + +test('POST /users should fail with invalid inputs', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + for (const invalidPayload of invalidPayloads) { + const response = await authOwnerAgent.post('/users').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); // DB unaffected + } +}); + +test('POST /users should ignore an empty payload', async () => { + const owner = await Db.collections.User!.findOneOrFail(); + const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authOwnerAgent.post('/users').send([]); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); + + const users = await Db.collections.User!.find(); + expect(users.length).toBe(1); +}); + +// TODO: UserManagementMailer is a singleton - cannot reinstantiate with wrong creds +// test('POST /users should error for wrong SMTP config', async () => { +// const owner = await Db.collections.User!.findOneOrFail(); +// const authOwnerAgent = await utils.createAgent(app, { auth: true, user: owner }); + +// config.set('userManagement.emails.mode', 'smtp'); +// config.set('userManagement.emails.smtp.host', 'XYZ'); // break SMTP config + +// const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e })); + +// const response = await authOwnerAgent.post('/users').send(payload); + +// expect(response.statusCode).toBe(500); +// }); + +const INITIAL_TEST_USER = { + id: uuid(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), +}; + +const INVALID_FILL_OUT_USER_PAYLOADS = [ + { + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + }, + { + inviterId: INITIAL_TEST_USER.id, + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, +]; + +const TEST_EMAILS_TO_CREATE_USER_SHELLS = [randomEmail(), randomEmail(), randomEmail()];