mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
708 lines
18 KiB
TypeScript
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.');
|
|
});
|
|
});
|
|
});
|