refactor(core): Cache workflow ownership (#6738)

* refactor: Set up ownership service

* refactor: Specify cache keys and values

* refactor: Replace util with service calls

* test: Mock service in tests

* refactor: Use dependency injection

* test: Write tests

* refactor: Apply feedback from Omar and Micha

* test: Fix tests

* test: Fix missing spot

* refactor: Return user entity from cache

* refactor: More dependency injection!
This commit is contained in:
Iván Ovejero 2023-07-31 11:37:09 +02:00 committed by GitHub
parent 72523462ea
commit ffae8edce3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 166 additions and 44 deletions

View file

@ -9,9 +9,11 @@ import { In } from 'typeorm';
import * as Db from '@/Db'; import * as Db from '@/Db';
import config from '@/config'; import config from '@/config';
import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { getRoleId, getWorkflowOwner, isSharingEnabled } from './UserManagementHelper'; import { getRoleId, isSharingEnabled } from './UserManagementHelper';
import { WorkflowsService } from '@/workflows/workflows.services'; import { WorkflowsService } from '@/workflows/workflows.services';
import { UserService } from '@/user/user.service'; import { UserService } from '@/user/user.service';
import { OwnershipService } from '@/services/ownership.service';
import Container from 'typedi';
export class PermissionChecker { export class PermissionChecker {
/** /**
@ -101,7 +103,9 @@ export class PermissionChecker {
policy = 'workflowsFromSameOwner'; policy = 'workflowsFromSameOwner';
} }
const subworkflowOwner = await getWorkflowOwner(subworkflow.id); const subworkflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(
subworkflow.id,
);
const errorToThrow = new SubworkflowOperationError( const errorToThrow = new SubworkflowOperationError(
`Target workflow ID ${subworkflow.id ?? ''} may not be called`, `Target workflow ID ${subworkflow.id ?? ''} may not be called`,

View file

@ -14,17 +14,6 @@ import { License } from '@/License';
import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { getWebhookBaseUrl } from '@/WebhookHelpers';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
export async function getWorkflowOwner(workflowId: string): Promise<User> {
const workflowOwnerRole = await Container.get(RoleRepository).findWorkflowOwnerRole();
const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({
where: { workflowId, roleId: workflowOwnerRole?.id ?? undefined },
relations: ['user', 'user.globalRole'],
});
return sharedWorkflow.user;
}
export function isEmailSetUp(): boolean { export function isEmailSetUp(): boolean {
const smtp = config.getEnv('userManagement.emails.mode') === 'smtp'; const smtp = config.getEnv('userManagement.emails.mode') === 'smtp';
const host = !!config.getEnv('userManagement.emails.smtp.host'); const host = !!config.getEnv('userManagement.emails.smtp.host');

View file

@ -16,10 +16,10 @@ import type {
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
} from '@/Interfaces'; } from '@/Interfaces';
import { WorkflowRunner } from '@/WorkflowRunner'; import { WorkflowRunner } from '@/WorkflowRunner';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { recoverExecutionDataFromEventLogMessages } from './eventbus/MessageEventBus/recoverEvents'; import { recoverExecutionDataFromEventLogMessages } from './eventbus/MessageEventBus/recoverEvents';
import { ExecutionRepository } from '@db/repositories'; import { ExecutionRepository } from '@db/repositories';
import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
import { OwnershipService } from './services/ownership.service';
@Service() @Service()
export class WaitTracker { export class WaitTracker {
@ -32,7 +32,10 @@ export class WaitTracker {
mainTimer: NodeJS.Timeout; mainTimer: NodeJS.Timeout;
constructor(private executionRepository: ExecutionRepository) { constructor(
private executionRepository: ExecutionRepository,
private ownershipService: OwnershipService,
) {
// Poll every 60 seconds a list of upcoming executions // Poll every 60 seconds a list of upcoming executions
this.mainTimer = setInterval(() => { this.mainTimer = setInterval(() => {
void this.getWaitingExecutions(); void this.getWaitingExecutions();
@ -180,7 +183,8 @@ export class WaitTracker {
if (!fullExecutionData.workflowData.id) { if (!fullExecutionData.workflowData.id) {
throw new Error('Only saved workflows can be resumed.'); throw new Error('Only saved workflows can be resumed.');
} }
const user = await getWorkflowOwner(fullExecutionData.workflowData.id); const workflowId = fullExecutionData.workflowData.id;
const user = await this.ownershipService.getWorkflowOwnerCached(workflowId);
const data: IWorkflowExecutionDataProcess = { const data: IWorkflowExecutionDataProcess = {
executionMode: fullExecutionData.mode, executionMode: fullExecutionData.mode,

View file

@ -8,14 +8,15 @@ import * as WebhookHelpers from '@/WebhookHelpers';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import type { IExecutionResponse, IResponseCallbackData, IWorkflowDb } from '@/Interfaces'; import type { IExecutionResponse, IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { ExecutionRepository } from '@db/repositories'; import { ExecutionRepository } from '@db/repositories';
import { OwnershipService } from './services/ownership.service';
@Service() @Service()
export class WaitingWebhooks { export class WaitingWebhooks {
constructor( constructor(
private nodeTypes: NodeTypes, private nodeTypes: NodeTypes,
private executionRepository: ExecutionRepository, private executionRepository: ExecutionRepository,
private ownershipService: OwnershipService,
) {} ) {}
async executeWebhook( async executeWebhook(
@ -83,7 +84,7 @@ export class WaitingWebhooks {
const { workflowData } = fullExecutionData; const { workflowData } = fullExecutionData;
const workflow = new Workflow({ const workflow = new Workflow({
id: workflowData.id!.toString(), id: workflowData.id!,
name: workflowData.name, name: workflowData.name,
nodes: workflowData.nodes, nodes: workflowData.nodes,
connections: workflowData.connections, connections: workflowData.connections,
@ -95,7 +96,7 @@ export class WaitingWebhooks {
let workflowOwner; let workflowOwner;
try { try {
workflowOwner = await getWorkflowOwner(workflowData.id!.toString()); workflowOwner = await this.ownershipService.getWorkflowOwnerCached(workflowData.id!);
} catch (error) { } catch (error) {
throw new ResponseHelper.NotFoundError('Could not find workflow'); throw new ResponseHelper.NotFoundError('Could not find workflow');
} }

View file

@ -58,8 +58,8 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveExecutions } from '@/ActiveExecutions';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { EventsService } from '@/services/events.service'; import { EventsService } from '@/services/events.service';
import { OwnershipService } from './services/ownership.service';
const pipeline = promisify(stream.pipeline); const pipeline = promisify(stream.pipeline);
@ -175,7 +175,7 @@ export async function executeWebhook(
user = (workflowData as WorkflowEntity).shared[0].user; user = (workflowData as WorkflowEntity).shared[0].user;
} else { } else {
try { try {
user = await getWorkflowOwner(workflowData.id); user = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowData.id);
} catch (error) { } catch (error) {
throw new ResponseHelper.NotFoundError('Cannot find workflow'); throw new ResponseHelper.NotFoundError('Cannot find workflow');
} }

View file

@ -58,7 +58,6 @@ import { NodeTypes } from '@/NodeTypes';
import { Push } from '@/push'; import { Push } from '@/push';
import * as WebhookHelpers from '@/WebhookHelpers'; import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils';
import { PermissionChecker } from './UserManagement/PermissionChecker'; import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services'; import { WorkflowsService } from './workflows/workflows.services';
@ -66,6 +65,7 @@ import { InternalHooks } from '@/InternalHooks';
import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata';
import { ExecutionRepository } from '@db/repositories'; import { ExecutionRepository } from '@db/repositories';
import { EventsService } from '@/services/events.service'; import { EventsService } from '@/services/events.service';
import { OwnershipService } from './services/ownership.service';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -146,7 +146,9 @@ export function executeErrorWorkflow(
// make sure there are no possible security gaps // make sure there are no possible security gaps
return; return;
} }
getWorkflowOwner(workflowId)
Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId)
.then((user) => { .then((user) => {
void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user); void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user);
}) })
@ -169,7 +171,9 @@ export function executeErrorWorkflow(
workflowData.nodes.some((node) => node.type === ERROR_TRIGGER_TYPE) workflowData.nodes.some((node) => node.type === ERROR_TRIGGER_TYPE)
) { ) {
Logger.verbose('Start internal error workflow', { executionId, workflowId }); Logger.verbose('Start internal error workflow', { executionId, workflowId });
void getWorkflowOwner(workflowId).then((user) => { void Container.get(OwnershipService)
.getWorkflowOwnerCached(workflowId)
.then((user) => {
void WorkflowHelpers.executeErrorWorkflow(workflowId, workflowErrorData, user); void WorkflowHelpers.executeErrorWorkflow(workflowId, workflowErrorData, user);
}); });
} }

View file

@ -18,11 +18,11 @@ import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import config from '@/config'; import config from '@/config';
import type { Job, JobId, JobQueue, JobResponse, WebhookResponse } from '@/Queue'; import type { Job, JobId, JobQueue, JobResponse, WebhookResponse } from '@/Queue';
import { Queue } from '@/Queue'; import { Queue } from '@/Queue';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
import { ExecutionRepository } from '@db/repositories'; import { ExecutionRepository } from '@db/repositories';
import { OwnershipService } from '@/services/ownership.service';
export class Worker extends BaseCommand { export class Worker extends BaseCommand {
static description = '\nStarts a n8n worker'; static description = '\nStarts a n8n worker';
@ -112,7 +112,7 @@ export class Worker extends BaseCommand {
`Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`, `Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`,
); );
const workflowOwner = await getWorkflowOwner(workflowId); const workflowOwner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId);
let { staticData } = fullExecutionData.workflowData; let { staticData } = fullExecutionData.workflowData;
if (loadStaticData) { if (loadStaticData) {

View file

@ -9,6 +9,10 @@ import { LoggerProxy, jsonStringify } from 'n8n-workflow';
@Service() @Service()
export class CacheService { export class CacheService {
/**
* Keys and values:
* - `'cache:workflow-owner:${workflowId}'`: `User`
*/
private cache: RedisCache | MemoryCache | undefined; private cache: RedisCache | MemoryCache | undefined;
async init() { async init() {

View file

@ -1,15 +1,18 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { Service } from 'typedi'; import Container, { Service } from 'typedi';
import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import { StatisticsNames } from '@db/entities/WorkflowStatistics'; import { StatisticsNames } from '@db/entities/WorkflowStatistics';
import { WorkflowStatisticsRepository } from '@db/repositories'; import { WorkflowStatisticsRepository } from '@db/repositories';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { UserService } from '@/user/user.service'; import { UserService } from '@/user/user.service';
import { OwnershipService } from './ownership.service';
@Service() @Service()
export class EventsService extends EventEmitter { export class EventsService extends EventEmitter {
constructor(private repository: WorkflowStatisticsRepository) { constructor(
private repository: WorkflowStatisticsRepository,
private ownershipService: OwnershipService,
) {
super({ captureRejections: true }); super({ captureRejections: true });
if ('SKIP_STATISTICS_EVENTS' in process.env) return; if ('SKIP_STATISTICS_EVENTS' in process.env) return;
@ -41,7 +44,7 @@ export class EventsService extends EventEmitter {
const upsertResult = await this.repository.upsertWorkflowStatistics(name, workflowId); const upsertResult = await this.repository.upsertWorkflowStatistics(name, workflowId);
if (name === 'production_success' && upsertResult === 'insert') { if (name === 'production_success' && upsertResult === 'insert') {
const owner = await getWorkflowOwner(workflowId); const owner = await Container.get(OwnershipService).getWorkflowOwnerCached(workflowId);
const metrics = { const metrics = {
user_id: owner.id, user_id: owner.id,
workflow_id: workflowId, workflow_id: workflowId,
@ -72,7 +75,7 @@ export class EventsService extends EventEmitter {
if (insertResult === 'failed') return; if (insertResult === 'failed') return;
// Compile the metrics since this was a new data loaded event // Compile the metrics since this was a new data loaded event
const owner = await getWorkflowOwner(workflowId); const owner = await this.ownershipService.getWorkflowOwnerCached(workflowId);
let metrics = { let metrics = {
user_id: owner.id, user_id: owner.id,

View file

@ -0,0 +1,36 @@
import { Service } from 'typedi';
import { CacheService } from './cache.service';
import { RoleRepository, SharedWorkflowRepository, UserRepository } from '@/databases/repositories';
import type { User } from '@/databases/entities/User';
@Service()
export class OwnershipService {
constructor(
private cacheService: CacheService,
private userRepository: UserRepository,
private roleRepository: RoleRepository,
private sharedWorkflowRepository: SharedWorkflowRepository,
) {}
/**
* Retrieve the user who owns the workflow. Note that workflow ownership is **immutable**.
*/
async getWorkflowOwnerCached(workflowId: string) {
const cachedValue = await this.cacheService.get<User>(`cache:workflow-owner:${workflowId}`);
if (cachedValue) return this.userRepository.create(cachedValue);
const workflowOwnerRole = await this.roleRepository.findWorkflowOwnerRole();
if (!workflowOwnerRole) throw new Error('Failed to find workflow owner role');
const sharedWorkflow = await this.sharedWorkflowRepository.findOneOrFail({
where: { workflowId, roleId: workflowOwnerRole.id },
relations: ['user', 'user.globalRole'],
});
void this.cacheService.set(`cache:workflow-owner:${workflowId}`, sharedWorkflow.user);
return sharedWorkflow.user;
}
}

View file

@ -23,6 +23,7 @@ import * as testDb from '../integration/shared/testDb';
import { mockNodeTypesData } from './Helpers'; import { mockNodeTypesData } from './Helpers';
import type { SaveCredentialFunction } from '../integration/shared/types'; import type { SaveCredentialFunction } from '../integration/shared/types';
import { mockInstance } from '../integration/shared/utils/'; import { mockInstance } from '../integration/shared/utils/';
import { OwnershipService } from '@/services/ownership.service';
let mockNodeTypes: INodeTypes; let mockNodeTypes: INodeTypes;
let credentialOwnerRole: Role; let credentialOwnerRole: Role;
@ -221,6 +222,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
const userId = uuid(); const userId = uuid();
const fakeUser = new User(); const fakeUser = new User();
fakeUser.id = userId; fakeUser.id = userId;
const ownershipService = mockInstance(OwnershipService);
const ownerMockRole = new Role(); const ownerMockRole = new Role();
ownerMockRole.name = 'owner'; ownerMockRole.name = 'owner';
@ -234,7 +236,9 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('sets default policy from environment when subworkflow has none', async () => { test('sets default policy from environment when subworkflow has none', async () => {
config.set('workflows.callerPolicyDefaultOption', 'none'); config.set('workflows.callerPolicyDefaultOption', 'none');
jest.spyOn(UserManagementHelper, 'getWorkflowOwner').mockImplementation(async (workflowId) => { jest
.spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => {
return fakeUser; return fakeUser;
}); });
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
@ -253,7 +257,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('if sharing is disabled, ensures that workflows are owner by same user', async () => { test('if sharing is disabled, ensures that workflows are owner by same user', async () => {
jest jest
.spyOn(UserManagementHelper, 'getWorkflowOwner') .spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser); .mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false);
jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser);
@ -287,7 +291,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('list of ids must include the parent workflow id', async () => { test('list of ids must include the parent workflow id', async () => {
const invalidParentWorkflowId = uuid(); const invalidParentWorkflowId = uuid();
jest jest
.spyOn(UserManagementHelper, 'getWorkflowOwner') .spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser); .mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser);
@ -313,7 +317,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('sameOwner passes when both workflows are owned by the same user', async () => { test('sameOwner passes when both workflows are owned by the same user', async () => {
jest jest
.spyOn(UserManagementHelper, 'getWorkflowOwner') .spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser); .mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false);
jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser);
@ -336,7 +340,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('workflowsFromAList works when the list contains the parent id', async () => { test('workflowsFromAList works when the list contains the parent id', async () => {
const workflowId = uuid(); const workflowId = uuid();
jest jest
.spyOn(UserManagementHelper, 'getWorkflowOwner') .spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser); .mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser);
@ -362,7 +366,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
test('should not throw when workflow policy is set to any', async () => { test('should not throw when workflow policy is set to any', async () => {
jest jest
.spyOn(UserManagementHelper, 'getWorkflowOwner') .spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser); .mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser);

View file

@ -15,13 +15,15 @@ import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics';
import { WorkflowStatisticsRepository } from '@db/repositories'; import { WorkflowStatisticsRepository } from '@db/repositories';
import { EventsService } from '@/services/events.service'; import { EventsService } from '@/services/events.service';
import { UserService } from '@/user/user.service'; import { UserService } from '@/user/user.service';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { OwnershipService } from '@/services/ownership.service';
import { mockInstance } from '../../integration/shared/utils';
jest.mock('@/UserManagement/UserManagementHelper', () => ({ getWorkflowOwner: jest.fn() })); jest.mock('@/UserManagement/UserManagementHelper', () => ({ getWorkflowOwner: jest.fn() }));
describe('EventsService', () => { describe('EventsService', () => {
const dbType = config.getEnv('database.type'); const dbType = config.getEnv('database.type');
const fakeUser = mock<User>({ id: 'abcde-fghij' }); const fakeUser = mock<User>({ id: 'abcde-fghij' });
const ownershipService = mockInstance(OwnershipService);
const entityManager = mock<EntityManager>(); const entityManager = mock<EntityManager>();
const dataSource = mock<DataSource>({ const dataSource = mock<DataSource>({
@ -36,10 +38,13 @@ describe('EventsService', () => {
LoggerProxy.init(mock<ILogger>()); LoggerProxy.init(mock<ILogger>());
config.set('diagnostics.enabled', true); config.set('diagnostics.enabled', true);
config.set('deployment.type', 'n8n-testing'); config.set('deployment.type', 'n8n-testing');
mocked(getWorkflowOwner).mockResolvedValue(fakeUser); mocked(ownershipService.getWorkflowOwnerCached).mockResolvedValue(fakeUser);
const updateUserSettingsMock = jest.spyOn(UserService, 'updateUserSettings').mockImplementation(); const updateUserSettingsMock = jest.spyOn(UserService, 'updateUserSettings').mockImplementation();
const eventsService = new EventsService(new WorkflowStatisticsRepository(dataSource)); const eventsService = new EventsService(
new WorkflowStatisticsRepository(dataSource),
ownershipService,
);
const onFirstProductionWorkflowSuccess = jest.fn(); const onFirstProductionWorkflowSuccess = jest.fn();
const onFirstWorkflowDataLoad = jest.fn(); const onFirstWorkflowDataLoad = jest.fn();

View file

@ -0,0 +1,68 @@
import { OwnershipService } from '@/services/ownership.service';
import { RoleRepository, SharedWorkflowRepository, UserRepository } from '@/databases/repositories';
import { mockInstance } from '../../integration/shared/utils';
import { Role } from '@/databases/entities/Role';
import { randomInteger } from '../../integration/shared/random';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { CacheService } from '@/services/cache.service';
import { User } from '@/databases/entities/User';
const wfOwnerRole = () =>
Object.assign(new Role(), {
scope: 'workflow',
name: 'owner',
id: randomInteger(),
});
describe('OwnershipService', () => {
const cacheService = mockInstance(CacheService);
const roleRepository = mockInstance(RoleRepository);
const userRepository = mockInstance(UserRepository);
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const ownershipService = new OwnershipService(
cacheService,
userRepository,
roleRepository,
sharedWorkflowRepository,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getWorkflowOwner()', () => {
test('should retrieve a workflow owner', async () => {
roleRepository.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole());
const mockOwner = new User();
const mockNonOwner = new User();
const sharedWorkflow = Object.assign(new SharedWorkflow(), {
role: new Role(),
user: mockOwner,
});
sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow);
const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id');
expect(returnedOwner).toBe(mockOwner);
expect(returnedOwner).not.toBe(mockNonOwner);
});
test('should throw if no workflow owner role found', async () => {
roleRepository.findWorkflowOwnerRole.mockRejectedValueOnce(new Error());
await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow();
});
test('should throw if no workflow owner found', async () => {
roleRepository.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole());
sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error());
await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow();
});
});
});