mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
fix: Introduce HooksService
(#8962)
This commit is contained in:
parent
4918ac81de
commit
dda7901398
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
120
packages/cli/src/services/hooks.service.ts
Normal file
120
packages/cli/src/services/hooks.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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) };
|
||||
|
|
151
packages/cli/test/unit/services/hooks.service.test.ts
Normal file
151
packages/cli/test/unit/services/hooks.service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue