import dbInit, { ITestDb } from '../helpers/database-init'; import getLogger from '../../fixtures/no-logger'; import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import ProjectService from '../../../lib/services/project-service'; import { AccessService } from '../../../lib/services/access-service'; import { MOVE_FEATURE_TOGGLE } from '../../../lib/types/permissions'; import { createTestConfig } from '../../config/test-config'; import { RoleName } from '../../../lib/types/model'; import { randomId } from '../../../lib/util/random-id'; import EnvironmentService from '../../../lib/services/environment-service'; import IncompatibleProjectError from '../../../lib/error/incompatible-project-error'; import { SegmentService } from '../../../lib/services/segment-service'; import { GroupService } from '../../../lib/services/group-service'; let stores; let db: ITestDb; let projectService: ProjectService; let groupService: GroupService; let accessService: AccessService; let environmentService: EnvironmentService; let featureToggleService: FeatureToggleService; let user; beforeAll(async () => { db = await dbInit('project_service_serial', getLogger); stores = db.stores; user = await stores.userStore.insert({ name: 'Some Name', email: 'test@getunleash.io', }); const config = createTestConfig({ getLogger, // @ts-ignore experimental: { environments: { enabled: true } }, }); groupService = new GroupService(stores, config); accessService = new AccessService(stores, config, groupService); featureToggleService = new FeatureToggleService( stores, config, new SegmentService(stores, config), accessService, ); environmentService = new EnvironmentService(stores, config); projectService = new ProjectService( stores, config, accessService, featureToggleService, groupService, ); }); afterAll(async () => { await db.destroy(); }); afterEach(async () => { const envs = await stores.environmentStore.getAll(); const deleteEnvs = envs .filter((env) => env.name !== 'default') .map(async (env) => { await stores.environmentStore.delete(env.name); }); const users = await stores.userStore.getAll(); const wipeUserPermissions = users.map(async (u) => { await stores.accessStore.unlinkUserRoles(u.id); }); await Promise.allSettled(deleteEnvs); await Promise.allSettled(wipeUserPermissions); }); test('should have default project', async () => { const project = await projectService.getProject('default'); expect(project).toBeDefined(); expect(project.id).toBe('default'); }); test('should list all projects', async () => { const project = { id: 'test-list', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projects = await projectService.getProjects(); expect(projects).toHaveLength(2); expect(projects.find((p) => p.name === project.name)?.memberCount).toBe(1); }); test('should create new project', async () => { const project = { id: 'test', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const ret = await projectService.getProject('test'); expect(project.id).toEqual(ret.id); expect(project.name).toEqual(ret.name); expect(project.description).toEqual(ret.description); expect(ret.createdAt).toBeTruthy(); }); test('should delete project', async () => { const project = { id: 'test-delete', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); await projectService.deleteProject(project.id, user); try { await projectService.getProject(project.id); } catch (err) { expect(err.message).toBe('No project found'); } }); test('should not be able to delete project with toggles', async () => { const project = { id: 'test-delete-with-toggles', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); await stores.featureToggleStore.create(project.id, { name: 'test-project-delete', project: project.id, enabled: false, }); try { await projectService.deleteProject(project.id, user); } catch (err) { expect(err.message).toBe( 'You can not delete a project with active feature toggles', ); } }); test('should not delete "default" project', async () => { try { await projectService.deleteProject('default', user); } catch (err) { expect(err.message).toBe('You can not delete the default project!'); } }); test('should validate name, legal', async () => { const result = await projectService.validateId('new_name'); expect(result).toBe(true); }); test('should not be able to create existing project', async () => { const project = { id: 'test-delete', name: 'New project', description: 'Blah', }; try { await projectService.createProject(project, user); await projectService.createProject(project, user); } catch (err) { expect(err.message).toBe('A project with this id already exists.'); } }); test('should require URL friendly ID', async () => { try { await projectService.validateId('new name øæå'); } catch (err) { expect(err.message).toBe('"value" must be URL friendly'); } }); test('should require unique ID', async () => { try { await projectService.validateId('default'); } catch (err) { expect(err.message).toBe('A project with this id already exists.'); } }); test('should update project', async () => { const project = { id: 'test-update', name: 'New project', description: 'Blah', }; const updatedProject = { id: 'test-update', name: 'New name', description: 'Blah longer desc', }; await projectService.createProject(project, user); await projectService.updateProject(updatedProject, user); const readProject = await projectService.getProject(project.id); expect(updatedProject.name).toBe(readProject.name); expect(updatedProject.description).toBe(readProject.description); }); test('should give error when getting unknown project', async () => { try { await projectService.getProject('unknown'); } catch (err) { expect(err.message).toBe('No project found'); } }); test('should get list of users with access to project', async () => { const project = { id: 'test-roles-access', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const { users } = await projectService.getAccessToProject(project.id); const member = await stores.roleStore.getRoleByName(RoleName.MEMBER); const owner = await stores.roleStore.getRoleByName(RoleName.OWNER); expect(users).toHaveLength(1); expect(users[0].id).toBe(user.id); expect(users[0].name).toBe(user.name); expect(users[0].roleId).toBe(owner.id); expect(member).toBeTruthy(); }); test('should add a member user to the project', async () => { const project = { id: 'add-users', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'member1@getunleash.io', }); const projectMember2 = await stores.userStore.insert({ name: 'Some Member 2', email: 'member2@getunleash.io', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await projectService.addUser( project.id, memberRole.id, projectMember2.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id); expect(memberUsers).toHaveLength(2); expect(memberUsers[0].id).toBe(projectMember1.id); expect(memberUsers[0].name).toBe(projectMember1.name); expect(memberUsers[1].id).toBe(projectMember2.id); expect(memberUsers[1].name).toBe(projectMember2.name); }); test('should add admin users to the project', async () => { const project = { id: 'add-admin-users', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectAdmin1 = await stores.userStore.insert({ name: 'Some Member', email: 'admin1@getunleash.io', }); const projectAdmin2 = await stores.userStore.insert({ name: 'Some Member 2', email: 'admin2@getunleash.io', }); const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER); await projectService.addUser( project.id, ownerRole.id, projectAdmin1.id, 'test', ); await projectService.addUser( project.id, ownerRole.id, projectAdmin2.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const adminUsers = users.filter((u) => u.roleId === ownerRole.id); expect(adminUsers).toHaveLength(3); expect(adminUsers[1].id).toBe(projectAdmin1.id); expect(adminUsers[1].name).toBe(projectAdmin1.name); expect(adminUsers[2].id).toBe(projectAdmin2.id); expect(adminUsers[2].name).toBe(projectAdmin2.name); }); test('add user should fail if user already have access', async () => { const project = { id: 'add-users-twice', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'member42@getunleash.io', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await expect(async () => projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ), ).rejects.toThrow( new Error('User already has access to project=add-users-twice'), ); }); test('should remove user from the project', async () => { const project = { id: 'remove-users', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'member99@getunleash.io', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await projectService.removeUser( project.id, memberRole.id, projectMember1.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id); expect(memberUsers).toHaveLength(0); }); test('should not remove user from the project', async () => { const project = { id: 'remove-users-not-allowed', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const roles = await stores.roleStore.getRolesForProject(project.id); const ownerRole = roles.find((r) => r.name === RoleName.OWNER); await expect(async () => { await projectService.removeUser( project.id, ownerRole.id, user.id, 'test', ); }).rejects.toThrowError( new Error('A project must have at least one owner'), ); }); test('should not change project if feature toggle project does not match current project id', async () => { const project = { id: 'test-change-project', name: 'New project', description: 'Blah', }; const toggle = { name: 'test-toggle' }; await projectService.createProject(project, user); await featureToggleService.createFeatureToggle(project.id, toggle, user); try { await projectService.changeProject( 'newProject', toggle.name, user, 'wrong-project-id', ); } catch (err) { expect(err.message).toBe( `You need permission=${MOVE_FEATURE_TOGGLE} to perform this action`, ); } }); test('should return 404 if no project is found with the project id', async () => { const project = { id: 'test-change-project-2', name: 'New project', description: 'Blah', }; const toggle = { name: 'test-toggle-2' }; await projectService.createProject(project, user); await featureToggleService.createFeatureToggle(project.id, toggle, user); try { await projectService.changeProject( 'newProject', toggle.name, user, project.id, ); } catch (err) { expect(err.message).toBe(`No project found`); } }); test('should fail if user is not authorized', async () => { const project = { id: 'test-change-project-3', name: 'New project', description: 'Blah', }; const projectDestination = { id: 'test-change-project-dest', name: 'New project 2', description: 'Blah', }; const toggle = { name: 'test-toggle-3' }; const projectAdmin1 = await stores.userStore.insert({ name: 'test-change-project-creator', email: 'admin-change-project@getunleash.io', }); await projectService.createProject(project, user); await projectService.createProject(projectDestination, projectAdmin1); await featureToggleService.createFeatureToggle(project.id, toggle, user); try { await projectService.changeProject( projectDestination.id, toggle.name, user, project.id, ); } catch (err) { expect(err.message).toBe( `You need permission=${MOVE_FEATURE_TOGGLE} to perform this action`, ); } }); test('should change project when checks pass', async () => { const projectA = { id: randomId(), name: randomId() }; const projectB = { id: randomId(), name: randomId() }; const toggle = { name: randomId() }; await projectService.createProject(projectA, user); await projectService.createProject(projectB, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user); await projectService.changeProject( projectB.id, toggle.name, user, projectA.id, ); const updatedFeature = await featureToggleService.getFeature({ featureName: toggle.name, }); expect(updatedFeature.project).toBe(projectB.id); }); test('changing project should emit event even if user does not have a username set', async () => { const projectA = { id: randomId(), name: randomId() }; const projectB = { id: randomId(), name: randomId() }; const toggle = { name: randomId() }; await projectService.createProject(projectA, user); await projectService.createProject(projectB, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user); const eventsBeforeChange = await stores.eventStore.getEvents(); await projectService.changeProject( projectB.id, toggle.name, user, projectA.id, ); const eventsAfterChange = await stores.eventStore.getEvents(); expect(eventsAfterChange.length).toBe(eventsBeforeChange.length + 1); }, 10000); test('should require equal project environments to move features', async () => { const projectA = { id: randomId(), name: randomId() }; const projectB = { id: randomId(), name: randomId() }; const environment = { name: randomId(), type: 'production' }; const toggle = { name: randomId() }; await projectService.createProject(projectA, user); await projectService.createProject(projectB, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user); await stores.environmentStore.create(environment); await environmentService.addEnvironmentToProject( environment.name, projectB.id, ); await expect(() => projectService.changeProject( projectB.id, toggle.name, user, projectA.id, ), ).rejects.toThrowError(IncompatibleProjectError); }); test('A newly created project only gets connected to enabled environments', async () => { const project = { id: 'environment-test', name: 'New environment project', description: 'Blah', }; const enabledEnv = 'connection_test'; await db.stores.environmentStore.create({ name: enabledEnv, type: 'test', }); const disabledEnv = 'do_not_connect'; await db.stores.environmentStore.create({ name: disabledEnv, type: 'test', enabled: false, }); await projectService.createProject(project, user); const connectedEnvs = await db.stores.projectStore.getEnvironmentsForProject(project.id); expect(connectedEnvs).toHaveLength(2); // default, connection_test expect(connectedEnvs.some((e) => e === enabledEnv)).toBeTruthy(); expect(connectedEnvs.some((e) => e === disabledEnv)).toBeFalsy(); }); test('should have environments sorted in order', async () => { const project = { id: 'environment-order-test', name: 'Environment testing project', description: '', }; const first = 'test'; const second = 'abc'; const third = 'example'; const fourth = 'mock'; await db.stores.environmentStore.create({ name: first, type: 'test', sortOrder: 1, }); await db.stores.environmentStore.create({ name: fourth, type: 'test', sortOrder: 4, }); await db.stores.environmentStore.create({ name: third, type: 'test', sortOrder: 3, }); await db.stores.environmentStore.create({ name: second, type: 'test', sortOrder: 2, }); await projectService.createProject(project, user); const connectedEnvs = await db.stores.projectStore.getEnvironmentsForProject(project.id); expect(connectedEnvs).toEqual(['default', first, second, third, fourth]); }); test('should add a user to the project with a custom role', async () => { const project = { id: 'add-users-custom-role', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Custom', email: 'custom@getunleash.io', }); const customRole = await accessService.createRole({ name: 'Service Engineer2', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, { id: 8, name: 'DELETE_FEATURE', environment: null, displayName: 'Delete Feature Toggles', type: 'project', }, ], }); await projectService.addUser( project.id, customRole.id, projectMember1.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const customRoleMember = users.filter((u) => u.roleId === customRole.id); expect(customRoleMember).toHaveLength(1); expect(customRoleMember[0].id).toBe(projectMember1.id); expect(customRoleMember[0].name).toBe(projectMember1.name); }); test('should delete role entries when deleting project', async () => { const project = { id: 'test-delete-users-1', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const user1 = await stores.userStore.insert({ name: 'Projectuser1', email: 'project1@getunleash.io', }); const user2 = await stores.userStore.insert({ name: 'Projectuser2', email: 'project2@getunleash.io', }); const customRole = await accessService.createRole({ name: 'Service Engineer', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, { id: 8, name: 'DELETE_FEATURE', environment: null, displayName: 'Delete Feature Toggles', type: 'project', }, ], }); await projectService.addUser(project.id, customRole.id, user1.id, 'test'); await projectService.addUser(project.id, customRole.id, user2.id, 'test'); let usersForRole = await accessService.getUsersForRole(customRole.id); expect(usersForRole.length).toBe(2); await projectService.deleteProject(project.id, user); usersForRole = await accessService.getUsersForRole(customRole.id); expect(usersForRole.length).toBe(0); }); test('should change a users role in the project', async () => { const project = { id: 'test-change-user-role', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectUser = await stores.userStore.insert({ name: 'Projectuser3', email: 'project3@getunleash.io', }); const customRole = await accessService.createRole({ name: 'Service Engineer3', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, { id: 8, name: 'DELETE_FEATURE', environment: null, displayName: 'Delete Feature Toggles', type: 'project', }, ], }); const member = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser(project.id, member.id, projectUser.id, 'test'); const { users } = await projectService.getAccessToProject(project.id); let memberUser = users.filter((u) => u.roleId === member.id); expect(memberUser).toHaveLength(1); expect(memberUser[0].id).toBe(projectUser.id); expect(memberUser[0].name).toBe(projectUser.name); await projectService.removeUser( project.id, member.id, projectUser.id, 'test', ); await projectService.addUser( project.id, customRole.id, projectUser.id, 'test', ); let { users: updatedUsers } = await projectService.getAccessToProject( project.id, ); const customUser = updatedUsers.filter((u) => u.roleId === customRole.id); expect(customUser).toHaveLength(1); expect(customUser[0].id).toBe(projectUser.id); expect(customUser[0].name).toBe(projectUser.name); }); test('should update role for user on project', async () => { const project = { id: 'update-users', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'update99@getunleash.io', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await projectService.changeRole( project.id, ownerRole.id, projectMember1.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id); const ownerUsers = users.filter((u) => u.roleId === ownerRole.id); expect(memberUsers).toHaveLength(0); expect(ownerUsers).toHaveLength(2); }); test('should able to assign role without existing members', async () => { const project = { id: 'update-users-test', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'update1999@getunleash.io', }); const testRole = await stores.roleStore.create({ name: 'Power user', roleType: 'custom', description: 'Grants access to modify all environments', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await projectService.changeRole( project.id, testRole.id, projectMember1.id, 'test', ); const { users } = await projectService.getAccessToProject(project.id); const memberUsers = users.filter((u) => u.roleId === memberRole.id); const testUsers = users.filter((u) => u.roleId === testRole.id); expect(memberUsers).toHaveLength(0); expect(testUsers).toHaveLength(1); }); test('should not update role for user on project when she is the owner', async () => { const project = { id: 'update-users-not-allowed', name: 'New project', description: 'Blah', }; await projectService.createProject(project, user); const projectMember1 = await stores.userStore.insert({ name: 'Some Member', email: 'update991@getunleash.io', }); const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); await projectService.addUser( project.id, memberRole.id, projectMember1.id, 'test', ); await expect(async () => { await projectService.changeRole( project.id, memberRole.id, user.id, 'test', ); }).rejects.toThrowError( new Error('A project must have at least one owner'), ); }); test('Should allow bulk update of group permissions', async () => { const project = { id: 'bulk-update-project', name: 'bulk-update-project', }; await projectService.createProject(project, user.id); const groupStore = stores.groupStore; const user1 = await stores.userStore.insert({ name: 'Vanessa Viewer', email: 'vanv@getunleash.io', }); const group1 = await groupStore.create({ name: 'ViewersOnly', description: '', }); const createFeatureRole = await accessService.createRole({ name: 'CreateRole', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, ], }); await projectService.addAccess( project.id, createFeatureRole.id, { users: [{ id: user1.id }], groups: [{ id: group1.id }], }, 'some-admin-user', ); }); test('Should bulk update of only users', async () => { const project = 'bulk-update-project-users'; const user1 = await stores.userStore.insert({ name: 'Van Viewer', email: 'vv@getunleash.io', }); const createFeatureRole = await accessService.createRole({ name: 'CreateRoleForUsers', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, ], }); await projectService.addAccess( project, createFeatureRole.id, { users: [{ id: user1.id }], groups: [], }, 'some-admin-user', ); }); test('Should allow bulk update of only groups', async () => { const project = { id: 'bulk-update-project-only', name: 'bulk-update-project-only', }; const groupStore = stores.groupStore; await projectService.createProject(project, user.id); const group1 = await groupStore.create({ name: 'ViewersOnly', description: '', }); const createFeatureRole = await accessService.createRole({ name: 'CreateRoleForGroups', description: '', permissions: [ { id: 2, name: 'CREATE_FEATURE', environment: null, displayName: 'Create Feature Toggles', type: 'project', }, ], }); await projectService.addAccess( project.id, createFeatureRole.id, { users: [], groups: [{ id: group1.id }], }, 'some-admin-user', ); });