mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
refactor(core): Improve instance owner setup and add unit tests (no-changelog) (#5499)
* refactor(core): Avoid fetching all workflows and credentials for the owner setup screen * refactor(core): Add unit tests for the owner controller
This commit is contained in:
parent
12104bc4a3
commit
561882f599
|
@ -1,6 +1,6 @@
|
|||
import validator from 'validator';
|
||||
import { validateEntity } from '@/GenericHelpers';
|
||||
import { Post, RestController } from '@/decorators';
|
||||
import { Get, Post, RestController } from '@/decorators';
|
||||
import { BadRequestError } from '@/ResponseHelper';
|
||||
import {
|
||||
hashPassword,
|
||||
|
@ -13,9 +13,10 @@ import type { Repository } from 'typeorm';
|
|||
import type { ILogger } from 'n8n-workflow';
|
||||
import type { Config } from '@/config';
|
||||
import { OwnerRequest } from '@/requests';
|
||||
import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces';
|
||||
import type { IDatabaseCollections, IInternalHooksClass, ICredentialsDb } from '@/Interfaces';
|
||||
import type { Settings } from '@db/entities/Settings';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
|
||||
@RestController('/owner')
|
||||
export class OwnerController {
|
||||
|
@ -29,6 +30,10 @@ export class OwnerController {
|
|||
|
||||
private readonly settingsRepository: Repository<Settings>;
|
||||
|
||||
private readonly credentialsRepository: Repository<ICredentialsDb>;
|
||||
|
||||
private readonly workflowsRepository: Repository<WorkflowEntity>;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
logger,
|
||||
|
@ -38,23 +43,38 @@ export class OwnerController {
|
|||
config: Config;
|
||||
logger: ILogger;
|
||||
internalHooks: IInternalHooksClass;
|
||||
repositories: Pick<IDatabaseCollections, 'User' | 'Settings'>;
|
||||
repositories: Pick<IDatabaseCollections, 'User' | 'Settings' | 'Credentials' | 'Workflow'>;
|
||||
}) {
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
this.internalHooks = internalHooks;
|
||||
this.userRepository = repositories.User;
|
||||
this.settingsRepository = repositories.Settings;
|
||||
this.credentialsRepository = repositories.Credentials;
|
||||
this.workflowsRepository = repositories.Workflow;
|
||||
}
|
||||
|
||||
@Get('/pre-setup')
|
||||
async preSetup(): Promise<{ credentials: number; workflows: number }> {
|
||||
if (this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
throw new BadRequestError('Instance owner already setup');
|
||||
}
|
||||
|
||||
const [credentials, workflows] = await Promise.all([
|
||||
this.credentialsRepository.countBy({}),
|
||||
this.workflowsRepository.countBy({}),
|
||||
]);
|
||||
return { credentials, workflows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a shell into the owner of the n8n instance,
|
||||
* and enable `isInstanceOwnerSetUp` setting.
|
||||
*/
|
||||
@Post('/')
|
||||
async promoteOwner(req: OwnerRequest.Post, res: Response) {
|
||||
@Post('/setup')
|
||||
async setupOwner(req: OwnerRequest.Post, res: Response) {
|
||||
const { email, firstName, lastName, password } = req.body;
|
||||
const { id: userId } = req.user;
|
||||
const { id: userId, globalRole } = req.user;
|
||||
|
||||
if (this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
this.logger.debug(
|
||||
|
@ -63,7 +83,7 @@ export class OwnerController {
|
|||
userId,
|
||||
},
|
||||
);
|
||||
throw new BadRequestError('Invalid request');
|
||||
throw new BadRequestError('Instance owner already setup');
|
||||
}
|
||||
|
||||
if (!email || !validator.isEmail(email)) {
|
||||
|
@ -84,12 +104,8 @@ export class OwnerController {
|
|||
throw new BadRequestError('First and last names are mandatory');
|
||||
}
|
||||
|
||||
let owner = await this.userRepository.findOne({
|
||||
relations: ['globalRole'],
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!owner || (owner.globalRole.scope === 'global' && owner.globalRole.name !== 'owner')) {
|
||||
// TODO: This check should be in a middleware outside this class
|
||||
if (globalRole.scope === 'global' && globalRole.name !== 'owner') {
|
||||
this.logger.debug(
|
||||
'Request to claim instance ownership failed because user shell does not exist or has wrong role!',
|
||||
{
|
||||
|
@ -99,6 +115,8 @@ export class OwnerController {
|
|||
throw new BadRequestError('Invalid request');
|
||||
}
|
||||
|
||||
let owner = req.user;
|
||||
|
||||
Object.assign(owner, {
|
||||
email,
|
||||
firstName,
|
||||
|
@ -110,7 +128,7 @@ export class OwnerController {
|
|||
|
||||
owner = await this.userRepository.save(owner);
|
||||
|
||||
this.logger.info('Owner was set up successfully', { userId: req.user.id });
|
||||
this.logger.info('Owner was set up successfully', { userId });
|
||||
|
||||
await this.settingsRepository.update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
|
@ -119,7 +137,7 @@ export class OwnerController {
|
|||
|
||||
this.config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId: req.user.id });
|
||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully', { userId });
|
||||
|
||||
await issueCookie(res, owner);
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ export type AuthenticatedRequest<
|
|||
ResponseBody = {},
|
||||
RequestBody = {},
|
||||
RequestQuery = {},
|
||||
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
|
||||
> = Omit<express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user'> & {
|
||||
user: User;
|
||||
mailer?: UserManagementMailer.UserManagementMailer;
|
||||
globalMemberRole?: Role;
|
||||
|
|
|
@ -38,7 +38,7 @@ afterAll(async () => {
|
|||
await testDb.terminate();
|
||||
});
|
||||
|
||||
test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () => {
|
||||
test('POST /owner/setup should create owner and enable isInstanceOwnerSetUp', async () => {
|
||||
const ownerShell = await testDb.createUserShell(globalOwnerRole);
|
||||
|
||||
const newOwnerData = {
|
||||
|
@ -48,7 +48,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
|
|||
password: randomValidPassword(),
|
||||
};
|
||||
|
||||
const response = await authAgent(ownerShell).post('/owner').send(newOwnerData);
|
||||
const response = await authAgent(ownerShell).post('/owner/setup').send(newOwnerData);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
|
@ -90,7 +90,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
|
|||
expect(isInstanceOwnerSetUpSetting).toBe(true);
|
||||
});
|
||||
|
||||
test('POST /owner should create owner with lowercased email', async () => {
|
||||
test('POST /owner/setup should create owner with lowercased email', async () => {
|
||||
const ownerShell = await testDb.createUserShell(globalOwnerRole);
|
||||
|
||||
const newOwnerData = {
|
||||
|
@ -100,7 +100,7 @@ test('POST /owner should create owner with lowercased email', async () => {
|
|||
password: randomValidPassword(),
|
||||
};
|
||||
|
||||
const response = await authAgent(ownerShell).post('/owner').send(newOwnerData);
|
||||
const response = await authAgent(ownerShell).post('/owner/setup').send(newOwnerData);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
|
@ -113,13 +113,13 @@ test('POST /owner should create owner with lowercased email', async () => {
|
|||
expect(storedOwner.email).toBe(newOwnerData.email.toLowerCase());
|
||||
});
|
||||
|
||||
test('POST /owner should fail with invalid inputs', async () => {
|
||||
test('POST /owner/setup should fail with invalid inputs', async () => {
|
||||
const ownerShell = await testDb.createUserShell(globalOwnerRole);
|
||||
const authOwnerAgent = authAgent(ownerShell);
|
||||
|
||||
await Promise.all(
|
||||
INVALID_POST_OWNER_PAYLOADS.map(async (invalidPayload) => {
|
||||
const response = await authOwnerAgent.post('/owner').send(invalidPayload);
|
||||
const response = await authOwnerAgent.post('/owner/setup').send(invalidPayload);
|
||||
expect(response.statusCode).toBe(400);
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -31,7 +31,7 @@ export const ROUTES_REQUIRING_AUTHENTICATION: Readonly<string[]> = [
|
|||
'PATCH /me',
|
||||
'PATCH /me/password',
|
||||
'POST /me/survey',
|
||||
'POST /owner',
|
||||
'POST /owner/setup',
|
||||
'GET /non-existent',
|
||||
];
|
||||
|
||||
|
@ -42,7 +42,8 @@ export const ROUTES_REQUIRING_AUTHORIZATION: Readonly<string[]> = [
|
|||
'POST /users',
|
||||
'DELETE /users/123',
|
||||
'POST /users/123/reinvite',
|
||||
'POST /owner',
|
||||
'POST /owner/pre-setup',
|
||||
'POST /owner/setup',
|
||||
'POST /owner/skip-setup',
|
||||
];
|
||||
|
||||
|
|
|
@ -9,21 +9,18 @@ import { MeController } from '@/controllers';
|
|||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { BadRequestError } from '@/ResponseHelper';
|
||||
import type { AuthenticatedRequest, MeRequest } from '@/requests';
|
||||
import { badPasswords } from '../shared/testData';
|
||||
|
||||
describe('MeController', () => {
|
||||
const logger = mock<ILogger>();
|
||||
const externalHooks = mock<IExternalHooksClass>();
|
||||
const internalHooks = mock<IInternalHooksClass>();
|
||||
const userRepository = mock<Repository<User>>();
|
||||
|
||||
let controller: MeController;
|
||||
beforeAll(() => {
|
||||
controller = new MeController({
|
||||
logger,
|
||||
externalHooks,
|
||||
internalHooks,
|
||||
repositories: { User: userRepository },
|
||||
});
|
||||
const controller = new MeController({
|
||||
logger,
|
||||
externalHooks,
|
||||
internalHooks,
|
||||
repositories: { User: userRepository },
|
||||
});
|
||||
|
||||
describe('updateCurrentUser', () => {
|
||||
|
@ -88,12 +85,7 @@ describe('MeController', () => {
|
|||
});
|
||||
|
||||
describe('should throw if newPassword is not valid', () => {
|
||||
Object.entries({
|
||||
pass: 'Password must be 8 to 64 characters long. Password must contain at least 1 number. Password must contain at least 1 uppercase letter.',
|
||||
password:
|
||||
'Password must contain at least 1 number. Password must contain at least 1 uppercase letter.',
|
||||
password1: 'Password must contain at least 1 uppercase letter.',
|
||||
}).forEach(([newPassword, errorMessage]) => {
|
||||
Object.entries(badPasswords).forEach(([newPassword, errorMessage]) => {
|
||||
it(newPassword, async () => {
|
||||
const req = mock<MeRequest.Password>({
|
||||
user: mock({ password: passwordHash }),
|
||||
|
|
134
packages/cli/test/unit/controllers/owner.controller.test.ts
Normal file
134
packages/cli/test/unit/controllers/owner.controller.test.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import type { Repository } from 'typeorm';
|
||||
import type { CookieOptions, Response } from 'express';
|
||||
import { anyObject, captor, mock } from 'jest-mock-extended';
|
||||
import type { ILogger } from 'n8n-workflow';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { ICredentialsDb, IInternalHooksClass } from '@/Interfaces';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type { Settings } from '@db/entities/Settings';
|
||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import type { Config } from '@/config';
|
||||
import { BadRequestError } from '@/ResponseHelper';
|
||||
import type { OwnerRequest } from '@/requests';
|
||||
import { OwnerController } from '@/controllers';
|
||||
import { badPasswords } from '../shared/testData';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
|
||||
describe('OwnerController', () => {
|
||||
const config = mock<Config>();
|
||||
const logger = mock<ILogger>();
|
||||
const internalHooks = mock<IInternalHooksClass>();
|
||||
const userRepository = mock<Repository<User>>();
|
||||
const settingsRepository = mock<Repository<Settings>>();
|
||||
const credentialsRepository = mock<Repository<ICredentialsDb>>();
|
||||
const workflowsRepository = mock<Repository<WorkflowEntity>>();
|
||||
const controller = new OwnerController({
|
||||
config,
|
||||
logger,
|
||||
internalHooks,
|
||||
repositories: {
|
||||
User: userRepository,
|
||||
Settings: settingsRepository,
|
||||
Credentials: credentialsRepository,
|
||||
Workflow: workflowsRepository,
|
||||
},
|
||||
});
|
||||
|
||||
describe('preSetup', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(true);
|
||||
expect(controller.preSetup()).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should a return credential and workflow count', async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(false);
|
||||
credentialsRepository.countBy.mockResolvedValue(7);
|
||||
workflowsRepository.countBy.mockResolvedValue(31);
|
||||
const { credentials, workflows } = await controller.preSetup();
|
||||
expect(credentials).toBe(7);
|
||||
expect(workflows).toBe(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupOwner', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(true);
|
||||
expect(controller.setupOwner(mock(), mock())).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if the email is invalid', async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'invalid email' } });
|
||||
expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('Invalid email address'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should throw if the password is invalid', () => {
|
||||
Object.entries(badPasswords).forEach(([password, errorMessage]) => {
|
||||
it(password, async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'valid@email.com', password } });
|
||||
expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError(errorMessage),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if firstName & lastName are missing ', async () => {
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' },
|
||||
});
|
||||
expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('First and last names are mandatory'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup the instance owner successfully', async () => {
|
||||
const user = mock<User>({
|
||||
id: 'userId',
|
||||
globalRole: { scope: 'global', name: 'owner' },
|
||||
authIdentities: [],
|
||||
});
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: {
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
user,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
config.getEnv.calledWith('userManagement.isInstanceOwnerSetUp').mockReturnValue(false);
|
||||
userRepository.save.calledWith(anyObject()).mockResolvedValue(user);
|
||||
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
|
||||
|
||||
await controller.setupOwner(req, res);
|
||||
|
||||
expect(userRepository.save).toHaveBeenCalledWith(user);
|
||||
|
||||
const cookieOptions = captor<CookieOptions>();
|
||||
expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions);
|
||||
expect(cookieOptions.value.httpOnly).toBe(true);
|
||||
expect(cookieOptions.value.sameSite).toBe('lax');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipSetup', () => {
|
||||
it('should skip setting up the instance owner', async () => {
|
||||
await controller.skipSetup();
|
||||
expect(settingsRepository.update).toHaveBeenCalledWith(
|
||||
{ key: 'userManagement.skipInstanceOwnerSetup' },
|
||||
{ value: JSON.stringify(true) },
|
||||
);
|
||||
expect(config.set).toHaveBeenCalledWith('userManagement.skipInstanceOwnerSetup', true);
|
||||
});
|
||||
});
|
||||
});
|
6
packages/cli/test/unit/shared/testData.ts
Normal file
6
packages/cli/test/unit/shared/testData.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const badPasswords = {
|
||||
pass: 'Password must be 8 to 64 characters long. Password must contain at least 1 number. Password must contain at least 1 uppercase letter.',
|
||||
password:
|
||||
'Password must contain at least 1 number. Password must contain at least 1 uppercase letter.',
|
||||
password1: 'Password must contain at least 1 uppercase letter.',
|
||||
};
|
|
@ -22,11 +22,17 @@ export async function logout(context: IRestApiContext): Promise<void> {
|
|||
await makeRestApiRequest(context, 'POST', '/logout');
|
||||
}
|
||||
|
||||
export function preOwnerSetup(
|
||||
context: IRestApiContext,
|
||||
): Promise<{ credentials: number; workflows: number }> {
|
||||
return makeRestApiRequest(context, 'GET', '/owner/pre-setup');
|
||||
}
|
||||
|
||||
export function setupOwner(
|
||||
context: IRestApiContext,
|
||||
params: { firstName: string; lastName: string; email: string; password: string },
|
||||
): Promise<IUserResponse> {
|
||||
return makeRestApiRequest(context, 'POST', '/owner', params as unknown as IDataObject);
|
||||
return makeRestApiRequest(context, 'POST', '/owner/setup', params as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export function skipOwnerSetup(context: IRestApiContext): Promise<void> {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
login,
|
||||
loginCurrentUser,
|
||||
logout,
|
||||
preOwnerSetup,
|
||||
reinvite,
|
||||
sendForgotPasswordEmail,
|
||||
setupOwner,
|
||||
|
@ -158,6 +159,9 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
|||
await logout(rootStore.getRestApiContext);
|
||||
this.currentUserId = null;
|
||||
},
|
||||
async preOwnerSetup() {
|
||||
return preOwnerSetup(useRootStore().getRestApiContext);
|
||||
},
|
||||
async createOwner(params: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
|
|
|
@ -28,9 +28,9 @@ export default mixins(showMessage, restApi).extend({
|
|||
AuthView,
|
||||
},
|
||||
async mounted() {
|
||||
const getAllCredentialsPromise = this.getAllCredentials();
|
||||
const getAllWorkflowsPromise = this.getAllWorkflows();
|
||||
await Promise.all([getAllCredentialsPromise, getAllWorkflowsPromise]);
|
||||
const { credentials, workflows } = await this.usersStore.preOwnerSetup();
|
||||
this.credentialsCount = credentials;
|
||||
this.workflowsCount = workflows;
|
||||
},
|
||||
data() {
|
||||
const FORM_CONFIG: IFormBoxConfig = {
|
||||
|
@ -102,14 +102,6 @@ export default mixins(showMessage, restApi).extend({
|
|||
...mapStores(useCredentialsStore, useSettingsStore, useUIStore, useUsersStore),
|
||||
},
|
||||
methods: {
|
||||
async getAllCredentials() {
|
||||
const credentials = await this.credentialsStore.fetchAllCredentials();
|
||||
this.credentialsCount = credentials.length;
|
||||
},
|
||||
async getAllWorkflows() {
|
||||
const workflows = await this.restApi().getWorkflows();
|
||||
this.workflowsCount = workflows.length;
|
||||
},
|
||||
async confirmSetupOrGoBack(): Promise<boolean> {
|
||||
if (this.workflowsCount === 0 && this.credentialsCount === 0) {
|
||||
return true;
|
||||
|
|
Loading…
Reference in a new issue