1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

feat: ensure at least one owner on remove user/group access (#5085)

## About the changes
This makes sure that projects have at least one owner, either a group or
a user. This is to prevent accidentally losing access to a project.

We check this when removing a user/group or when changing the role of a
user/group

**Note**: We can still leave a group empty as the only owner of the
project, but that's okay because we can still add more users to the
group
This commit is contained in:
Gastón Fournier 2023-10-19 14:14:59 +02:00 committed by GitHub
parent 6760fc0723
commit 3d9f31f839
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 269 additions and 94 deletions

View File

@ -14,7 +14,6 @@ import FakeGroupStore from '../../../test/fixtures/fake-group-store';
import FakeEventStore from '../../../test/fixtures/fake-event-store'; import FakeEventStore from '../../../test/fixtures/fake-event-store';
import ProjectStore from '../../db/project-store'; import ProjectStore from '../../db/project-store';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import FeatureTypeStore from '../../db/feature-type-store';
import { FeatureEnvironmentStore } from '../../db/feature-environment-store'; import { FeatureEnvironmentStore } from '../../db/feature-environment-store';
import ProjectStatsStore from '../../db/project-stats-store'; import ProjectStatsStore from '../../db/project-stats-store';
import { import {
@ -29,7 +28,6 @@ import { FavoriteFeaturesStore } from '../../db/favorite-features-store';
import { FavoriteProjectsStore } from '../../db/favorite-projects-store'; import { FavoriteProjectsStore } from '../../db/favorite-projects-store';
import FakeProjectStore from '../../../test/fixtures/fake-project-store'; import FakeProjectStore from '../../../test/fixtures/fake-project-store';
import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store'; import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
import FakeEnvironmentStore from '../../../test/fixtures/fake-environment-store'; import FakeEnvironmentStore from '../../../test/fixtures/fake-environment-store';
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store'; import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store'; import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store';
@ -41,8 +39,6 @@ import {
createPrivateProjectChecker, createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker'; } from '../private-project/createPrivateProjectChecker';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store'; import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import { LastSeenAtReadModel } from '../../services/client-metrics/last-seen/last-seen-read-model';
import { FakeLastSeenReadModel } from '../../services/client-metrics/last-seen/fake-last-seen-read-model';
export const createProjectService = ( export const createProjectService = (
db: Db, db: Db,
@ -63,7 +59,6 @@ export const createProjectService = (
getLogger, getLogger,
flagResolver, flagResolver,
); );
const featureTypeStore = new FeatureTypeStore(db, getLogger);
const accountStore = new AccountStore(db, getLogger); const accountStore = new AccountStore(db, getLogger);
const environmentStore = new EnvironmentStore(db, eventBus, getLogger); const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
const featureEnvironmentStore = new FeatureEnvironmentStore( const featureEnvironmentStore = new FeatureEnvironmentStore(
@ -106,14 +101,12 @@ export const createProjectService = (
); );
const privateProjectChecker = createPrivateProjectChecker(db, config); const privateProjectChecker = createPrivateProjectChecker(db, config);
const lastSeenReadModel = new LastSeenAtReadModel(db);
return new ProjectService( return new ProjectService(
{ {
projectStore, projectStore,
eventStore, eventStore,
featureToggleStore, featureToggleStore,
featureTypeStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
accountStore, accountStore,
@ -126,7 +119,6 @@ export const createProjectService = (
favoriteService, favoriteService,
eventService, eventService,
privateProjectChecker, privateProjectChecker,
lastSeenReadModel,
); );
}; };
@ -138,7 +130,6 @@ export const createFakeProjectService = (
const projectStore = new FakeProjectStore(); const projectStore = new FakeProjectStore();
const groupStore = new FakeGroupStore(); const groupStore = new FakeGroupStore();
const featureToggleStore = new FakeFeatureToggleStore(); const featureToggleStore = new FakeFeatureToggleStore();
const featureTypeStore = new FakeFeatureTypeStore();
const accountStore = new FakeAccountStore(); const accountStore = new FakeAccountStore();
const environmentStore = new FakeEnvironmentStore(); const environmentStore = new FakeEnvironmentStore();
const featureEnvironmentStore = new FakeFeatureEnvironmentStore(); const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
@ -169,14 +160,12 @@ export const createFakeProjectService = (
); );
const privateProjectChecker = createFakePrivateProjectChecker(); const privateProjectChecker = createFakePrivateProjectChecker();
const fakeLastSeenReadModel = new FakeLastSeenReadModel();
return new ProjectService( return new ProjectService(
{ {
projectStore, projectStore,
eventStore, eventStore,
featureToggleStore, featureToggleStore,
featureTypeStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
accountStore, accountStore,
@ -189,6 +178,5 @@ export const createFakeProjectService = (
favoriteService, favoriteService,
eventService, eventService,
privateProjectChecker, privateProjectChecker,
fakeLastSeenReadModel,
); );
}; };

View File

@ -568,7 +568,7 @@ export class AccessService {
} }
async removeDefaultProjectRoles( async removeDefaultProjectRoles(
owner: User, owner: IUser,
projectId: string, projectId: string,
): Promise<void> { ): Promise<void> {
this.logger.info(`Removing project roles for ${projectId}`); this.logger.info(`Removing project roles for ${projectId}`);

View File

@ -1,6 +1,6 @@
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { ValidationError } from 'joi'; import { ValidationError } from 'joi';
import User, { IUser } from '../types/user'; import { IUser } from '../types/user';
import { AccessService, AccessWithRoles } from './access-service'; import { AccessService, AccessWithRoles } from './access-service';
import NameExistsError from '../error/name-exists-error'; import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error'; import InvalidOperationError from '../error/invalid-operation-error';
@ -15,7 +15,6 @@ import {
IEventStore, IEventStore,
IFeatureEnvironmentStore, IFeatureEnvironmentStore,
IFeatureToggleStore, IFeatureToggleStore,
IFeatureTypeStore,
IProject, IProject,
IProjectOverview, IProjectOverview,
IProjectWithCount, IProjectWithCount,
@ -65,8 +64,6 @@ import { ProjectDoraMetricsSchema } from 'lib/openapi';
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation'; import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType'; import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import EventService from './event-service'; import EventService from './event-service';
import { ILastSeenReadModel } from './client-metrics/last-seen/types/last-seen-read-model-type';
import { LastSeenMapper } from './client-metrics/last-seen/last-seen-mapper';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
@ -89,6 +86,10 @@ interface ICalculateStatus {
updates: IProjectStats; updates: IProjectStats;
} }
function includes(list: number[], { id }: { id: number }): boolean {
return list.some((l) => l === id);
}
export default class ProjectService { export default class ProjectService {
private projectStore: IProjectStore; private projectStore: IProjectStore;
@ -98,8 +99,6 @@ export default class ProjectService {
private featureToggleStore: IFeatureToggleStore; private featureToggleStore: IFeatureToggleStore;
private featureTypeStore: IFeatureTypeStore;
private featureEnvironmentStore: IFeatureEnvironmentStore; private featureEnvironmentStore: IFeatureEnvironmentStore;
private environmentStore: IEnvironmentStore; private environmentStore: IEnvironmentStore;
@ -120,8 +119,6 @@ export default class ProjectService {
private projectStatsStore: IProjectStatsStore; private projectStatsStore: IProjectStatsStore;
private lastSeenReadModel: ILastSeenReadModel;
private flagResolver: IFlagResolver; private flagResolver: IFlagResolver;
private isEnterprise: boolean; private isEnterprise: boolean;
@ -131,7 +128,6 @@ export default class ProjectService {
projectStore, projectStore,
eventStore, eventStore,
featureToggleStore, featureToggleStore,
featureTypeStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
accountStore, accountStore,
@ -141,7 +137,6 @@ export default class ProjectService {
| 'projectStore' | 'projectStore'
| 'eventStore' | 'eventStore'
| 'featureToggleStore' | 'featureToggleStore'
| 'featureTypeStore'
| 'environmentStore' | 'environmentStore'
| 'featureEnvironmentStore' | 'featureEnvironmentStore'
| 'accountStore' | 'accountStore'
@ -154,7 +149,6 @@ export default class ProjectService {
favoriteService: FavoritesService, favoriteService: FavoritesService,
eventService: EventService, eventService: EventService,
privateProjectChecker: IPrivateProjectChecker, privateProjectChecker: IPrivateProjectChecker,
lastSeenReadModel: ILastSeenReadModel,
) { ) {
this.projectStore = projectStore; this.projectStore = projectStore;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
@ -162,7 +156,6 @@ export default class ProjectService {
this.accessService = accessService; this.accessService = accessService;
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.favoritesService = favoriteService; this.favoritesService = favoriteService;
this.privateProjectChecker = privateProjectChecker; this.privateProjectChecker = privateProjectChecker;
@ -170,7 +163,6 @@ export default class ProjectService {
this.groupService = groupService; this.groupService = groupService;
this.eventService = eventService; this.eventService = eventService;
this.projectStatsStore = projectStatsStore; this.projectStatsStore = projectStatsStore;
this.lastSeenReadModel = lastSeenReadModel;
this.logger = config.getLogger('services/project-service.js'); this.logger = config.getLogger('services/project-service.js');
this.flagResolver = config.flagResolver; this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise; this.isEnterprise = config.isEnterprise;
@ -267,7 +259,7 @@ export default class ProjectService {
return data; return data;
} }
async updateProject(updatedProject: IProject, user: User): Promise<void> { async updateProject(updatedProject: IProject, user: IUser): Promise<void> {
const preData = await this.projectStore.get(updatedProject.id); const preData = await this.projectStore.get(updatedProject.id);
await this.projectStore.update(updatedProject); await this.projectStore.update(updatedProject);
@ -283,7 +275,7 @@ export default class ProjectService {
async updateProjectEnterpriseSettings( async updateProjectEnterpriseSettings(
updatedProject: IProjectEnterpriseSettingsUpdate, updatedProject: IProjectEnterpriseSettingsUpdate,
user: User, user: IUser,
): Promise<void> { ): Promise<void> {
const preData = await this.projectStore.get(updatedProject.id); const preData = await this.projectStore.get(updatedProject.id);
@ -330,7 +322,7 @@ export default class ProjectService {
async changeProject( async changeProject(
newProjectId: string, newProjectId: string,
featureName: string, featureName: string,
user: User, user: IUser,
currentProjectId: string, currentProjectId: string,
): Promise<any> { ): Promise<any> {
const feature = await this.featureToggleStore.get(featureName); const feature = await this.featureToggleStore.get(featureName);
@ -372,7 +364,7 @@ export default class ProjectService {
return updatedFeature; return updatedFeature;
} }
async deleteProject(id: string, user: User): Promise<void> { async deleteProject(id: string, user: IUser): Promise<void> {
if (id === DEFAULT_PROJECT) { if (id === DEFAULT_PROJECT) {
throw new InvalidOperationError( throw new InvalidOperationError(
'You can not delete the default project!', 'You can not delete the default project!',
@ -508,6 +500,11 @@ export default class ProjectService {
userId, userId,
); );
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.removeUserAccess(projectId, userId); await this.accessService.removeUserAccess(projectId, userId);
await this.eventService.storeEvent( await this.eventService.storeEvent(
@ -532,6 +529,11 @@ export default class ProjectService {
groupId, groupId,
); );
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.removeGroupAccess(projectId, groupId); await this.accessService.removeGroupAccess(projectId, groupId);
await this.eventService.storeEvent( await this.eventService.storeEvent(
@ -598,6 +600,8 @@ export default class ProjectService {
undefined, undefined,
); );
await this.validateAtLeastOneOwner(projectId, role);
await this.accessService.removeGroupFromRole( await this.accessService.removeGroupFromRole(
group.id, group.id,
role.id, role.id,
@ -675,28 +679,39 @@ export default class ProjectService {
async setRolesForUser( async setRolesForUser(
projectId: string, projectId: string,
userId: number, userId: number,
roles: number[], newRoles: number[],
createdByUserName: string, createdByUserName: string,
): Promise<void> { ): Promise<void> {
const existingRoles = await this.accessService.getProjectRolesForUser( const currentRoles = await this.accessService.getProjectRolesForUser(
projectId, projectId,
userId, userId,
); );
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
const hasOwnerRole = includes(currentRoles, ownerRole);
const isRemovingOwnerRole = !includes(newRoles, ownerRole);
if (hasOwnerRole && isRemovingOwnerRole) {
await this.validateAtLeastOneOwner(projectId, ownerRole);
}
await this.accessService.setProjectRolesForUser( await this.accessService.setProjectRolesForUser(
projectId, projectId,
userId, userId,
roles, newRoles,
); );
await this.eventService.storeEvent( await this.eventService.storeEvent(
new ProjectAccessUserRolesUpdated({ new ProjectAccessUserRolesUpdated({
project: projectId, project: projectId,
createdBy: createdByUserName, createdBy: createdByUserName,
data: { data: {
roles, roles: newRoles,
userId, userId,
}, },
preData: { preData: {
roles: existingRoles, roles: currentRoles,
userId, userId,
}, },
}), }),
@ -706,17 +721,28 @@ export default class ProjectService {
async setRolesForGroup( async setRolesForGroup(
projectId: string, projectId: string,
groupId: number, groupId: number,
roles: number[], newRoles: number[],
createdBy: string, createdBy: string,
): Promise<void> { ): Promise<void> {
const existingRoles = await this.accessService.getProjectRolesForGroup( const currentRoles = await this.accessService.getProjectRolesForGroup(
projectId, projectId,
groupId, groupId,
); );
const ownerRole = await this.accessService.getRoleByName(
RoleName.OWNER,
);
const hasOwnerRole = includes(currentRoles, ownerRole);
const isRemovingOwnerRole = !includes(newRoles, ownerRole);
if (hasOwnerRole && isRemovingOwnerRole) {
await this.validateAtLeastOneOwner(projectId, ownerRole);
}
await this.validateAtLeastOneOwner(projectId, ownerRole);
await this.accessService.setProjectRolesForGroup( await this.accessService.setProjectRolesForGroup(
projectId, projectId,
groupId, groupId,
roles, newRoles,
createdBy, createdBy,
); );
await this.eventService.storeEvent( await this.eventService.storeEvent(
@ -724,11 +750,11 @@ export default class ProjectService {
project: projectId, project: projectId,
createdBy, createdBy,
data: { data: {
roles, roles: newRoles,
groupId, groupId,
}, },
preData: { preData: {
roles: existingRoles, roles: currentRoles,
groupId, groupId,
}, },
}), }),
@ -1091,7 +1117,7 @@ export default class ProjectService {
return { return {
stats: projectStats, stats: projectStats,
name: project.name, name: project.name,
description: project.description, description: project.description!,
mode: project.mode, mode: project.mode,
featureLimit: project.featureLimit, featureLimit: project.featureLimit,
featureNaming: project.featureNaming, featureNaming: project.featureNaming,

View File

@ -17,8 +17,10 @@ import {
createFeatureToggleService, createFeatureToggleService,
createProjectService, createProjectService,
} from '../../../lib/features'; } from '../../../lib/features';
import { IGroup, IUnleashStores } from 'lib/types';
import { User } from 'lib/server-impl';
let stores; let stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;
let projectService: ProjectService; let projectService: ProjectService;
@ -26,7 +28,8 @@ let accessService: AccessService;
let eventService: EventService; let eventService: EventService;
let environmentService: EnvironmentService; let environmentService: EnvironmentService;
let featureToggleService: FeatureToggleService; let featureToggleService: FeatureToggleService;
let user; let user: User; // many methods in this test use User instead of IUser
let group: IGroup;
const isProjectUser = async ( const isProjectUser = async (
userId: number, userId: number,
@ -41,13 +44,17 @@ const isProjectUser = async (
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('project_service_serial', getLogger); db = await dbInit('project_service_serial', getLogger);
stores = db.stores; stores = db.stores;
// @ts-ignore return type IUser type missing generateImageUrl
user = await stores.userStore.insert({ user = await stores.userStore.insert({
name: 'Some Name', name: 'Some Name',
email: 'test@getunleash.io', email: 'test@getunleash.io',
}); });
group = await stores.groupStore.create({
name: 'aTestGroup',
description: '',
});
const config = createTestConfig({ const config = createTestConfig({
getLogger, getLogger,
// @ts-ignore
experimental: { experimental: {
flags: { privateProjects: true }, flags: { privateProjects: true },
}, },
@ -164,6 +171,7 @@ test('should not be able to delete project with toggles', async () => {
await projectService.createProject(project, user); await projectService.createProject(project, user);
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
name: 'test-project-delete', name: 'test-project-delete',
// @ts-ignore project does not exist in type FeatureToggleDTO
project: project.id, project: project.id,
enabled: false, enabled: false,
defaultStickiness: 'default', defaultStickiness: 'default',
@ -491,31 +499,6 @@ test('should remove user from the project', async () => {
expect(memberUsers).toHaveLength(0); 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',
mode: 'open' as const,
defaultStickiness: 'clientId',
};
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 () => { test('should not change project if feature toggle project does not match current project id', async () => {
const project = { const project = {
id: 'test-change-project', id: 'test-change-project',
@ -528,6 +511,7 @@ test('should not change project if feature toggle project does not match current
const toggle = { name: 'test-toggle' }; const toggle = { name: 'test-toggle' };
await projectService.createProject(project, user); await projectService.createProject(project, user);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(project.id, toggle, user); await featureToggleService.createFeatureToggle(project.id, toggle, user);
try { try {
@ -555,6 +539,7 @@ test('should return 404 if no project is found with the project id', async () =>
const toggle = { name: 'test-toggle-2' }; const toggle = { name: 'test-toggle-2' };
await projectService.createProject(project, user); await projectService.createProject(project, user);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(project.id, toggle, user); await featureToggleService.createFeatureToggle(project.id, toggle, user);
try { try {
@ -594,6 +579,7 @@ test('should fail if user is not authorized', async () => {
await projectService.createProject(project, user); await projectService.createProject(project, user);
await projectService.createProject(projectDestination, projectAdmin1); await projectService.createProject(projectDestination, projectAdmin1);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(project.id, toggle, user); await featureToggleService.createFeatureToggle(project.id, toggle, user);
try { try {
@ -626,6 +612,7 @@ test('should change project when checks pass', async () => {
await projectService.createProject(projectA, user); await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user); await projectService.createProject(projectB, user);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(projectA.id, toggle, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
await projectService.changeProject( await projectService.changeProject(
projectB.id, projectB.id,
@ -656,6 +643,7 @@ test('changing project should emit event even if user does not have a username s
const toggle = { name: randomId() }; const toggle = { name: randomId() };
await projectService.createProject(projectA, user); await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user); await projectService.createProject(projectB, user);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(projectA.id, toggle, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
const eventsBeforeChange = await stores.eventStore.getEvents(); const eventsBeforeChange = await stores.eventStore.getEvents();
await projectService.changeProject( await projectService.changeProject(
@ -686,6 +674,7 @@ test('should require equal project environments to move features', async () => {
await projectService.createProject(projectA, user); await projectService.createProject(projectA, user);
await projectService.createProject(projectB, user); await projectService.createProject(projectB, user);
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(projectA.id, toggle, user); await featureToggleService.createFeatureToggle(projectA.id, toggle, user);
await stores.environmentStore.create(environment); await stores.environmentStore.create(environment);
await environmentService.addEnvironmentToProject( await environmentService.addEnvironmentToProject(
@ -1013,6 +1002,38 @@ test('should able to assign role without existing members', async () => {
expect(testUsers).toHaveLength(1); 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);
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'),
);
await expect(async () => {
await projectService.removeUserAccess(project.id, user.id, 'test');
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
test('should not update role for user on project when she is the owner', async () => { test('should not update role for user on project when she is the owner', async () => {
const project = { const project = {
id: 'update-users-not-allowed', id: 'update-users-not-allowed',
@ -1028,7 +1049,9 @@ test('should not update role for user on project when she is the owner', async (
email: 'update991@getunleash.io', email: 'update991@getunleash.io',
}); });
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER); const memberRole = await stores.roleStore.getRoleByName(
RoleName.MEMBER,
);
await projectService.addUser( await projectService.addUser(
project.id, project.id,
@ -1047,6 +1070,112 @@ test('should not update role for user on project when she is the owner', async (
}).rejects.toThrowError( }).rejects.toThrowError(
new Error('A project must have at least one owner'), new Error('A project must have at least one owner'),
); );
await expect(async () => {
await projectService.setRolesForUser(
project.id,
user.id,
[memberRole.id],
'test',
);
}).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);
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,
'test',
);
// 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,
'test',
);
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,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.removeGroupAccess(
project.id,
group.id,
'test',
);
}).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,
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
await expect(async () => {
await projectService.setRolesForGroup(
project.id,
group.id,
[memberRole.id],
'test',
);
}).rejects.toThrowError(
new Error('A project must have at least one owner'),
);
});
}); });
test('Should allow bulk update of group permissions', async () => { test('Should allow bulk update of group permissions', async () => {
@ -1056,6 +1185,7 @@ test('Should allow bulk update of group permissions', async () => {
mode: 'open' as const, mode: 'open' as const,
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const groupStore = stores.groupStore; const groupStore = stores.groupStore;
@ -1124,6 +1254,7 @@ test('Should allow bulk update of only groups', async () => {
}; };
const groupStore = stores.groupStore; const groupStore = stores.groupStore;
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const group1 = await groupStore.create({ const group1 = await groupStore.create({
@ -1158,6 +1289,7 @@ test('Should allow permutations of roles, groups and users when adding a new acc
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const group1 = await stores.groupStore.create({ const group1 = await stores.groupStore.create({
@ -1232,11 +1364,13 @@ test('should only count active feature toggles for project', async () => {
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
name: 'only-active-t1', name: 'only-active-t1',
// @ts-ignore project property does not exist in FeatureToggleDTO
project: project.id, project: project.id,
enabled: false, enabled: false,
}); });
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
name: 'only-active-t2', name: 'only-active-t2',
// @ts-ignore project property does not exist in FeatureToggleDTO
project: project.id, project: project.id,
enabled: false, enabled: false,
}); });
@ -1261,6 +1395,7 @@ test('should list projects with all features archived', async () => {
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
name: 'archived-toggle', name: 'archived-toggle',
// @ts-ignore project property does not exist in FeatureToggleDTO
project: project.id, project: project.id,
enabled: false, enabled: false,
}); });
@ -1294,6 +1429,7 @@ test('should calculate average time to production', async () => {
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const toggles = [ const toggles = [
@ -1309,6 +1445,7 @@ test('should calculate average time to production', async () => {
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project.id, project.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
}), }),
@ -1360,6 +1497,7 @@ test('should calculate average time to production ignoring some items', async ()
tags: [], tags: [],
}); });
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'customEnv', name: 'customEnv',
@ -1369,6 +1507,7 @@ test('should calculate average time to production ignoring some items', async ()
// actual toggle we take for calculations // actual toggle we take for calculations
const toggle = { name: 'main-toggle' }; const toggle = { name: 'main-toggle' };
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(project.id, toggle, user); await featureToggleService.createFeatureToggle(project.id, toggle, user);
await updateFeature(toggle.name, { await updateFeature(toggle.name, {
created_at: subDays(new Date(), 20), created_at: subDays(new Date(), 20),
@ -1384,6 +1523,7 @@ test('should calculate average time to production ignoring some items', async ()
// ignore toggles enabled in non-prod envs // ignore toggles enabled in non-prod envs
const devToggle = { name: 'dev-toggle' }; const devToggle = { name: 'dev-toggle' };
// @ts-ignore user is wrong parameter type, should be string
await featureToggleService.createFeatureToggle(project.id, devToggle, user); await featureToggleService.createFeatureToggle(project.id, devToggle, user);
await eventService.storeEvent( await eventService.storeEvent(
new FeatureEnvironmentEvent({ new FeatureEnvironmentEvent({
@ -1397,6 +1537,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle( await featureToggleService.createFeatureToggle(
'default', 'default',
otherProjectToggle, otherProjectToggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
await eventService.storeEvent( await eventService.storeEvent(
@ -1408,6 +1549,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle( await featureToggleService.createFeatureToggle(
project.id, project.id,
nonReleaseToggle, nonReleaseToggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
await eventService.storeEvent( await eventService.storeEvent(
@ -1419,6 +1561,7 @@ test('should calculate average time to production ignoring some items', async ()
await featureToggleService.createFeatureToggle( await featureToggleService.createFeatureToggle(
project.id, project.id,
previouslyDeleteToggle, previouslyDeleteToggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
await eventService.storeEvent( await eventService.storeEvent(
@ -1441,6 +1584,7 @@ test('should get correct amount of features created in current and past window',
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const toggles = [ const toggles = [
@ -1455,6 +1599,7 @@ test('should get correct amount of features created in current and past window',
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project.id, project.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type
user, user,
); );
}), }),
@ -1478,6 +1623,7 @@ test('should get correct amount of features archived in current and past window'
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const toggles = [ const toggles = [
@ -1492,6 +1638,7 @@ test('should get correct amount of features archived in current and past window'
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project.id, project.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
}), }),
@ -1529,6 +1676,7 @@ test('should get correct amount of project members for current and past window',
defaultStickiness: 'default', defaultStickiness: 'default',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const users = [ const users = [
@ -1569,6 +1717,7 @@ test('should return average time to production per toggle', async () => {
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong type should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const toggles = [ const toggles = [
@ -1584,7 +1733,7 @@ test('should return average time to production per toggle', async () => {
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project.id, project.id,
toggle, toggle,
user, user.email!,
); );
}), }),
); );
@ -1633,7 +1782,9 @@ test('should return average time to production per toggle for a specific project
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project1, user.id); await projectService.createProject(project1, user.id);
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project2, user.id); await projectService.createProject(project2, user.id);
const togglesProject1 = [ const togglesProject1 = [
@ -1652,6 +1803,7 @@ test('should return average time to production per toggle for a specific project
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project1.id, project1.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
}), }),
@ -1662,6 +1814,7 @@ test('should return average time to production per toggle for a specific project
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project2.id, project2.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
}), }),
@ -1726,6 +1879,7 @@ test('should return average time to production per toggle and include archived t
defaultStickiness: 'clientId', defaultStickiness: 'clientId',
}; };
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project1, user.id); await projectService.createProject(project1, user.id);
const togglesProject1 = [ const togglesProject1 = [
@ -1739,6 +1893,7 @@ test('should return average time to production per toggle and include archived t
return featureToggleService.createFeatureToggle( return featureToggleService.createFeatureToggle(
project1.id, project1.id,
toggle, toggle,
// @ts-ignore user is wrong parameter type, should be string
user, user,
); );
}), }),
@ -1790,6 +1945,7 @@ describe('feature flag naming patterns', () => {
featureNaming, featureNaming,
}; };
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
await projectService.updateProjectEnterpriseSettings(project, user); await projectService.updateProjectEnterpriseSettings(project, user);
@ -1804,6 +1960,7 @@ describe('feature flag naming patterns', () => {
...project, ...project,
featureNaming: { pattern: newPattern }, featureNaming: { pattern: newPattern },
}, },
// @ts-ignore user.id is wrong parameter type, should be user
user.id, user.id,
); );
@ -1822,10 +1979,12 @@ test('deleting a project with archived toggles should result in any remaining ar
}; };
const toggleName = 'archived-and-deleted'; const toggleName = 'archived-and-deleted';
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
name: toggleName, name: toggleName,
// @ts-ignore project property does not exist in FeatureToggleDTO
project: project.id, project: project.id,
enabled: false, enabled: false,
defaultStickiness: 'default', defaultStickiness: 'default',
@ -1836,6 +1995,7 @@ test('deleting a project with archived toggles should result in any remaining ar
// bring the project back again, previously this would allow those archived toggles to be resurrected // bring the project back again, previously this would allow those archived toggles to be resurrected
// we now expect them to be deleted correctly // we now expect them to be deleted correctly
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
const toggles = await stores.featureToggleStore.getAll({ const toggles = await stores.featureToggleStore.getAll({
@ -1852,6 +2012,7 @@ test('deleting a project with no archived toggles should not result in an error'
name: 'project-with-nothing', name: 'project-with-nothing',
}; };
// @ts-ignore user.id is wrong parameter type, should be user
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
await projectService.deleteProject(project.id, user); await projectService.deleteProject(project.id, user);
}); });