1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00
unleash.unleash/src/lib/features/project/project-service.e2e.test.ts
2024-09-26 11:27:59 +02:00

2999 lines
90 KiB
TypeScript

import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import getLogger from '../../../test/fixtures/no-logger';
import type FeatureToggleService from '../feature-toggle/feature-toggle-service';
import type ProjectService from './project-service';
import type { AccessService } from '../../services/access-service';
import { MOVE_FEATURE_TOGGLE } from '../../types/permissions';
import { createTestConfig } from '../../../test/config/test-config';
import { RoleName } from '../../types/model';
import { randomId } from '../../util/random-id';
import EnvironmentService from '../project-environments/environment-service';
import IncompatibleProjectError from '../../error/incompatible-project-error';
import type { ApiTokenService, EventService } from '../../services';
import { FeatureEnvironmentEvent } from '../../types/events';
import { addDays, subDays } from 'date-fns';
import {
createAccessService,
createEventsService,
createFeatureToggleService,
createProjectService,
} from '../index';
import {
type IAuditUser,
type IGroup,
type IUnleashStores,
type IUser,
SYSTEM_USER_AUDIT,
SYSTEM_USER_ID,
TEST_AUDIT_USER,
} from '../../types';
import type { User } from '../../server-impl';
import { BadDataError, InvalidOperationError } from '../../error';
import { DEFAULT_ENV, extractAuditInfoFromUser } from '../../util';
import { ApiTokenType } from '../../types/models/api-token';
import { createApiTokenService } from '../api-tokens/createApiTokenService';
let stores: IUnleashStores;
let db: ITestDb;
let projectService: ProjectService;
let accessService: AccessService;
let eventService: EventService;
let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService;
let user: User; // many methods in this test use User instead of IUser
let auditUser: IAuditUser;
let apiTokenService: ApiTokenService;
let opsUser: IUser;
let group: IGroup;
const isProjectUser = async (
userId: number,
projectName: string,
condition: boolean,
) => {
expect(await projectService.isProjectUser(userId, projectName)).toBe(
condition,
);
};
beforeAll(async () => {
db = await dbInit('project_service_serial', getLogger);
stores = db.stores;
// @ts-ignore return type IUser type missing generateImageUrl
user = await stores.userStore.insert({
name: 'Some Name',
email: 'test@getunleash.io',
});
auditUser = {
id: user.id,
username: user.email,
ip: '127.0.0.1',
};
group = await stores.groupStore.create({
name: 'aTestGroup',
description: '',
});
opsUser = await stores.userStore.insert({
name: 'Test user',
email: 'test@example.com',
});
await stores.accessStore.addUserToRole(opsUser.id, 1, '');
const config = createTestConfig({
getLogger,
experimental: {},
});
eventService = createEventsService(db.rawDatabase, config);
accessService = createAccessService(db.rawDatabase, config);
featureToggleService = createFeatureToggleService(db.rawDatabase, config);
environmentService = new EnvironmentService(stores, config, eventService);
projectService = createProjectService(db.rawDatabase, config);
apiTokenService = createApiTokenService(db.rawDatabase, config);
});
beforeEach(async () => {
await stores.accessStore.addUserToRole(opsUser.id, 1, '');
});
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 stores.eventStore.deleteAll();
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',
mode: 'open' as const,
defaultStickiness: 'default',
};
await projectService.createProject(project, user, auditUser);
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',
defaultStickiness: 'default',
};
await projectService.createProject(project, user, auditUser);
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();
const projectsById = await projectService.getProjects({ id: 'test' });
const projectsByIds = await projectService.getProjects({ ids: ['test'] });
expect(projectsById).toMatchObject([{ id: 'test' }]);
expect(projectsByIds).toMatchObject([{ id: 'test' }]);
});
test('should create new private project', async () => {
const project = {
id: 'testPrivate',
name: 'New private project',
description: 'Blah',
defaultStickiness: 'default',
};
await projectService.createProject(project, user, auditUser);
const ret = await projectService.getProject('testPrivate');
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',
mode: 'open' as const,
defaultStickiness: 'default',
};
await projectService.createProject(project, user, auditUser);
await projectService.deleteProject(project.id, user, auditUser);
try {
await projectService.getProject(project.id);
} catch (err) {
expect(err.message).toBe('No project found');
}
});
test('should not be able to delete project with flags', async () => {
const project = {
id: 'test-delete-with-flags',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, {
name: 'test-project-delete',
createdByUserId: 9999,
});
try {
await projectService.deleteProject(project.id, user, auditUser);
} catch (err) {
expect(err.message).toBe(
'You can not delete a project with active feature flags',
);
}
});
test('should not delete "default" project', async () => {
try {
await projectService.deleteProject('default', user, auditUser);
} 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',
mode: 'open' as const,
defaultStickiness: 'default',
};
try {
await projectService.createProject(project, user, auditUser);
await projectService.createProject(project, user, auditUser);
} 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',
mode: 'open' as const,
defaultStickiness: 'default',
};
const updatedProject = {
id: 'test-update',
name: 'New name',
description: 'Blah longer desc',
mode: 'protected' as const,
defaultStickiness: 'userId',
};
await projectService.createProject(project, user, TEST_AUDIT_USER);
await projectService.updateProject(updatedProject, TEST_AUDIT_USER);
const readProject = await projectService.getProject(project.id);
expect(updatedProject.name).toBe(readProject.name);
expect(updatedProject.description).toBe(readProject.description);
expect(updatedProject.mode).toBe('protected');
expect(updatedProject.defaultStickiness).toBe('userId');
});
test('should archive project', async () => {
const project = {
id: 'test-archive',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'default',
};
await projectService.createProject(project, user, TEST_AUDIT_USER);
await projectService.archiveProject(project.id, TEST_AUDIT_USER);
const events = await stores.eventStore.getEvents();
expect(events[0]).toMatchObject({
type: 'project-archived',
createdBy: TEST_AUDIT_USER.username,
});
const projects = await projectService.getProjects();
expect(projects.find((p) => p.id === project.id)).toBeUndefined();
expect(projects.length).not.toBe(0);
const archivedProjects = await projectService.getProjects({
archived: true,
});
expect(archivedProjects).toMatchObject([
{ id: 'test-archive', archivedAt: expect.any(Date) },
]);
const archivedProject = await projectService.getProject(project.id);
expect(archivedProject).toMatchObject({ archivedAt: expect.any(Date) });
});
test('archive project removes it from user projects', async () => {
const project = {
id: 'test-user-archive',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'default',
};
await projectService.createProject(project, user, TEST_AUDIT_USER);
const userProjectsBeforeArchive = await projectService.getProjectsByUser(
user.id,
);
expect(userProjectsBeforeArchive).toEqual(['test-user-archive']);
await projectService.archiveProject(project.id, TEST_AUDIT_USER);
const userProjects = await projectService.getProjectsByUser(user.id);
expect(userProjects).toEqual([]);
});
test('should revive project', async () => {
const project = {
id: 'test-revive',
name: 'New project',
mode: 'open' as const,
};
await projectService.createProject(project, user, TEST_AUDIT_USER);
await projectService.archiveProject(project.id, TEST_AUDIT_USER);
await projectService.reviveProject(project.id, TEST_AUDIT_USER);
const events = await stores.eventStore.getEvents();
expect(events[0]).toMatchObject({
type: 'project-revived',
createdBy: TEST_AUDIT_USER.username,
});
const projects = await projectService.getProjects();
expect(projects.find((p) => p.id === project.id)).toMatchObject(project);
});
test('should not be able to archive project with flags', async () => {
const project = {
id: 'test-archive-with-flags',
name: 'New project',
mode: 'open' as const,
};
await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, {
name: 'test-project-archive',
createdByUserId: 9999,
});
try {
await projectService.archiveProject(project.id, auditUser);
} catch (err) {
expect(err.message).toBe(
'You can not archive a project with active feature flags',
);
}
});
test('should update project without existing settings', async () => {
const project = {
id: 'test-update-legacy',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'default',
};
const updatedProject = {
id: 'test-update-legacy',
name: 'New name',
description: 'Blah longer desc',
mode: 'protected' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, {
id: user.id,
username: user.email,
ip: '127.0.0.1',
});
await db
.rawDatabase('project_settings')
.del()
.where({ project: project.id });
await projectService.updateProject(updatedProject, auditUser);
const readProject = await projectService.getProject(project.id);
expect(updatedProject.name).toBe(readProject.name);
expect(updatedProject.description).toBe(readProject.description);
expect(updatedProject.mode).toBe('protected');
expect(updatedProject.defaultStickiness).toBe('clientId');
});
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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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();
await isProjectUser(users[0].id, project.id, true);
});
test('should add a member user to the project', async () => {
const project = {
id: 'add-users',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await projectService.addUser(
project.id,
memberRole.id,
projectMember2.id,
auditUser,
);
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);
expect(await projectService.getProjectUsers(project.id)).toStrictEqual([
{ email: user.email, id: user.id, username: user.username },
{
email: projectMember1.email,
id: projectMember1.id,
username: projectMember1.username,
},
{
email: projectMember2.email,
id: projectMember2.id,
username: projectMember2.username,
},
]);
});
describe('Managing Project access', () => {
test('Admin users should be allowed to add any project role', async () => {
const project = {
id: 'admin-project-admin',
name: 'admin',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const customRole = await stores.roleStore.create({
name: 'my_custom_role_admin_user',
roleType: 'custom',
description:
'Used to prove that you can assign a role when you are admin',
});
const projectUserAdmin = await stores.userStore.insert({
name: 'Some project user',
email: 'user_admin@example.com',
});
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await expect(
projectService.addAccess(
project.id,
[customRole.id, ownerRole.id],
[],
[projectUserAdmin.id],
auditUser,
),
).resolves.not.toThrow();
});
test('Admin group members should be allowed to add any project role', async () => {
const viewerUser = await stores.userStore.insert({
name: 'Some project admin',
email: 'some_project_admin@example.com',
});
await accessService.setUserRootRole(viewerUser.id, RoleName.VIEWER);
const adminRole = await stores.roleStore.getRoleByName(RoleName.ADMIN);
const adminGroup = await stores.groupStore.create({
name: 'admin_group',
rootRole: adminRole.id,
});
await stores.groupStore.addUsersToGroup(
adminGroup.id,
[{ user: { id: viewerUser.id } }],
opsUser.username!,
);
const project = {
id: 'some-project',
name: 'sp',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const customRole = await stores.roleStore.create({
name: 'my_custom_project_role_admin_user',
roleType: 'custom',
description:
'Used to prove that you can assign a role when you are admin',
});
await expect(
projectService.addAccess(
project.id,
[customRole.id], // roles
[], // groups
[opsUser.id], // users
extractAuditInfoFromUser(viewerUser),
),
).resolves.not.toThrow();
});
test('Users with project owner should be allowed to add any project role', async () => {
const project = {
id: 'project-owner',
name: 'Owner',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectAdmin = await stores.userStore.insert({
name: 'Some project admin',
email: 'some_other_project_admin@example.com',
});
const projectCustomer = await stores.userStore.insert({
name: 'Some project customer',
email: 'some_project_customer@example.com',
});
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await accessService.addUserToRole(
projectAdmin.id,
ownerRole.id,
project.id,
);
const customRole = await stores.roleStore.create({
name: 'my_custom_project_role',
roleType: 'custom',
description:
'Used to prove that you can assign a role the project owner does not have',
});
await expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[projectCustomer.id],
auditUser,
),
).resolves.not.toThrow();
});
test('Users with project role should only be allowed to grant same role to others', async () => {
const project = {
id: 'project_role',
name: 'custom_role',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'user@example.com',
});
const projectAuditUser = extractAuditInfoFromUser(projectUser);
const secondUser = await stores.userStore.insert({
name: 'Some other user',
email: 'otheruser@example.com',
});
const customRole = await stores.roleStore.create({
name: 'my_custom_role_project_role',
roleType: 'custom',
description:
'Used to prove that you can assign a role the project owner does not have',
});
await accessService.addUserToRole(
projectUser.id,
customRole.id,
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
await expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[secondUser.id],
projectAuditUser,
),
).resolves.not.toThrow();
await expect(async () =>
projectService.addAccess(
project.id,
[ownerRole.id],
[],
[secondUser.id],
projectAuditUser,
),
).rejects.toThrow(
new InvalidOperationError(
'User tried to grant role they did not have access to',
),
);
});
test('Users that are members of a group with project role should only be allowed to grant same role to others', async () => {
const project = {
id: 'project_group_role',
name: 'custom_role',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'user_with_group_membership@example.com',
});
const projectAuditUser = extractAuditInfoFromUser(projectUser);
const group = await stores.groupStore.create({
name: 'custom_group_for_role_access',
});
await stores.groupStore.addUsersToGroup(
group.id,
[{ user: { id: projectUser.id } }],
opsUser.username!,
);
const secondUser = await stores.userStore.insert({
name: 'Some other user',
email: 'otheruser_from_group_members@example.com',
});
const customRole = await stores.roleStore.create({
name: 'my_custom_role_from_group_members',
roleType: 'custom',
description:
'Used to prove that you can assign a role via a group membership',
});
await accessService.addGroupToRole(
group.id,
customRole.id,
opsUser.username!,
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
const otherGroup = await stores.groupStore.create({
name: 'custom_group_to_receive_new_access',
});
await expect(
projectService.addAccess(
project.id,
[customRole.id],
[],
[secondUser.id],
projectAuditUser,
),
).resolves.not.toThrow();
await expect(
projectService.addAccess(
project.id,
[customRole.id],
[otherGroup.id],
[],
projectAuditUser,
),
).resolves.not.toThrow();
await expect(
projectService.addAccess(
project.id,
[ownerRole.id],
[],
[secondUser.id],
projectAuditUser,
),
).rejects.toThrow(
new InvalidOperationError(
'User tried to grant role they did not have access to',
),
);
});
test('Users can assign roles they have to a group', async () => {
const project = {
id: 'user_assign_to_group',
name: 'user_assign_to_group',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'assign_role_to_group@example.com',
});
const secondGroup = await stores.groupStore.create({
name: 'custom_group_awaiting_new_role',
});
const customRole = await stores.roleStore.create({
name: 'role_assigned_to_group',
roleType: 'custom',
description:
'Used to prove that you can assign a role via a group membership',
});
await accessService.addUserToRole(
projectUser.id,
customRole.id,
project.id,
);
await expect(
projectService.addAccess(
project.id,
[customRole.id],
[secondGroup.id],
[],
auditUser,
),
).resolves.not.toThrow(
new InvalidOperationError(
'User tried to assign a role they did not have access to',
),
);
});
test('Users can not assign roles where they do not hold the same permissions', async () => {
const project = {
id: 'user_fail_assign_to_user',
name: 'user_fail_assign_to_user',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const auditUser = extractAuditInfoFromUser(user);
await projectService.createProject(project, user, auditUser);
const projectUser = await stores.userStore.insert({
name: 'Some project user',
email: 'fail_assign_role_to_user@example.com',
});
const secondUser = await stores.userStore.insert({
name: 'Some other user',
email: 'otheruser_no_roles@example.com',
});
const customRoleUserAccess = await accessService.createRole(
{
name: 'Project-permissions-lead',
description: 'Role',
permissions: [
{
name: 'PROJECT_USER_ACCESS_WRITE',
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
const customRoleUpdateEnvironments = await accessService.createRole(
{
name: 'Project Lead',
description: 'Role',
permissions: [
{
name: 'UPDATE_FEATURE_ENVIRONMENT',
environment: 'production',
},
{
name: 'CREATE_FEATURE_STRATEGY',
environment: 'production',
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await projectService.setRolesForUser(
project.id,
projectUser.id,
[customRoleUserAccess.id],
auditUser,
);
const auditProjectUser = extractAuditInfoFromUser(projectUser);
await expect(
projectService.setRolesForUser(
project.id,
secondUser.id,
[customRoleUpdateEnvironments.id],
auditProjectUser,
),
).rejects.toThrow(
new InvalidOperationError(
'User tried to assign a role they did not have access to',
),
);
const group = await stores.groupStore.create({
name: 'Some group_awaiting_role',
});
await expect(
projectService.setRolesForGroup(
project.id,
group.id,
[customRoleUpdateEnvironments.id],
auditProjectUser,
),
).rejects.toThrow(
new InvalidOperationError(
'User tried to assign a role they did not have access to',
),
);
});
});
test('should add admin users to the project', async () => {
const project = {
id: 'add-admin-users',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await projectService.addUser(
project.id,
ownerRole.id,
projectAdmin2.id,
auditUser,
);
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);
await isProjectUser(adminUsers[0].id, project.id, true);
await isProjectUser(adminUsers[1].id, project.id, true);
await isProjectUser(adminUsers[2].id, project.id, true);
});
test('add user should fail if user already have access', async () => {
const project = {
id: 'add-users-twice',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await expect(async () =>
projectService.addUser(
project.id,
memberRole.id,
projectMember1.id,
auditUser,
),
).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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await projectService.removeUser(
project.id,
memberRole.id,
projectMember1.id,
auditUser,
);
const { users } = await projectService.getAccessToProject(project.id);
const memberUsers = users.filter((u) => u.roleId === memberRole.id);
expect(memberUsers).toHaveLength(0);
});
test('should not change project if feature flag project does not match current project id', async () => {
const project = {
id: 'test-change-project',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const flag = { name: 'test-flag' };
await projectService.createProject(project, user, auditUser);
await featureToggleService.createFeatureToggle(project.id, flag, auditUser);
try {
await projectService.changeProject(
'newProject',
flag.name,
user,
'wrong-project-id',
auditUser,
);
} catch (err) {
expect(err.message.toLowerCase().includes('permission')).toBeTruthy();
expect(err.message.includes(MOVE_FEATURE_TOGGLE)).toBeTruthy();
}
});
test('should return 404 if no active project is found with the project id', async () => {
const project = {
id: 'test-change-project-2',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const flag = { name: 'test-flag-2' };
await projectService.createProject(project, user, auditUser);
await featureToggleService.createFeatureToggle(project.id, flag, auditUser);
try {
await projectService.changeProject(
'newProject',
flag.name,
user,
project.id,
auditUser,
);
} catch (err) {
expect(err.message).toBe(
`Active project with id newProject does not exist`,
);
}
const newProject = {
id: 'newProject',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(newProject, user, auditUser);
await projectService.archiveProject(newProject.id, TEST_AUDIT_USER);
try {
await projectService.changeProject(
'newProject',
flag.name,
user,
project.id,
auditUser,
);
} catch (err) {
expect(err.message).toBe(
`Active project with id newProject does not exist`,
);
}
});
test('should fail if user is not authorized', async () => {
const project = {
id: 'test-change-project-3',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const projectDestination = {
id: 'test-change-project-dest',
name: 'New project 2',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const flag = { name: 'test-flag-3' };
const projectAdmin1 = await stores.userStore.insert({
name: 'test-change-project-creator',
email: 'admin-change-project@getunleash.io',
});
await projectService.createProject(project, user, auditUser);
await projectService.createProject(
projectDestination,
projectAdmin1,
auditUser,
);
await featureToggleService.createFeatureToggle(project.id, flag, auditUser);
try {
await projectService.changeProject(
projectDestination.id,
flag.name,
user,
project.id,
auditUser,
);
} catch (err) {
expect(err.message.toLowerCase().includes('permission')).toBeTruthy();
expect(err.message.includes(MOVE_FEATURE_TOGGLE)).toBeTruthy();
}
});
test('should change project when checks pass', async () => {
const projectA = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const flag = { name: randomId() };
await projectService.createProject(projectA, user, auditUser);
await projectService.createProject(projectB, user, auditUser);
await featureToggleService.createFeatureToggle(
projectA.id,
flag,
auditUser,
);
await projectService.changeProject(
projectB.id,
flag.name,
user,
projectA.id,
auditUser,
);
const updatedFeature = await featureToggleService.getFeature({
featureName: flag.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(),
mode: 'open' as const,
defaultStickiness: 'default',
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const flag = { name: randomId() };
await projectService.createProject(projectA, user, auditUser);
await projectService.createProject(projectB, user, auditUser);
await featureToggleService.createFeatureToggle(
projectA.id,
flag,
auditUser,
);
const eventsBeforeChange = await stores.eventStore.getEvents();
await projectService.changeProject(
projectB.id,
flag.name,
user,
projectA.id,
auditUser,
);
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(),
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const projectB = {
id: randomId(),
name: randomId(),
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const environment = { name: randomId(), type: 'production' };
const flag = { name: randomId() };
await projectService.createProject(projectA, user, auditUser);
await projectService.createProject(projectB, user, auditUser);
await featureToggleService.createFeatureToggle(
projectA.id,
flag,
auditUser,
);
await stores.environmentStore.create(environment);
await environmentService.addEnvironmentToProject(
environment.name,
projectB.id,
auditUser,
);
await expect(() =>
projectService.changeProject(
projectB.id,
flag.name,
user,
projectA.id,
auditUser,
),
).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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
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, auditUser);
const connectedEnvs =
await db.stores.projectStore.getEnvironmentsForProject(project.id);
expect(connectedEnvs).toHaveLength(2); // default, connection_test
expect(
connectedEnvs.some((e) => e.environment === enabledEnv),
).toBeTruthy();
expect(
connectedEnvs.some((e) => e.environment === disabledEnv),
).toBeFalsy();
});
test('should have environments sorted in order', async () => {
const project = {
id: 'environment-order-test',
name: 'Environment testing project',
description: '',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
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, auditUser);
const connectedEnvs =
await db.stores.projectStore.getEnvironmentsForProject(project.id);
expect(connectedEnvs.map((e) => e.environment)).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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectMember1 = await stores.userStore.insert({
name: 'Custom',
email: 'custom@getunleash.io',
});
const customRole = await accessService.createRole(
{
name: 'Service Engineer2',
description: '',
permissions: [
{
id: 2, // CREATE_FEATURE
},
{
id: 8, // DELETE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await projectService.addUser(
project.id,
customRole.id,
projectMember1.id,
auditUser,
);
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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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, // CREATE_FEATURE
},
{
id: 8, // DELETE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await projectService.addUser(
project.id,
customRole.id,
user1.id,
auditUser,
);
await projectService.addUser(
project.id,
customRole.id,
user2.id,
auditUser,
);
let usersForRole = await accessService.getUsersForRole(customRole.id);
expect(usersForRole.length).toBe(2);
await projectService.deleteProject(project.id, user, auditUser);
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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const projectUser = await stores.userStore.insert({
name: 'Projectuser3',
email: 'project3@getunleash.io',
});
const customRole = await accessService.createRole(
{
name: 'Service Engineer3',
description: '',
permissions: [
{
id: 2, // CREATE_FEATURE
},
{
id: 8, // DELETE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
const member = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await projectService.addUser(
project.id,
member.id,
projectUser.id,
auditUser,
);
const { users } = await projectService.getAccessToProject(project.id);
const 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,
auditUser,
);
await projectService.addUser(
project.id,
customRole.id,
projectUser.id,
auditUser,
);
const { 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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await projectService.changeRole(
project.id,
ownerRole.id,
projectMember1.id,
auditUser,
);
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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await projectService.changeRole(
project.id,
testRole.id,
projectMember1.id,
auditUser,
);
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);
});
describe('ensure project has at least one owner', () => {
test('should not remove user from the project', async () => {
const project = {
id: 'remove-users-not-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.removeUserAccess(
project.id,
user.id,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
test('should be able to remove member user from the project when another is owner', async () => {
const project = {
id: 'remove-users-members-allowed',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const memberRole = await stores.roleStore.getRoleByName(
RoleName.MEMBER,
);
const memberUser = await stores.userStore.insert({
name: 'Some Name',
email: 'member@getunleash.io',
});
await projectService.addAccess(
project.id,
[memberRole.id],
[],
[memberUser.id],
auditUser,
);
const usersBefore = await projectService.getProjectUsers(project.id);
await projectService.removeUserAccess(
project.id,
memberUser.id,
auditUser,
);
const usersAfter = await projectService.getProjectUsers(project.id);
expect(usersBefore).toHaveLength(2);
expect(usersAfter).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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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,
auditUser,
);
await expect(async () => {
await projectService.changeRole(
project.id,
memberRole.id,
user.id,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.setRolesForUser(
project.id,
user.id,
[memberRole.id],
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
async function projectWithGroupOwner(projectId: string) {
const project = {
id: projectId,
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const roles = await stores.roleStore.getRolesForProject(project.id);
const ownerRole = roles.find((r) => r.name === RoleName.OWNER)!;
await projectService.addGroup(
project.id,
ownerRole.id,
group.id,
auditUser,
);
// this should be fine, leaving the group as the only owner
// note group has zero members, but it still acts as an owner
await projectService.removeUser(
project.id,
ownerRole.id,
user.id,
auditUser,
);
return {
project,
group,
ownerRole,
};
}
test('should not remove group from the project', async () => {
const { project, group, ownerRole } = await projectWithGroupOwner(
'remove-group-not-allowed',
);
await expect(async () => {
await projectService.removeGroup(
project.id,
ownerRole.id,
group.id,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.removeGroupAccess(
project.id,
group.id,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
test('should not update role for group on project when she is the owner', async () => {
const { project, group } = await projectWithGroupOwner(
'update-group-not-allowed',
);
const memberRole = await stores.roleStore.getRoleByName(
RoleName.MEMBER,
);
await expect(async () => {
await projectService.changeGroupRole(
project.id,
memberRole.id,
group.id,
auditUser,
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.setRolesForGroup(
project.id,
group.id,
[memberRole.id],
auditUser,
);
}).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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
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, // CREATE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await stores.accessStore.addUserToRole(
opsUser.id,
createFeatureRole.id,
project.id,
);
await projectService.addAccess(
project.id,
[createFeatureRole.id],
[group1.id],
[user1.id],
auditUser,
);
});
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, // CREATE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
const auditUserFromOps = extractAuditInfoFromUser(opsUser);
await projectService.addAccess(
project,
[createFeatureRole.id],
[],
[user1.id],
auditUserFromOps,
);
});
test('Should allow bulk update of only groups', async () => {
const project = {
id: 'bulk-update-project-only',
name: 'bulk-update-project-only',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const groupStore = stores.groupStore;
await projectService.createProject(project, user, auditUser);
const group1 = await groupStore.create({
name: 'ViewersOnly',
description: '',
});
const createFeatureRole = await accessService.createRole(
{
name: 'CreateRoleForGroups',
description: '',
permissions: [
{
id: 2, // CREATE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await projectService.addAccess(
project.id,
[createFeatureRole.id],
[group1.id],
[],
auditUser,
);
});
test('Should allow permutations of roles, groups and users when adding a new access', async () => {
const project = {
id: 'project-access-permutations',
name: 'project-access-permutations',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const group1 = await stores.groupStore.create({
name: 'permutation-group-1',
description: '',
});
const group2 = await stores.groupStore.create({
name: 'permutation-group-2',
description: '',
});
const user1 = await stores.userStore.insert({
name: 'permutation-user-1',
email: 'pu1@getunleash.io',
});
const user2 = await stores.userStore.insert({
name: 'permutation-user-2',
email: 'pu2@getunleash.io',
});
const role1 = await accessService.createRole(
{
name: 'permutation-role-1',
description: '',
permissions: [
{
id: 2, // CREATE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
const role2 = await accessService.createRole(
{
name: 'permutation-role-2',
description: '',
permissions: [
{
id: 7, // UPDATE_FEATURE
},
],
createdByUserId: SYSTEM_USER_ID,
},
SYSTEM_USER_AUDIT,
);
await projectService.addAccess(
project.id,
[role1.id, role2.id],
[group1.id, group2.id],
[user1.id, user2.id],
auditUser,
);
const { users, groups } = await projectService.getAccessToProject(
project.id,
);
const ownerRole = await stores.roleStore.getRoleByName(RoleName.OWNER);
expect(users).toHaveLength(3); // the 2 added plus the one that created the project
expect(groups).toHaveLength(2);
expect(users[0].roles).toStrictEqual([ownerRole.id]);
expect(users[1].roles).toStrictEqual([role1.id, role2.id]);
expect(groups[0].roles).toStrictEqual([role1.id, role2.id]);
});
test('should only count active feature flags for project', async () => {
const project = {
id: 'only-active',
name: 'New project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, {
name: 'only-active-t1',
createdByUserId: 9999,
});
await stores.featureToggleStore.create(project.id, {
name: 'only-active-t2',
createdByUserId: 9999,
});
await featureToggleService.archiveToggle('only-active-t2', user, auditUser);
const projects = await projectService.getProjects();
const theProject = projects.find((p) => p.id === project.id);
expect(theProject?.featureCount).toBe(1);
});
test('should list projects with all features archived', async () => {
const project = {
id: 'only-archived',
name: 'Listed project',
description: 'Blah',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, {
name: 'archived-flag',
createdByUserId: 9999,
});
await featureToggleService.archiveToggle('archived-flag', user, auditUser);
const projects = await projectService.getProjects();
const theProject = projects.find((p) => p.id === project.id);
expect(theProject?.featureCount).toBe(0);
});
const updateEventCreatedAt = async (date: Date, featureName: string) => {
return db.rawDatabase
.table('events')
.update({ created_at: date })
.where({ feature_name: featureName });
};
const updateFeature = async (featureName: string, update: any) => {
return db.rawDatabase
.table('features')
.update(update)
.where({ name: featureName });
};
test('should calculate average time to production', async () => {
const project = {
id: 'average-time-to-prod',
name: 'average-time-to-prod',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const flags = [
{ name: 'average-prod-time' },
{ name: 'average-prod-time-2' },
{ name: 'average-prod-time-3' },
{ name: 'average-prod-time-4' },
{ name: 'average-prod-time-5' },
];
const featureFlags = await Promise.all(
flags.map((flag) => {
return featureToggleService.createFeatureToggle(
project.id,
flag,
auditUser,
);
}),
);
await Promise.all(
featureFlags.map((flag) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project.id,
featureName: flag.name,
environment: 'default',
auditUser,
}),
);
}),
);
await updateEventCreatedAt(subDays(new Date(), 31), 'average-prod-time-5');
await Promise.all(
featureFlags.map((flag) =>
updateFeature(flag.name, { created_at: subDays(new Date(), 15) }),
),
);
await updateFeature('average-prod-time-5', {
created_at: subDays(new Date(), 33),
});
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.avgTimeToProdCurrentWindow).toBe(11.4);
});
test('should calculate average time to production ignoring some items', async () => {
const project = {
id: 'average-time-to-prod-corner-cases',
name: 'average-time-to-prod',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const makeEvent = (featureName: string) => ({
enabled: true,
project: project.id,
featureName,
environment: 'default',
auditUser,
tags: [],
});
await projectService.createProject(project, user, auditUser);
await stores.environmentStore.create({
name: 'customEnv',
type: 'development',
});
await environmentService.addEnvironmentToProject(
'customEnv',
project.id,
SYSTEM_USER_AUDIT,
);
// actual flag we take for calculations
const flag = { name: 'main-flag' };
await featureToggleService.createFeatureToggle(project.id, flag, auditUser);
await updateFeature(flag.name, {
created_at: subDays(new Date(), 20),
});
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(flag.name)),
);
// ignore events added after first enabled
await updateEventCreatedAt(addDays(new Date(), 1), flag.name);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(flag.name)),
);
// ignore flags enabled in non-prod envs
const devFlag = { name: 'dev-flag' };
await featureToggleService.createFeatureToggle(
project.id,
devFlag,
auditUser,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent({
...makeEvent(devFlag.name),
environment: 'customEnv',
}),
);
// ignore flags from other projects
const otherProjectFlag = { name: 'other-project' };
await featureToggleService.createFeatureToggle(
'default',
otherProjectFlag,
auditUser,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(otherProjectFlag.name)),
);
// ignore non-release flags
const nonReleaseFlag = { name: 'permission-flag', type: 'permission' };
await featureToggleService.createFeatureToggle(
project.id,
nonReleaseFlag,
auditUser,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(nonReleaseFlag.name)),
);
// ignore flags with events before flag creation time
const previouslyDeleteFlag = { name: 'previously-deleted' };
await featureToggleService.createFeatureToggle(
project.id,
previouslyDeleteFlag,
auditUser,
);
await eventService.storeEvent(
new FeatureEnvironmentEvent(makeEvent(previouslyDeleteFlag.name)),
);
await updateEventCreatedAt(
subDays(new Date(), 30),
previouslyDeleteFlag.name,
);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.avgTimeToProdCurrentWindow).toBe(20);
});
test('should get correct amount of features created in current and past window', async () => {
const project = {
id: 'features-created',
name: 'features-created',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const flags = [
{ name: 'features-created' },
{ name: 'features-created-2' },
{ name: 'features-created-3' },
{ name: 'features-created-4' },
];
await Promise.all(
flags.map((flag) => {
return featureToggleService.createFeatureToggle(
project.id,
flag,
auditUser,
);
}),
);
await Promise.all([
updateFeature(flags[2].name, { created_at: subDays(new Date(), 31) }),
updateFeature(flags[3].name, { created_at: subDays(new Date(), 31) }),
]);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.createdCurrentWindow).toBe(2);
expect(result.updates.createdPastWindow).toBe(2);
});
test('should get correct amount of features archived in current and past window', async () => {
const project = {
id: 'features-archived',
name: 'features-archived',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const flags = [
{ name: 'features-archived' },
{ name: 'features-archived-2' },
{ name: 'features-archived-3' },
{ name: 'features-archived-4' },
];
await Promise.all(
flags.map((flag) => {
return featureToggleService.createFeatureToggle(
project.id,
flag,
auditUser,
);
}),
);
await Promise.all([
updateFeature(flags[0].name, {
archived_at: new Date(),
}),
updateFeature(flags[1].name, {
archived_at: new Date(),
}),
updateFeature(flags[2].name, {
archived_at: subDays(new Date(), 31),
}),
updateFeature(flags[3].name, {
archived_at: subDays(new Date(), 31),
}),
]);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.archivedCurrentWindow).toBe(2);
expect(result.updates.archivedPastWindow).toBe(2);
});
test('should get correct amount of project members for current and past window', async () => {
const project = {
id: 'features-members',
name: 'features-members',
mode: 'open' as const,
defaultStickiness: 'default',
};
await projectService.createProject(project, user, auditUser);
const users = [
{ name: 'memberOne', email: 'memberOne@getunleash.io' },
{ name: 'memberTwo', email: 'memberTwo@getunleash.io' },
{ name: 'memberThree', email: 'memberThree@getunleash.io' },
{ name: 'memberFour', email: 'memberFour@getunleash.io' },
{ name: 'memberFive', email: 'memberFive@getunleash.io' },
];
const createdUsers = await Promise.all(
users.map((userObj) => stores.userStore.insert(userObj)),
);
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
await Promise.all(
createdUsers.map((createdUser) =>
projectService.addUser(
project.id,
memberRole.id,
createdUser.id,
auditUser,
),
),
);
const result = await projectService.getStatusUpdates(project.id);
expect(result.updates.projectMembersAddedCurrentWindow).toBe(6); // 5 members + 1 owner
expect(result.updates.projectActivityCurrentWindow).toBe(6);
expect(result.updates.projectActivityPastWindow).toBe(0);
});
test('should return average time to production per flag', async () => {
const project = {
id: 'average-time-to-prod-per-flag',
name: 'average-time-to-prod-per-flag',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project, user, auditUser);
const flags = [
{ name: 'average-prod-time-pt', subdays: 7 },
{ name: 'average-prod-time-pt-2', subdays: 14 },
{ name: 'average-prod-time-pt-3', subdays: 40 },
{ name: 'average-prod-time-pt-4', subdays: 15 },
{ name: 'average-prod-time-pt-5', subdays: 2 },
];
const featureFlags = await Promise.all(
flags.map((flag) => {
return featureToggleService.createFeatureToggle(
project.id,
flag,
auditUser,
);
}),
);
await Promise.all(
featureFlags.map((flag) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project.id,
featureName: flag.name,
environment: 'default',
auditUser,
}),
);
}),
);
await Promise.all(
flags.map((flag) =>
updateFeature(flag.name, {
created_at: subDays(new Date(), flag.subdays),
}),
),
);
const result = await projectService.getDoraMetrics(project.id);
expect(result.features).toHaveLength(5);
expect(result.features[0].timeToProduction).toBeTruthy();
expect(result.projectAverage).toBeTruthy();
});
test('should return average time to production per flag for a specific project', async () => {
const project1 = {
id: 'average-time-to-prod-per-flag-1',
name: 'Project 1',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
const project2 = {
id: 'average-time-to-prod-per-flag-2',
name: 'Project 2',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user, auditUser);
await projectService.createProject(project2, user, auditUser);
const flagsProject1 = [
{ name: 'average-prod-time-pt-10', subdays: 7 },
{ name: 'average-prod-time-pt-11', subdays: 14 },
{ name: 'average-prod-time-pt-12', subdays: 40 },
];
const flagsProject2 = [
{ name: 'average-prod-time-pt-13', subdays: 15 },
{ name: 'average-prod-time-pt-14', subdays: 2 },
];
const featureFlagsProject1 = await Promise.all(
flagsProject1.map((flag) => {
return featureToggleService.createFeatureToggle(
project1.id,
flag,
auditUser,
);
}),
);
const featureFlagsProject2 = await Promise.all(
flagsProject2.map((flag) => {
return featureToggleService.createFeatureToggle(
project2.id,
flag,
auditUser,
);
}),
);
await Promise.all(
featureFlagsProject1.map((flag) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project1.id,
featureName: flag.name,
environment: 'default',
auditUser,
}),
);
}),
);
await Promise.all(
featureFlagsProject2.map((flag) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project2.id,
featureName: flag.name,
environment: 'default',
auditUser,
}),
);
}),
);
await Promise.all(
flagsProject1.map((flag) =>
updateFeature(flag.name, {
created_at: subDays(new Date(), flag.subdays),
}),
),
);
await Promise.all(
flagsProject2.map((flag) =>
updateFeature(flag.name, {
created_at: subDays(new Date(), flag.subdays),
}),
),
);
const resultProject1 = await projectService.getDoraMetrics(project1.id);
const resultProject2 = await projectService.getDoraMetrics(project2.id);
expect(resultProject1.features).toHaveLength(3);
expect(resultProject2.features).toHaveLength(2);
});
test('should return average time to production per flag and include archived flags', async () => {
const project1 = {
id: 'average-time-to-prod-per-flag-12',
name: 'Project 1',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
await projectService.createProject(project1, user, auditUser);
const flagsProject1 = [
{ name: 'average-prod-time-pta-10', subdays: 7 },
{ name: 'average-prod-time-pta-11', subdays: 14 },
{ name: 'average-prod-time-pta-12', subdays: 40 },
];
const featureFlagsProject1 = await Promise.all(
flagsProject1.map((flag) => {
return featureToggleService.createFeatureToggle(
project1.id,
flag,
auditUser,
);
}),
);
await Promise.all(
featureFlagsProject1.map((flag) => {
return eventService.storeEvent(
new FeatureEnvironmentEvent({
enabled: true,
project: project1.id,
featureName: flag.name,
environment: 'default',
auditUser,
}),
);
}),
);
await Promise.all(
flagsProject1.map((flag) =>
updateFeature(flag.name, {
created_at: subDays(new Date(), flag.subdays),
}),
),
);
await featureToggleService.archiveToggle(
'average-prod-time-pta-12',
user,
auditUser,
);
const resultProject1 = await projectService.getDoraMetrics(project1.id);
expect(resultProject1.features).toHaveLength(3);
});
describe('feature flag naming patterns', () => {
test(`should clear existing example and description if the payload doesn't contain them`, async () => {
const featureNaming = {
pattern: '.+',
example: 'example',
description: 'description',
};
const project = {
id: 'feature-flag-naming-patterns-cleanup',
name: 'Project',
mode: 'open' as const,
defaultStickiness: 'clientId',
description: 'description',
featureNaming,
};
await projectService.createProject(project, user, auditUser);
await projectService.updateProjectEnterpriseSettings(
project,
extractAuditInfoFromUser(user),
);
expect(
(await projectService.getProject(project.id)).featureNaming,
).toMatchObject(featureNaming);
const newPattern = 'new-pattern.+';
await projectService.updateProjectEnterpriseSettings(
{
...project,
featureNaming: { pattern: newPattern },
},
extractAuditInfoFromUser(user),
);
const { events } = await eventService.getEvents();
expect(events[0]).toMatchObject({
preData: events[0].preData,
data: {
...events[0].preData,
featureNaming: events[0].data.featureNaming,
},
});
const updatedProject = await projectService.getProject(project.id);
expect(updatedProject.featureNaming!.pattern).toBe(newPattern);
expect(updatedProject.featureNaming!.example).toBeFalsy();
expect(updatedProject.featureNaming!.description).toBeFalsy();
});
});
test('deleting a project with archived flags should result in any remaining archived flags being deleted', async () => {
const project = {
id: 'project-with-archived-flags',
name: 'project-with-archived-flags',
};
const flagName = 'archived-and-deleted';
await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, {
name: flagName,
createdByUserId: 9999,
});
await stores.featureToggleStore.archive(flagName);
await projectService.deleteProject(project.id, user, auditUser);
// bring the project back again, previously this would allow those archived flags to be resurrected
// we now expect them to be deleted correctly
await projectService.createProject(project, user, auditUser);
const flags = await stores.featureToggleStore.getAll({
project: project.id,
archived: true,
});
expect(flags.find((t) => t.name === flagName)).toBeUndefined();
});
test('should also delete api tokens that were only bound to deleted project', async () => {
const project = 'some';
const tokenName = 'test';
await projectService.createProject(
{
id: project,
name: 'Test Project 1',
},
user,
auditUser,
);
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
tokenName,
environment: DEFAULT_ENV,
project: project,
});
await projectService.deleteProject(project, user, auditUser);
const deletedToken = await apiTokenService.getToken(token.secret);
expect(deletedToken).toBeUndefined();
});
test('should not delete project-bound api tokens still bound to project', async () => {
const project1 = 'token-deleted-project';
const project2 = 'token-not-deleted-project';
const tokenName = 'test';
await projectService.createProject(
{
id: project1,
name: 'Test Project 1',
},
user,
auditUser,
);
await projectService.createProject(
{
id: project2,
name: 'Test Project 2',
},
user,
auditUser,
);
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
tokenName,
environment: DEFAULT_ENV,
projects: [project1, project2],
});
await projectService.deleteProject(project1, user, auditUser);
const fetchedToken = await apiTokenService.getToken(token.secret);
expect(fetchedToken).not.toBeUndefined();
expect(fetchedToken.project).toBe(project2);
});
test('should delete project-bound api tokens when all projects they belong to are deleted', async () => {
const project1 = 'token-deleted-project-1';
const project2 = 'token-deleted-project-2';
const tokenName = 'test';
await projectService.createProject(
{
id: project1,
name: 'Test Project 1',
},
user,
auditUser,
);
await projectService.createProject(
{
id: project2,
name: 'Test Project 2',
},
user,
auditUser,
);
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
tokenName,
environment: DEFAULT_ENV,
projects: [project1, project2],
});
await projectService.deleteProject(project1, user, auditUser);
await projectService.deleteProject(project2, user, auditUser);
const fetchedToken = await apiTokenService.getToken(token.secret);
expect(fetchedToken).toBeUndefined();
});
test('deleting a project with no archived flags should not result in an error', async () => {
const project = {
id: 'project-with-nothing',
name: 'project-with-nothing',
};
await projectService.createProject(project, user, auditUser);
await projectService.deleteProject(project.id, user, auditUser);
});
test('should get project settings with mode', async () => {
const projectOne = {
id: 'mode-private',
name: 'New project',
description: 'Desc',
mode: 'open' as const,
defaultStickiness: 'default',
};
const projectTwo = {
id: 'mode-open',
name: 'New project',
description: 'Desc',
mode: 'open' as const,
defaultStickiness: 'default',
};
const updatedProject = {
id: 'mode-private',
name: 'New name',
description: 'Desc',
mode: 'private' as const,
defaultStickiness: 'clientId',
};
const { mode, id, ...rest } = updatedProject;
await projectService.createProject(projectOne, user, auditUser);
await projectService.createProject(projectTwo, user, auditUser);
await projectService.updateProject({ id, ...rest }, auditUser);
await projectService.updateProjectEnterpriseSettings(
{ mode, id },
extractAuditInfoFromUser(user),
);
const projects = await projectService.getProjects();
const foundProjectOne = projects.find(
(project) => projectOne.id === project.id,
);
const foundProjectTwo = projects.find(
(project) => projectTwo.id === project.id,
);
expect(foundProjectOne!.mode).toBe('private');
expect(foundProjectTwo!.mode).toBe('open');
});
describe('create project with environments', () => {
const disabledEnv = { name: 'disabled', type: 'production' };
const extraEnvs = [
{ name: 'development', type: 'development' },
{ name: 'production', type: 'production' },
{ name: 'staging', type: 'staging' },
{ name: 'QA', type: 'QA' },
disabledEnv,
];
const allEnabledEnvs = [
'QA',
'default',
'development',
'production',
'staging',
];
beforeEach(async () => {
await Promise.all(
extraEnvs.map((env) => stores.environmentStore.create(env)),
);
await stores.environmentStore.disable([
{ ...disabledEnv, enabled: true, protected: false, sortOrder: 5 },
]);
});
afterAll(async () => {
await Promise.all(
extraEnvs.map((env) => stores.environmentStore.delete(env.name)),
);
});
const createProjectWithEnvs = async (environments) => {
const project = await projectService.createProject(
{
id: randomId(),
name: 'New name',
mode: 'open' as const,
defaultStickiness: 'default',
...(environments ? { environments } : {}),
},
user,
auditUser,
);
const projectEnvs = (
await projectService.getProjectOverview(project.id)
).environments.map(({ environment }) => environment);
projectEnvs.sort();
return projectEnvs;
};
test('no environments specified means all enabled envs are enabled', async () => {
const created = await createProjectWithEnvs(undefined);
expect(created).toMatchObject(allEnabledEnvs);
});
test('an empty list throws an error', async () => {
// You shouldn't be allowed to pass an empty list via the API.
// This test checks what happens in the event that an empty
// list manages to sneak in.
await expect(createProjectWithEnvs([])).rejects.toThrow(BadDataError);
});
test('it only enables the envs it is asked to enable', async () => {
const selectedEnvs = ['development', 'production'];
const created = await createProjectWithEnvs(selectedEnvs);
expect(created).toMatchObject(selectedEnvs);
});
test('it enables deprecated environments when asked explicitly', async () => {
const selectedEnvs = ['disabled'];
const created = await createProjectWithEnvs(selectedEnvs);
expect(created).toMatchObject(selectedEnvs);
});
test("envs that don't exist cause errors", async () => {
await expect(createProjectWithEnvs(['fake-project'])).rejects.toThrow(
BadDataError,
);
await expect(createProjectWithEnvs(['fake-project'])).rejects.toThrow(
/'fake-project'/,
);
});
});
describe('automatic ID generation for create project', () => {
test('if no ID is included in the creation argument, it gets generated based on the project name', async () => {
const project = await projectService.createProject(
{
name: 'New name',
},
user,
auditUser,
);
expect(project.id).toBe('new-name');
});
test('projects with the same name get ids with incrementing counters', async () => {
const createProject = async () =>
projectService.createProject(
{ name: 'some name' },
user,
auditUser,
);
const project1 = await createProject();
const project2 = await createProject();
const project3 = await createProject();
expect(project1.id).toBe('some-name');
expect(project2.id).toBe('some-name-1');
expect(project3.id).toBe('some-name-2');
});
test.each(['', undefined, ' '])(
'An id with the value `%s` is treated as missing (and the id is based on the name)',
async (id) => {
const name = randomId();
const project = await projectService.createProject(
{ name, id },
user,
auditUser,
);
expect(project.id).toBe(name);
},
);
test('Projects with long names get ids capped at 90 characters and then suffixed', async () => {
const name = Array.from({ length: 200 })
.map(() => 'a')
.join();
const project = await projectService.createProject(
{
name,
},
user,
auditUser,
);
expect(project.name).toBe(name);
expect(project.id.length).toBeLessThanOrEqual(90);
const secondName =
name +
Array.from({ length: 100 })
.map(() => 'b')
.join();
const secondProject = await projectService.createProject(
{
name: secondName,
},
user,
auditUser,
);
expect(secondProject.name).toBe(secondName);
expect(secondProject.id).toBe(`${project.id}-1`);
});
describe('backwards compatibility', () => {
const featureFlag = 'createProjectWithEnvironmentConfig';
test.each([true, false])(
'if the ID is present in the input, it is used as the ID regardless of the feature flag states. Flag state: %s',
async (flagState) => {
const id = randomId();
// @ts-expect-error - we're just checking that the same
// thing happens regardless of flag state
projectService.flagResolver.isEnabled = (
flagToCheck: string,
) => {
if (flagToCheck === featureFlag) {
return flagState;
} else {
return false;
}
};
const project = await projectService.createProject(
{
name: id,
id,
},
user,
auditUser,
);
expect(project.id).toBe(id);
},
);
});
});