Files
alighasami 3d5eaf9445
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
first commit
2026-03-17 16:22:57 +03:30

380 lines
11 KiB
TypeScript

import {
createWorkflow,
shareWorkflowWithUsers,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { IWorkflowBase } from 'n8n-workflow';
import type {
WorkflowClosedMessage,
WorkflowOpenedMessage,
WriteAccessRequestedMessage,
WriteAccessReleaseRequestedMessage,
} from '@/collaboration/collaboration.message';
import { CollaborationService } from '@/collaboration/collaboration.service';
import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service';
import { createMember, createOwner } from '@test-integration/db/users';
describe('CollaborationService', () => {
mockInstance(Push, new Push(mock(), mock(), mock(), mock(), mock()));
let pushService: Push;
let collaborationService: CollaborationService;
let owner: User;
let memberWithoutAccess: User;
let memberWithAccess: User;
let workflow: IWorkflowBase;
let cacheService: CacheService;
beforeAll(async () => {
await testDb.init();
pushService = Container.get(Push);
collaborationService = Container.get(CollaborationService);
cacheService = Container.get(CacheService);
await cacheService.init();
[owner, memberWithAccess, memberWithoutAccess] = await Promise.all([
createOwner(),
createMember(),
createMember(),
]);
workflow = await createWorkflow({}, owner);
await shareWorkflowWithUsers(workflow, [memberWithAccess]);
});
afterEach(async () => {
jest.resetAllMocks();
await cacheService.reset();
});
const sendWorkflowOpenedMessage = async (
workflowId: string,
userId: string,
clientId = 'test-client-id',
) => {
const openMessage: WorkflowOpenedMessage = {
type: 'workflowOpened',
workflowId,
};
return await collaborationService.handleUserMessage(userId, clientId, openMessage);
};
const sendWorkflowClosedMessage = async (
workflowId: string,
userId: string,
clientId = 'test-client-id',
) => {
const openMessage: WorkflowClosedMessage = {
type: 'workflowClosed',
workflowId,
};
return await collaborationService.handleUserMessage(userId, clientId, openMessage);
};
const sendWriteAccessRequestedMessage = async (
workflowId: string,
userId: string,
clientId = 'test-client-id',
) => {
const message: WriteAccessRequestedMessage = {
type: 'writeAccessRequested',
workflowId,
};
return await collaborationService.handleUserMessage(userId, clientId, message);
};
const sendWriteAccessReleaseRequestedMessage = async (
workflowId: string,
userId: string,
clientId = 'test-client-id',
) => {
const message: WriteAccessReleaseRequestedMessage = {
type: 'writeAccessReleaseRequested',
workflowId,
};
return await collaborationService.handleUserMessage(userId, clientId, message);
};
describe('workflow opened message', () => {
it('should emit collaboratorsChanged after workflowOpened', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
// Act
await sendWorkflowOpenedMessage(workflow.id, owner.id, 'owner-client-id');
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
// Assert
expect(sendToUsersSpy).toHaveBeenNthCalledWith(
1,
{
type: 'collaboratorsChanged',
data: {
collaborators: [
{
lastSeen: expect.any(String),
user: owner.toIUser(),
},
],
workflowId: workflow.id,
},
},
[owner.id],
);
expect(sendToUsersSpy).toHaveBeenNthCalledWith(
2,
{
type: 'collaboratorsChanged',
data: {
collaborators: expect.arrayContaining([
expect.objectContaining({
lastSeen: expect.any(String),
user: expect.objectContaining({
id: owner.id,
}),
}),
expect.objectContaining({
lastSeen: expect.any(String),
user: expect.objectContaining({
id: memberWithAccess.id,
}),
}),
]),
workflowId: workflow.id,
},
},
[owner.id, memberWithAccess.id],
);
});
it("should not emit collaboratorsChanged if user don't have access to the workflow", async () => {
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
// Act
await sendWorkflowOpenedMessage(workflow.id, memberWithoutAccess.id);
// Assert
expect(sendToUsersSpy).not.toHaveBeenCalled();
});
});
describe('workflow closed message', () => {
it('should not emit collaboratorsChanged after workflowClosed when there are no active users', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id);
sendToUsersSpy.mockClear();
// Act
await sendWorkflowClosedMessage(workflow.id, owner.id);
// Assert
expect(sendToUsersSpy).not.toHaveBeenCalled();
});
it('should emit collaboratorsChanged after workflowClosed when there are active users', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id, 'owner-client-id');
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
sendToUsersSpy.mockClear();
// Act
await sendWorkflowClosedMessage(workflow.id, owner.id, 'owner-client-id');
// Assert
expect(sendToUsersSpy).toHaveBeenCalledWith(
{
type: 'collaboratorsChanged',
data: {
collaborators: expect.arrayContaining([
expect.objectContaining({
lastSeen: expect.any(String),
user: expect.objectContaining({
id: memberWithAccess.id,
}),
}),
]),
workflowId: workflow.id,
},
},
[memberWithAccess.id],
);
});
it("should not emit collaboratorsChanged if user don't have access to the workflow", async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id);
sendToUsersSpy.mockClear();
// Act
await sendWorkflowClosedMessage(workflow.id, memberWithoutAccess.id);
// Assert
expect(sendToUsersSpy).not.toHaveBeenCalled();
});
});
describe('write lock acquisition', () => {
it('should grant write lock to first user', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id);
sendToUsersSpy.mockClear();
// Act
await sendWriteAccessRequestedMessage(workflow.id, owner.id);
// Assert
expect(sendToUsersSpy).toHaveBeenCalledWith(
{
type: 'writeAccessAcquired',
data: {
workflowId: workflow.id,
userId: owner.id,
clientId: 'test-client-id',
},
},
[owner.id],
);
});
it('should deny write lock if another user holds it', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id, 'owner-client-id');
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
// Owner acquires the lock first
await sendWriteAccessRequestedMessage(workflow.id, owner.id, 'owner-client-id');
sendToUsersSpy.mockClear();
// Act - Member tries to acquire the lock
await sendWriteAccessRequestedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
// Assert - No message should be sent (silent rejection)
expect(sendToUsersSpy).not.toHaveBeenCalled();
});
it('should allow lock acquisition after release', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id, 'owner-client-id');
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
// Owner acquires the lock
await sendWriteAccessRequestedMessage(workflow.id, owner.id, 'owner-client-id');
// Owner releases the lock
await sendWriteAccessReleaseRequestedMessage(workflow.id, owner.id, 'owner-client-id');
sendToUsersSpy.mockClear();
// Act - Member tries to acquire the lock
await sendWriteAccessRequestedMessage(workflow.id, memberWithAccess.id, 'member-client-id');
// Assert - Member should successfully acquire the lock
expect(sendToUsersSpy).toHaveBeenCalledWith(
{
type: 'writeAccessAcquired',
data: {
workflowId: workflow.id,
userId: memberWithAccess.id,
clientId: 'member-client-id',
},
},
[owner.id, memberWithAccess.id],
);
});
it('should allow same user to re-acquire lock they already hold', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, owner.id);
// Owner acquires the lock
await sendWriteAccessRequestedMessage(workflow.id, owner.id);
sendToUsersSpy.mockClear();
// Act - Owner tries to acquire the lock again
await sendWriteAccessRequestedMessage(workflow.id, owner.id);
// Assert - Should succeed (idempotent)
expect(sendToUsersSpy).toHaveBeenCalledWith(
{
type: 'writeAccessAcquired',
data: {
workflowId: workflow.id,
userId: owner.id,
clientId: 'test-client-id',
},
},
[owner.id],
);
});
it('should not grant write lock to user without write access', async () => {
// Arrange
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
await sendWorkflowOpenedMessage(workflow.id, memberWithoutAccess.id);
sendToUsersSpy.mockClear();
// Act - User without access tries to acquire lock
await sendWriteAccessRequestedMessage(workflow.id, memberWithoutAccess.id);
// Assert - No messages should be sent
expect(sendToUsersSpy).not.toHaveBeenCalled();
});
});
describe('getWriteLock', () => {
it('should return lock holder for user with read access', async () => {
// Arrange
await sendWorkflowOpenedMessage(workflow.id, owner.id);
await sendWriteAccessRequestedMessage(workflow.id, owner.id);
// Act
const lockHolder = await collaborationService.getWriteLock(memberWithAccess.id, workflow.id);
// Assert
expect(lockHolder).toEqual({
clientId: 'test-client-id',
userId: owner.id,
});
});
it('should return null for user without read access', async () => {
// Arrange
await sendWorkflowOpenedMessage(workflow.id, owner.id);
await sendWriteAccessRequestedMessage(workflow.id, owner.id);
// Act
const lockHolder = await collaborationService.getWriteLock(
memberWithoutAccess.id,
workflow.id,
);
// Assert
expect(lockHolder).toBeNull();
});
it('should return null when no lock is held', async () => {
// Act
const lockHolder = await collaborationService.getWriteLock(owner.id, workflow.id);
// Assert
expect(lockHolder).toBeNull();
});
});
});