fix: Introduce HooksService (#8962)

This commit is contained in:
Ricardo Espinoza 2024-06-11 07:28:45 -04:00 committed by GitHub
parent 4918ac81de
commit dda7901398
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 285 additions and 17 deletions

View file

@ -35,7 +35,7 @@ import type PCancelable from 'p-cancelable';
import type { AuthProviderType } from '@db/entities/AuthIdentity';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import type { TagEntity } from '@db/entities/TagEntity';
import type { GlobalRole, User } from '@db/entities/User';
import type { AssignableRole, GlobalRole, User } from '@db/entities/User';
import type { CredentialsRepository } from '@db/repositories/credentials.repository';
import type { SettingsRepository } from '@db/repositories/settings.repository';
import type { UserRepository } from '@db/repositories/user.repository';
@ -634,6 +634,11 @@ export interface PublicUser {
featureFlags?: FeatureFlags;
}
export interface Invitation {
email: string;
role: AssignableRole;
}
export interface N8nApp {
app: Application;
restEndpoint: string;

View file

@ -1,16 +1,8 @@
import { Column, Entity, PrimaryColumn } from '@n8n/typeorm';
import { Column, Entity } from '@n8n/typeorm';
import { User } from './User';
@Entity({ name: 'user' })
export class AuthUser {
@PrimaryColumn({ type: 'uuid', update: false })
id: string;
@Column({ type: String, update: false })
email: string;
@Column({ type: Boolean, default: false })
mfaEnabled: boolean;
export class AuthUser extends User {
@Column({ type: String, nullable: true })
mfaSecret?: string | null;

View file

@ -0,0 +1,120 @@
import { Service } from 'typedi';
import type { NextFunction, Response } from 'express';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import type { FindManyOptions, FindOneOptions, FindOptionsWhere } from '@n8n/typeorm';
import { AuthService } from '@/auth/auth.service';
import type { AuthUser } from '@db/entities/AuthUser';
import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import { SettingsRepository } from '@db/repositories/settings.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { AuthUserRepository } from '@db/repositories/authUser.repository';
import type { Settings } from '@db/entities/Settings';
import { UserService } from '@/services/user.service';
import type { AuthenticatedRequest } from '@/requests';
import type { Invitation } from '@/Interfaces';
/**
* Exposes functionality to be used by the cloud BE hooks.
* DO NOT DELETE or RENAME any of the methods without making sure this is not used in cloud BE hooks.
*/
@Service()
export class HooksService {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
private readonly userRepository: UserRepository,
private readonly settingsRepository: SettingsRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly authUserRepository: AuthUserRepository,
) {}
/**
* Invite users to instance during signup
*/
async inviteUsers(owner: User, attributes: Invitation[]) {
return await this.userService.inviteUsers(owner, attributes);
}
/**
* Set the n8n-auth cookie in the response to auto-login
* the user after instance is provisioned
*/
issueCookie(res: Response, user: AuthUser) {
return this.authService.issueCookie(res, user);
}
/**
* Find user in the instance
* 1. To know whether the instance owner is already setup
* 2. To know when to update the user's profile also in cloud
*/
async findOneUser(filter: FindOneOptions<AuthUser>) {
return await this.authUserRepository.findOne(filter);
}
/**
* Save instance owner with the cloud signup data
*/
async saveUser(user: User) {
return await this.userRepository.save(user);
}
/**
* Update instance's settings
* 1. To keep the state when users are invited to the instance
*/
async updateSettings(filter: FindOptionsWhere<Settings>, set: QueryDeepPartialEntity<Settings>) {
return await this.settingsRepository.update(filter, set);
}
/**
* Count the number of workflows
* 1. To enforce the active workflow limits in cloud
*/
async workflowsCount(filter: FindManyOptions<WorkflowEntity>) {
return await this.workflowRepository.count(filter);
}
/**
* Count the number of credentials
* 1. To enforce the max credential limits in cloud
*/
async credentialsCount(filter: FindManyOptions<CredentialsEntity>) {
return await this.credentialsRepository.count(filter);
}
/**
* Count the number of occurrences of a specific key
* 1. To know when to stop attempting to invite users
*/
async settingsCount(filter: FindManyOptions<Settings>) {
return await this.settingsRepository.count(filter);
}
/**
* Add auth middleware to routes injected via the hooks
* 1. To authenticate the /proxy routes in the hooks
*/
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
return await this.authService.authMiddleware(req, res, next);
}
/**
* Return repositories to be used in the hooks
* 1. Some self-hosted users rely in the repositories to interact with the DB directly
*/
dbCollections() {
return {
User: this.userRepository,
Settings: this.settingsRepository,
Credentials: this.credentialsRepository,
Workflow: this.workflowRepository,
};
}
}

View file

@ -4,7 +4,7 @@ import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workf
import type { User, AssignableRole } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository';
import type { PublicUser } from '@/Interfaces';
import type { Invitation, PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog';
import { Logger } from '@/Logger';
import { UserManagementMailer } from '@/UserManagement/email';
@ -178,14 +178,14 @@ export class UserService {
);
}
async inviteUsers(owner: User, attributes: Array<{ email: string; role: AssignableRole }>) {
const emails = attributes.map(({ email }) => email);
async inviteUsers(owner: User, invitations: Invitation[]) {
const emails = invitations.map(({ email }) => email);
const existingUsers = await this.userRepository.findManyByEmail(emails);
const existUsersEmails = existingUsers.map((user) => user.email);
const toCreateUsers = attributes.filter(({ email }) => !existUsersEmails.includes(email));
const toCreateUsers = invitations.filter(({ email }) => !existUsersEmails.includes(email));
const pendingUsersToInvite = existingUsers.filter((email) => email.isPending);
@ -222,7 +222,7 @@ export class UserService {
const usersInvited = await this.sendEmails(
owner,
Object.fromEntries(createdUsers),
attributes[0].role, // same role for all invited users
invitations[0].role, // same role for all invited users
);
return { usersInvited, usersCreated: toCreateUsers.map(({ email }) => email) };

View file

@ -0,0 +1,151 @@
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { AuthUser } from '@db/entities/AuthUser';
import type { CredentialsRepository } from '@db/repositories/credentials.repository';
import type { SettingsRepository } from '@db/repositories/settings.repository';
import type { UserRepository } from '@db/repositories/user.repository';
import type { WorkflowRepository } from '@db/repositories/workflow.repository';
import type { AuthService } from '@/auth/auth.service';
import type { UserService } from '@/services/user.service';
import { HooksService } from '@/services/hooks.service';
import type { Invitation } from '@/Interfaces';
import type { AuthenticatedRequest } from '@/requests';
import type { AuthUserRepository } from '@/databases/repositories/authUser.repository';
describe('HooksService', () => {
const mockedUser = mock<AuthUser>();
const userService = mock<UserService>();
const authService = mock<AuthService>();
const userRepository = mock<UserRepository>();
const settingsRepository = mock<SettingsRepository>();
const workflowRepository = mock<WorkflowRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const authUserRepository = mock<AuthUserRepository>();
const hooksService = new HooksService(
userService,
authService,
userRepository,
settingsRepository,
workflowRepository,
credentialsRepository,
authUserRepository,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('hooksService.inviteUsers should call userService.inviteUsers', async () => {
// ARRANGE
const usersToInvite: Invitation[] = [{ email: 'test@n8n.io', role: 'global:member' }];
// ACT
await hooksService.inviteUsers(mockedUser, usersToInvite);
// ASSERT
expect(userService.inviteUsers).toHaveBeenCalledWith(mockedUser, usersToInvite);
});
it('hooksService.issueCookie should call authService.issueCookie', async () => {
// ARRANGE
const res = mock<Response>();
// ACT
hooksService.issueCookie(res, mockedUser);
// ASSERT
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
});
it('hooksService.findOneUser should call authUserRepository.findOne', async () => {
// ARRANGE
const filter = { where: { id: '1' } };
// ACT
await hooksService.findOneUser(filter);
// ASSERT
expect(authUserRepository.findOne).toHaveBeenCalledWith(filter);
});
it('hooksService.saveUser should call userRepository.save', async () => {
// ACT
await hooksService.saveUser(mockedUser);
// ASSERT
expect(userRepository.save).toHaveBeenCalledWith(mockedUser);
});
it('hooksService.updateSettings should call settingRepository.update', async () => {
// ARRANGE
const filter = { key: 'test' };
const set = { value: 'true' };
// ACT
await hooksService.updateSettings(filter, set);
// ASSERT
expect(settingsRepository.update).toHaveBeenCalledWith(filter, set);
});
it('hooksService.workflowsCount should call workflowRepository.count', async () => {
// ARRANGE
const filter = { where: { active: true } };
// ACT
await hooksService.workflowsCount(filter);
// ASSERT
expect(workflowRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.credentialsCount should call credentialRepository.count', async () => {
// ARRANGE
const filter = { where: {} };
// ACT
await hooksService.credentialsCount(filter);
// ASSERT
expect(credentialsRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.settingsCount should call settingsRepository.count', async () => {
// ARRANGE
const filter = { where: { key: 'test' } };
// ACT
await hooksService.settingsCount(filter);
// ASSERT
expect(settingsRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.authMiddleware should call authService.authMiddleware', async () => {
// ARRANGE
const res = mock<Response>();
const req = mock<AuthenticatedRequest>();
const next = jest.fn();
// ACT
await hooksService.authMiddleware(req, res, next);
// ASSERT
expect(authService.authMiddleware).toHaveBeenCalledWith(req, res, next);
});
it('hooksService.dbCollections should return valid repositories', async () => {
// ACT
const collections = hooksService.dbCollections();
// ASSERT
expect(collections).toHaveProperty('User');
expect(collections).toHaveProperty('Settings');
expect(collections).toHaveProperty('Credentials');
expect(collections).toHaveProperty('Workflow');
});
});