mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: validate archive dependent features (#5019)
This commit is contained in:
		
							parent
							
								
									36ae842248
								
							
						
					
					
						commit
						3eeafba5f9
					
				| @ -16,6 +16,11 @@ export const createKnexTransactionStarter = ( | ||||
|     function transaction<T>( | ||||
|         scope: (trx: KnexTransaction) => void | Promise<T>, | ||||
|     ) { | ||||
|         if (!knex) { | ||||
|             console.warn( | ||||
|                 'It looks like your DB is not provided. Very often it is a test setup problem in setupAppWithCustomConfig', | ||||
|             ); | ||||
|         } | ||||
|         return knex.transaction(scope); | ||||
|     } | ||||
|     return transaction; | ||||
|  | ||||
| @ -21,7 +21,6 @@ import { IAuthRequest } from '../../routes/unleash-types'; | ||||
| import { InvalidOperationError } from '../../error'; | ||||
| import { DependentFeaturesService } from './dependent-features-service'; | ||||
| import { TransactionCreator, UnleashTransaction } from '../../db/transaction'; | ||||
| import { extractUsernameFromUser } from '../../util'; | ||||
| 
 | ||||
| interface ProjectParams { | ||||
|     projectId: string; | ||||
| @ -91,7 +90,7 @@ export default class DependentFeaturesController extends Controller { | ||||
|             permission: UPDATE_FEATURE_DEPENDENCY, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     tags: ['Dependencies'], | ||||
|                     summary: 'Add a feature dependency.', | ||||
|                     description: | ||||
|                         'Add a dependency to a parent feature. Each environment will resolve corresponding dependency independently.', | ||||
| @ -115,7 +114,7 @@ export default class DependentFeaturesController extends Controller { | ||||
|             acceptAnyContentType: true, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     tags: ['Dependencies'], | ||||
|                     summary: 'Deletes a feature dependency.', | ||||
|                     description: 'Remove a dependency to a parent feature.', | ||||
|                     operationId: 'deleteFeatureDependency', | ||||
| @ -135,7 +134,7 @@ export default class DependentFeaturesController extends Controller { | ||||
|             acceptAnyContentType: true, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     tags: ['Dependencies'], | ||||
|                     summary: 'Deletes feature dependencies.', | ||||
|                     description: 'Remove dependencies to all parent features.', | ||||
|                     operationId: 'deleteFeatureDependencies', | ||||
| @ -154,7 +153,7 @@ export default class DependentFeaturesController extends Controller { | ||||
|             permission: NONE, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     tags: ['Dependencies'], | ||||
|                     summary: 'List parent options.', | ||||
|                     description: | ||||
|                         'List available parents who have no transitive dependencies.', | ||||
|  | ||||
| @ -1533,6 +1533,10 @@ class FeatureToggleService { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async validateArchiveToggles(featureNames: string[]): Promise<string[]> { | ||||
|         return this.dependentFeaturesReadModel.getOrphanParents(featureNames); | ||||
|     } | ||||
| 
 | ||||
|     async unprotectedArchiveToggles( | ||||
|         featureNames: string[], | ||||
|         createdBy: string, | ||||
|  | ||||
| @ -39,6 +39,10 @@ const OPENAPI_TAGS = [ | ||||
|         description: | ||||
|             'Create, update, and delete [context fields](https://docs.getunleash.io/reference/unleash-context) that Unleash is aware of.', | ||||
|     }, | ||||
|     { | ||||
|         name: 'Dependencies', | ||||
|         description: 'Manage feature dependencies.', | ||||
|     }, | ||||
|     { name: 'Edge', description: 'Endpoints related to Unleash on the Edge.' }, | ||||
|     { | ||||
|         name: 'Environments', | ||||
|  | ||||
| @ -16,7 +16,11 @@ import { | ||||
|     emptyResponse, | ||||
|     getStandardResponses, | ||||
| } from '../../../openapi/util/standard-responses'; | ||||
| import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi'; | ||||
| import { | ||||
|     BatchFeaturesSchema, | ||||
|     createRequestSchema, | ||||
|     createResponseSchema, | ||||
| } from '../../../openapi'; | ||||
| import Controller from '../../controller'; | ||||
| import { | ||||
|     TransactionCreator, | ||||
| @ -25,6 +29,7 @@ import { | ||||
| 
 | ||||
| const PATH = '/:projectId'; | ||||
| const PATH_ARCHIVE = `${PATH}/archive`; | ||||
| const PATH_VALIDATE_ARCHIVE = `${PATH}/archive/validate`; | ||||
| const PATH_DELETE = `${PATH}/delete`; | ||||
| const PATH_REVIVE = `${PATH}/revive`; | ||||
| 
 | ||||
| @ -109,6 +114,27 @@ export default class ProjectArchiveController extends Controller { | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: PATH_VALIDATE_ARCHIVE, | ||||
|             handler: this.validateArchiveFeatures, | ||||
|             permission: DELETE_FEATURE, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     operationId: 'validateArchiveFeatures', | ||||
|                     description: | ||||
|                         'This endpoint validated if a list of features can be archived. Returns a list of parent features that would orphan some child features. If archive can process then empty list is returned.', | ||||
|                     summary: 'Validates if a list of features can be archived', | ||||
|                     requestBody: createRequestSchema('batchFeaturesSchema'), | ||||
|                     responses: { | ||||
|                         200: createResponseSchema('batchFeaturesSchema'), | ||||
|                         ...getStandardResponses(400, 401, 403, 415), | ||||
|                     }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
| 
 | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: PATH_ARCHIVE, | ||||
| @ -169,6 +195,18 @@ export default class ProjectArchiveController extends Controller { | ||||
|         await this.featureService.archiveToggles(features, req.user, projectId); | ||||
|         res.status(202).end(); | ||||
|     } | ||||
| 
 | ||||
|     async validateArchiveFeatures( | ||||
|         req: IAuthRequest<IProjectParam, void, BatchFeaturesSchema>, | ||||
|         res: Response, | ||||
|     ): Promise<void> { | ||||
|         const { features } = req.body; | ||||
| 
 | ||||
|         const offendingParents = | ||||
|             await this.featureService.validateArchiveToggles(features); | ||||
| 
 | ||||
|         res.send(offendingParents); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = ProjectArchiveController; | ||||
|  | ||||
| @ -17,6 +17,7 @@ beforeAll(async () => { | ||||
|             experimental: { | ||||
|                 flags: { | ||||
|                     strictSchemaValidation: true, | ||||
|                     dependentFeatures: true, | ||||
|                     disableEnvsOnRevive: true, | ||||
|                 }, | ||||
|             }, | ||||
| @ -249,3 +250,48 @@ test('Should be able to bulk archive features', async () => { | ||||
|     ); | ||||
|     expect(archivedFeatures).toHaveLength(2); | ||||
| }); | ||||
| 
 | ||||
| test('Should validate if a list of features with dependencies can be archived', async () => { | ||||
|     const child1 = 'child1Feature'; | ||||
|     const child2 = 'child2Feature'; | ||||
|     const parent = 'parentFeature'; | ||||
| 
 | ||||
|     await app.createFeature(child1); | ||||
|     await app.createFeature(child2); | ||||
|     await app.createFeature(parent); | ||||
|     await app.addDependency(child1, parent); | ||||
|     await app.addDependency(child2, parent); | ||||
| 
 | ||||
|     const { body: allChildrenAndParent } = await app.request | ||||
|         .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive/validate`) | ||||
|         .send({ | ||||
|             features: [child1, child2, parent], | ||||
|         }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     const { body: allChildren } = await app.request | ||||
|         .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive/validate`) | ||||
|         .send({ | ||||
|             features: [child1, child2], | ||||
|         }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     const { body: onlyParent } = await app.request | ||||
|         .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive/validate`) | ||||
|         .send({ | ||||
|             features: [parent], | ||||
|         }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     const { body: oneChildAndParent } = await app.request | ||||
|         .post(`/api/admin/projects/${DEFAULT_PROJECT}/archive/validate`) | ||||
|         .send({ | ||||
|             features: [child1, parent], | ||||
|         }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     expect(allChildrenAndParent).toEqual([]); | ||||
|     expect(allChildren).toEqual([]); | ||||
|     expect(onlyParent).toEqual([parent]); | ||||
|     expect(oneChildAndParent).toEqual([parent]); | ||||
| }); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user