Files
n8n-enterprise-unlocked/packages/cli/test/integration/services/role.service.test.ts

708 lines
18 KiB
TypeScript

import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { testDb } from '@n8n/backend-test-utils';
import { RoleRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { ALL_ROLES } from '@n8n/permissions';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { License } from '@/license';
import { RoleService } from '@/services/role.service';
import {
createRole,
createSystemRole,
createCustomRoleWithScopes,
createTestScopes,
cleanupRolesAndScopes,
} from '../shared/db/roles';
import { createMember } from '../shared/db/users';
let roleService: RoleService;
let roleRepository: RoleRepository;
let license: License;
let licenseState: LicenseState;
const ALL_ROLES_SET = ALL_ROLES.global.concat(
ALL_ROLES.project,
ALL_ROLES.credential,
ALL_ROLES.workflow,
);
beforeAll(async () => {
await testDb.init();
roleService = Container.get(RoleService);
roleRepository = Container.get(RoleRepository);
license = Container.get(License);
licenseState = Container.get(LicenseState);
licenseState.setLicenseProvider(license);
});
afterAll(async () => {
await testDb.terminate();
});
afterEach(async () => {
await cleanupRolesAndScopes();
await testDb.truncate(['User']);
});
describe('RoleService', () => {
describe('getAllRoles', () => {
it('should return all roles with licensing information', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const customRole = await createCustomRoleWithScopes(
[testScopes.readScope, testScopes.writeScope],
{
displayName: 'Custom Test Role',
description: 'A custom role for testing',
},
);
const systemRole = await createSystemRole({
displayName: 'System Test Role',
});
//
// ACT
//
const roles = await roleService.getAllRoles();
//
// ASSERT
//
expect(roles).toBeDefined();
expect(Array.isArray(roles)).toBe(true);
expect(roles.length).toBeGreaterThanOrEqual(2);
// Find our test roles
const returnedCustomRole = roles.find((r) => r.slug === customRole.slug);
const returnedSystemRole = roles.find((r) => r.slug === systemRole.slug);
expect(returnedCustomRole).toBeDefined();
expect(returnedSystemRole).toBeDefined();
// Verify role structure
expect(returnedCustomRole).toMatchObject({
slug: customRole.slug,
displayName: customRole.displayName,
description: customRole.description,
systemRole: false,
roleType: customRole.roleType,
scopes: expect.any(Array),
licensed: expect.any(Boolean),
});
// Verify scopes are converted to slugs
expect(returnedCustomRole?.scopes).toEqual(
expect.arrayContaining([testScopes.readScope.slug, testScopes.writeScope.slug]),
);
});
it('should return built-in system roles when no custom roles exist', async () => {
//
// ARRANGE
// (only built-in system roles exist in database)
//
// ACT
//
const roles = await roleService.getAllRoles();
//
// ASSERT
//
expect(roles).toBeDefined();
expect(Array.isArray(roles)).toBe(true);
expect(roles.length).toBeGreaterThan(0);
// Verify all returned roles have proper structure
roles.forEach((role) => {
expect(role).toHaveProperty('slug');
expect(role).toHaveProperty('displayName');
expect(role).toHaveProperty('systemRole');
expect(role.systemRole).toBe(true);
expect(role).toHaveProperty('roleType');
expect(role).toHaveProperty('scopes');
expect(role).toHaveProperty('licensed');
expect(Array.isArray(role.scopes)).toBe(true);
expect(typeof role.licensed).toBe('boolean');
expect(ALL_ROLES_SET.some((r) => r.slug === role.slug)).toBe(true);
});
});
});
describe('getRole', () => {
it('should return role with licensing information when role exists', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const role = await createCustomRoleWithScopes([testScopes.adminScope], {
displayName: 'Admin Role',
description: 'Administrator role',
});
//
// ACT
//
const result = await roleService.getRole(role.slug);
//
// ASSERT
//
expect(result).toMatchObject({
slug: role.slug,
displayName: role.displayName,
description: role.description,
systemRole: false,
roleType: role.roleType,
scopes: [testScopes.adminScope.slug],
licensed: expect.any(Boolean),
});
});
it('should throw NotFoundError when role does not exist', async () => {
//
// ARRANGE
//
const nonExistentSlug = 'non-existent-role';
//
// ACT & ASSERT
//
await expect(roleService.getRole(nonExistentSlug)).rejects.toThrow(NotFoundError);
await expect(roleService.getRole(nonExistentSlug)).rejects.toThrow('Role not found');
});
});
describe('createCustomRole', () => {
it('should create custom role with valid data', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const createRoleDto: CreateRoleDto = {
displayName: 'Test Custom Role',
description: 'A test custom role',
roleType: 'project',
scopes: [testScopes.readScope.slug, testScopes.writeScope.slug],
};
//
// ACT
//
const result = await roleService.createCustomRole(createRoleDto);
//
// ASSERT
//
expect(result).toMatchObject({
displayName: createRoleDto.displayName,
description: createRoleDto.description,
systemRole: false,
roleType: createRoleDto.roleType,
scopes: expect.arrayContaining(createRoleDto.scopes),
licensed: expect.any(Boolean),
});
// Verify slug was generated correctly
expect(result.slug).toMatch(/^project:test-custom-role-[a-z0-9]{6}$/);
// Verify role was saved to database
const savedRole = await roleRepository.findBySlug(result.slug);
expect(savedRole).toBeDefined();
expect(savedRole?.displayName).toBe(createRoleDto.displayName);
});
it('should create custom role without description', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const createRoleDto: CreateRoleDto = {
displayName: 'No Description Role',
roleType: 'project',
scopes: [testScopes.readScope.slug],
};
//
// ACT
//
const result = await roleService.createCustomRole(createRoleDto);
//
// ASSERT
//
expect(result).toMatchObject({
displayName: createRoleDto.displayName,
description: null,
systemRole: false,
roleType: createRoleDto.roleType,
scopes: createRoleDto.scopes,
});
});
it('should throw BadRequestError when scopes are undefined', async () => {
//
// ARRANGE
//
const createRoleDto = {
displayName: 'Invalid Role',
roleType: 'project' as const,
scopes: undefined as any,
};
//
// ACT & ASSERT
//
await expect(roleService.createCustomRole(createRoleDto)).rejects.toThrow(BadRequestError);
await expect(roleService.createCustomRole(createRoleDto)).rejects.toThrow(
'Scopes are required',
);
});
it('should throw error when invalid scopes are provided', async () => {
//
// ARRANGE
//
const createRoleDto: CreateRoleDto = {
displayName: 'Invalid Scopes Role',
roleType: 'project',
scopes: ['invalid:scope', 'another:invalid:scope'],
};
//
// ACT & ASSERT
//
await expect(roleService.createCustomRole(createRoleDto)).rejects.toThrow(
'The following scopes are invalid: invalid:scope, another:invalid:scope',
);
});
it('should generate slug correctly for complex display names', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const createRoleDto: CreateRoleDto = {
displayName: 'Complex Role Name With Spaces & Special Characters!',
roleType: 'project',
scopes: [testScopes.readScope.slug],
};
//
// ACT
//
const result = await roleService.createCustomRole(createRoleDto);
//
// ASSERT
//
// The actual implementation uses a specific slug generation pattern
expect(result.slug).toMatch(/^project:.+/);
expect(result.slug).toContain('complex');
expect(result.slug).toContain('role');
expect(result.slug).toContain('name');
});
});
describe('updateCustomRole', () => {
it('should update custom role with valid data', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const existingRole = await createCustomRoleWithScopes([testScopes.readScope], {
displayName: 'Original Role',
description: 'Original description',
});
const updateRoleDto: UpdateRoleDto = {
displayName: 'Updated Role',
description: 'Updated description',
scopes: [testScopes.writeScope.slug, testScopes.deleteScope.slug],
};
//
// ACT
//
const result = await roleService.updateCustomRole(existingRole.slug, updateRoleDto);
//
// ASSERT
//
expect(result).toMatchObject({
slug: existingRole.slug,
displayName: updateRoleDto.displayName,
description: updateRoleDto.description,
scopes: expect.arrayContaining(updateRoleDto.scopes as string[]),
});
// Verify database was updated
const updatedRole = await roleRepository.findBySlug(existingRole.slug);
expect(updatedRole?.displayName).toBe(updateRoleDto.displayName);
expect(updatedRole?.description).toBe(updateRoleDto.description);
});
it('should update displayName when provided', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const existingRole = await createCustomRoleWithScopes([testScopes.readScope], {
displayName: 'Original Role',
description: 'Original description',
});
const updateRoleDto: UpdateRoleDto = {
displayName: 'Updated Name Only',
};
//
// ACT
//
const result = await roleService.updateCustomRole(existingRole.slug, updateRoleDto);
//
// ASSERT
//
expect(result.displayName).toBe(updateRoleDto.displayName);
});
it('should update role with empty scopes array', async () => {
//
// ARRANGE
//
const testScopes = await createTestScopes();
const existingRole = await createCustomRoleWithScopes([
testScopes.readScope,
testScopes.writeScope,
]);
const updateRoleDto: UpdateRoleDto = {
scopes: [],
};
//
// ACT
//
const result = await roleService.updateCustomRole(existingRole.slug, updateRoleDto);
//
// ASSERT
//
expect(result.scopes).toEqual([]);
});
it('should throw error when role does not exist', async () => {
//
// ARRANGE
//
const nonExistentSlug = 'non-existent-role';
const updateRoleDto: UpdateRoleDto = {
displayName: 'Updated Name',
};
//
// ACT & ASSERT
//
await expect(roleService.updateCustomRole(nonExistentSlug, updateRoleDto)).rejects.toThrow(
'Role not found',
);
});
it('should throw error when invalid scopes are provided', async () => {
//
// ARRANGE
//
const existingRole = await createRole();
const updateRoleDto: UpdateRoleDto = {
scopes: ['invalid:scope'],
};
//
// ACT & ASSERT
//
await expect(roleService.updateCustomRole(existingRole.slug, updateRoleDto)).rejects.toThrow(
'The following scopes are invalid: invalid:scope',
);
});
});
describe('removeCustomRole', () => {
it('should remove custom role successfully', async () => {
//
// ARRANGE
//
const customRole = await createRole({
displayName: 'Role to Delete',
systemRole: false,
});
//
// ACT
//
const result = await roleService.removeCustomRole(customRole.slug);
//
// ASSERT
//
expect(result).toMatchObject({
slug: customRole.slug,
displayName: customRole.displayName,
systemRole: false,
});
// Verify role was deleted from database
const deletedRole = await roleRepository.findBySlug(customRole.slug);
expect(deletedRole).toBeNull();
});
it('should throw NotFoundError when role does not exist', async () => {
//
// ARRANGE
//
const nonExistentSlug = 'non-existent-role';
//
// ACT & ASSERT
//
await expect(roleService.removeCustomRole(nonExistentSlug)).rejects.toThrow(NotFoundError);
await expect(roleService.removeCustomRole(nonExistentSlug)).rejects.toThrow('Role not found');
});
it('should throw BadRequestError when trying to delete system role', async () => {
//
// ARRANGE
//
const systemRole = await createSystemRole({
displayName: 'System Role',
});
//
// ACT & ASSERT
//
await expect(roleService.removeCustomRole(systemRole.slug)).rejects.toThrow(BadRequestError);
await expect(roleService.removeCustomRole(systemRole.slug)).rejects.toThrow(
'Cannot delete system roles',
);
// Verify system role still exists
const stillExistsRole = await roleRepository.findBySlug(systemRole.slug);
expect(stillExistsRole).toBeDefined();
});
});
describe('isRoleLicensed', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
{ role: 'project:admin', licenseMethod: 'isProjectRoleAdminLicensed' },
{ role: 'project:editor', licenseMethod: 'isProjectRoleEditorLicensed' },
{ role: 'project:viewer', licenseMethod: 'isProjectRoleViewerLicensed' },
{ role: 'global:admin', licenseMethod: 'isAdvancedPermissionsLicensed' },
] as const)(
'should pass license check for built-in role $role',
async ({ role, licenseMethod }) => {
//
// ARRANGE
//
const mockLicenseResult = true;
jest.spyOn(licenseState, licenseMethod).mockReturnValue(mockLicenseResult);
//
// ACT
//
const result = roleService.isRoleLicensed(role);
//
// ASSERT
//
expect(result).toBe(mockLicenseResult);
expect(licenseState[licenseMethod]).toHaveBeenCalledTimes(1);
},
);
it.each([
{ role: 'project:admin', licenseMethod: 'isProjectRoleAdminLicensed' },
{ role: 'project:editor', licenseMethod: 'isProjectRoleEditorLicensed' },
{ role: 'project:viewer', licenseMethod: 'isProjectRoleViewerLicensed' },
{ role: 'global:admin', licenseMethod: 'isAdvancedPermissionsLicensed' },
] as const)(
'should fail license state check for built-in role $role',
async ({ role, licenseMethod }) => {
//
// ARRANGE
//
const mockLicenseResult = false;
jest.spyOn(licenseState, licenseMethod).mockReturnValue(mockLicenseResult);
//
// ACT
//
const result = roleService.isRoleLicensed(role);
//
// ASSERT
//
expect(result).toBe(mockLicenseResult);
expect(licenseState[licenseMethod]).toHaveBeenCalledTimes(1);
},
);
it('should return true for custom roles if licensed', async () => {
//
// ARRANGE
//
const customRoleSlug = 'custom:test-role';
const mockLicenseResult = true; // Random boolean
jest.spyOn(licenseState, 'isCustomRolesLicensed').mockReturnValue(mockLicenseResult);
//
// ACT
//
const result = roleService.isRoleLicensed(customRoleSlug as any);
//
// ASSERT
//
expect(result).toBe(mockLicenseResult);
expect(licenseState.isCustomRolesLicensed).toHaveBeenCalledTimes(1);
});
it('should return false for custom roles if not licensed', async () => {
//
// ARRANGE
//
const customRoleSlug = 'custom:test-role';
const mockLicenseResult = false; // Random boolean
jest.spyOn(licenseState, 'isCustomRolesLicensed').mockReturnValue(mockLicenseResult);
//
// ACT
//
const result = roleService.isRoleLicensed(customRoleSlug as any);
//
// ASSERT
//
expect(result).toBe(mockLicenseResult);
expect(licenseState.isCustomRolesLicensed).toHaveBeenCalledTimes(1);
});
});
describe('addScopes', () => {
it('should add scopes to workflow entity', async () => {
//
// ARRANGE
//
const user = await createMember();
const mockWorkflow = {
id: 'workflow-1',
name: 'Test Workflow',
active: true,
shared: [
{
projectId: 'project-1',
role: 'workflow:owner',
},
],
} as any;
const userProjectRelations = [] as any[];
//
// ACT
//
const result = roleService.addScopes(mockWorkflow, user, userProjectRelations);
//
// ASSERT
//
expect(result).toHaveProperty('scopes');
expect(Array.isArray(result.scopes)).toBe(true);
});
it('should add scopes to credential entity', async () => {
//
// ARRANGE
//
const user = await createMember();
const mockCredential = {
id: 'cred-1',
name: 'Test Credential',
type: 'testCredential',
shared: [
{
projectId: 'project-1',
role: 'credential:owner',
},
],
} as any;
const userProjectRelations = [] as any[];
//
// ACT
//
const result = roleService.addScopes(mockCredential, user, userProjectRelations);
//
// ASSERT
//
expect(result).toHaveProperty('scopes');
expect(Array.isArray(result.scopes)).toBe(true);
});
it('should return empty scopes when shared is undefined', async () => {
//
// ARRANGE
//
const user = await createMember();
const mockEntity = {
id: 'entity-1',
name: 'Test Entity',
active: true,
shared: undefined,
} as any;
const userProjectRelations = [] as any[];
//
// ACT
//
const result = roleService.addScopes(mockEntity, user, userProjectRelations);
//
// ASSERT
//
expect(result.scopes).toEqual([]);
});
it('should throw UnexpectedError when entity type cannot be detected', async () => {
//
// ARRANGE
//
const user = await createMember();
const mockEntity = {
id: 'entity-1',
name: 'Test Entity',
// Missing both 'active' and 'type' properties
shared: [],
} as any;
const userProjectRelations = [] as any[];
//
// ACT & ASSERT
//
expect(() => {
roleService.addScopes(mockEntity, user, userProjectRelations);
}).toThrow('Cannot detect if entity is a workflow or credential.');
});
});
});