diff --git a/src/lib/error/minimum-one-environment-error.ts b/src/lib/error/minimum-one-environment-error.ts new file mode 100644 index 0000000000..ea7804394e --- /dev/null +++ b/src/lib/error/minimum-one-environment-error.ts @@ -0,0 +1,23 @@ +class MinimumOneEnvironmentError extends Error { + constructor(message: string) { + super(); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.message = message; + } + + toJSON(): object { + return { + isJoi: true, + name: this.constructor.name, + details: [ + { + message: this.message, + }, + ], + }; + } +} +export default MinimumOneEnvironmentError; +module.exports = MinimumOneEnvironmentError; diff --git a/src/lib/routes/util.ts b/src/lib/routes/util.ts index 0d2ec2ce23..50dc5d12fd 100644 --- a/src/lib/routes/util.ts +++ b/src/lib/routes/util.ts @@ -54,6 +54,8 @@ export const handleErrors: ( return res.status(400).json(error).end(); case 'IncompatibleProjectError': return res.status(403).json(error).end(); + case 'MinimumOneEnvironmentError': + return res.status(400).json(error).end(); default: logger.error('Server failed executing request', error); return res.status(500).end(); diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts index 9a42a1e9ba..48c9ce91da 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -9,6 +9,8 @@ import NotFoundError from '../error/notfound-error'; import { IEnvironmentStore } from '../types/stores/environment-store'; import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; +import { IProjectStore } from 'lib/types/stores/project-store'; +import MinimumOneEnvironmentError from '../error/minimum-one-environment-error'; export default class EnvironmentService { private logger: Logger; @@ -17,6 +19,8 @@ export default class EnvironmentService { private featureStrategiesStore: IFeatureStrategiesStore; + private projectStore: IProjectStore; + private featureEnvironmentStore: IFeatureEnvironmentStore; constructor( @@ -24,11 +28,13 @@ export default class EnvironmentService { environmentStore, featureStrategiesStore, featureEnvironmentStore, + projectStore, }: Pick< IUnleashStores, | 'environmentStore' | 'featureStrategiesStore' | 'featureEnvironmentStore' + | 'projectStore' >, { getLogger }: Pick, ) { @@ -36,6 +42,7 @@ export default class EnvironmentService { this.environmentStore = environmentStore; this.featureStrategiesStore = featureStrategiesStore; this.featureEnvironmentStore = featureEnvironmentStore; + this.projectStore = projectStore; } async getAll(): Promise { @@ -91,13 +98,23 @@ export default class EnvironmentService { environment: string, projectId: string, ): Promise { - await this.featureEnvironmentStore.disconnectFeatures( - environment, + const projectEnvs = await this.projectStore.getEnvironmentsForProject( projectId, ); - await this.featureEnvironmentStore.disconnectProject( - environment, - projectId, + + if (projectEnvs.length > 1) { + await this.featureEnvironmentStore.disconnectFeatures( + environment, + projectId, + ); + await this.featureEnvironmentStore.disconnectProject( + environment, + projectId, + ); + return; + } + throw new MinimumOneEnvironmentError( + 'You must always have one active environment', ); } } diff --git a/src/test/e2e/api/admin/project/environments.e2e.test.ts b/src/test/e2e/api/admin/project/environments.e2e.test.ts index 1aead68270..e1b96eb2bb 100644 --- a/src/test/e2e/api/admin/project/environments.e2e.test.ts +++ b/src/test/e2e/api/admin/project/environments.e2e.test.ts @@ -85,3 +85,20 @@ test('Should remove environment from project', async () => { expect(envs).toHaveLength(1); }); + +test('Should not remove environment from project if project only has one environment enabled', async () => { + await app.request + .delete(`/api/admin/projects/default/environments/default`) + .expect(400) + .expect((r) => { + expect(r.body.details[0].message).toBe( + 'You must always have one active environment', + ); + }); + + const envs = await db.stores.projectStore.getEnvironmentsForProject( + 'default', + ); + + expect(envs).toHaveLength(1); +}); diff --git a/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts b/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts index adb4fa5599..2985559f19 100644 --- a/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts +++ b/src/test/e2e/api/client/feature.env.disabled.e2e.test.ts @@ -53,7 +53,12 @@ test('returns feature toggle for default env', async () => { }); test('returns feature toggle for default env even if it is removed from project', async () => { - await app.services.environmentService.removeEnvironmentFromProject( + await db.stores.featureEnvironmentStore.disconnectFeatures( + 'default', + 'default', + ); + + await db.stores.featureEnvironmentStore.disconnectProject( 'default', 'default', ); diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts index 1d7b486e4e..aab32d1184 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -109,10 +109,11 @@ test('Adding same environment twice should throw a NameExistsError', async () => name: 'uniqueness-test', type: 'production', }); + await service.addEnvironmentToProject('uniqueness-test', 'default'); + await service.removeEnvironmentFromProject('test-connection', 'default'); await service.removeEnvironmentFromProject('removal-test', 'default'); - await service.addEnvironmentToProject('uniqueness-test', 'default'); return expect(async () => service.addEnvironmentToProject('uniqueness-test', 'default'), ).rejects.toThrow(