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>(
 | 
					    function transaction<T>(
 | 
				
			||||||
        scope: (trx: KnexTransaction) => void | Promise<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 knex.transaction(scope);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return transaction;
 | 
					    return transaction;
 | 
				
			||||||
 | 
				
			|||||||
@ -21,7 +21,6 @@ import { IAuthRequest } from '../../routes/unleash-types';
 | 
				
			|||||||
import { InvalidOperationError } from '../../error';
 | 
					import { InvalidOperationError } from '../../error';
 | 
				
			||||||
import { DependentFeaturesService } from './dependent-features-service';
 | 
					import { DependentFeaturesService } from './dependent-features-service';
 | 
				
			||||||
import { TransactionCreator, UnleashTransaction } from '../../db/transaction';
 | 
					import { TransactionCreator, UnleashTransaction } from '../../db/transaction';
 | 
				
			||||||
import { extractUsernameFromUser } from '../../util';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface ProjectParams {
 | 
					interface ProjectParams {
 | 
				
			||||||
    projectId: string;
 | 
					    projectId: string;
 | 
				
			||||||
@ -91,7 +90,7 @@ export default class DependentFeaturesController extends Controller {
 | 
				
			|||||||
            permission: UPDATE_FEATURE_DEPENDENCY,
 | 
					            permission: UPDATE_FEATURE_DEPENDENCY,
 | 
				
			||||||
            middleware: [
 | 
					            middleware: [
 | 
				
			||||||
                openApiService.validPath({
 | 
					                openApiService.validPath({
 | 
				
			||||||
                    tags: ['Features'],
 | 
					                    tags: ['Dependencies'],
 | 
				
			||||||
                    summary: 'Add a feature dependency.',
 | 
					                    summary: 'Add a feature dependency.',
 | 
				
			||||||
                    description:
 | 
					                    description:
 | 
				
			||||||
                        'Add a dependency to a parent feature. Each environment will resolve corresponding dependency independently.',
 | 
					                        '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,
 | 
					            acceptAnyContentType: true,
 | 
				
			||||||
            middleware: [
 | 
					            middleware: [
 | 
				
			||||||
                openApiService.validPath({
 | 
					                openApiService.validPath({
 | 
				
			||||||
                    tags: ['Features'],
 | 
					                    tags: ['Dependencies'],
 | 
				
			||||||
                    summary: 'Deletes a feature dependency.',
 | 
					                    summary: 'Deletes a feature dependency.',
 | 
				
			||||||
                    description: 'Remove a dependency to a parent feature.',
 | 
					                    description: 'Remove a dependency to a parent feature.',
 | 
				
			||||||
                    operationId: 'deleteFeatureDependency',
 | 
					                    operationId: 'deleteFeatureDependency',
 | 
				
			||||||
@ -135,7 +134,7 @@ export default class DependentFeaturesController extends Controller {
 | 
				
			|||||||
            acceptAnyContentType: true,
 | 
					            acceptAnyContentType: true,
 | 
				
			||||||
            middleware: [
 | 
					            middleware: [
 | 
				
			||||||
                openApiService.validPath({
 | 
					                openApiService.validPath({
 | 
				
			||||||
                    tags: ['Features'],
 | 
					                    tags: ['Dependencies'],
 | 
				
			||||||
                    summary: 'Deletes feature dependencies.',
 | 
					                    summary: 'Deletes feature dependencies.',
 | 
				
			||||||
                    description: 'Remove dependencies to all parent features.',
 | 
					                    description: 'Remove dependencies to all parent features.',
 | 
				
			||||||
                    operationId: 'deleteFeatureDependencies',
 | 
					                    operationId: 'deleteFeatureDependencies',
 | 
				
			||||||
@ -154,7 +153,7 @@ export default class DependentFeaturesController extends Controller {
 | 
				
			|||||||
            permission: NONE,
 | 
					            permission: NONE,
 | 
				
			||||||
            middleware: [
 | 
					            middleware: [
 | 
				
			||||||
                openApiService.validPath({
 | 
					                openApiService.validPath({
 | 
				
			||||||
                    tags: ['Features'],
 | 
					                    tags: ['Dependencies'],
 | 
				
			||||||
                    summary: 'List parent options.',
 | 
					                    summary: 'List parent options.',
 | 
				
			||||||
                    description:
 | 
					                    description:
 | 
				
			||||||
                        'List available parents who have no transitive dependencies.',
 | 
					                        '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(
 | 
					    async unprotectedArchiveToggles(
 | 
				
			||||||
        featureNames: string[],
 | 
					        featureNames: string[],
 | 
				
			||||||
        createdBy: string,
 | 
					        createdBy: string,
 | 
				
			||||||
 | 
				
			|||||||
@ -39,6 +39,10 @@ const OPENAPI_TAGS = [
 | 
				
			|||||||
        description:
 | 
					        description:
 | 
				
			||||||
            'Create, update, and delete [context fields](https://docs.getunleash.io/reference/unleash-context) that Unleash is aware of.',
 | 
					            '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: 'Edge', description: 'Endpoints related to Unleash on the Edge.' },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        name: 'Environments',
 | 
					        name: 'Environments',
 | 
				
			||||||
 | 
				
			|||||||
@ -16,7 +16,11 @@ import {
 | 
				
			|||||||
    emptyResponse,
 | 
					    emptyResponse,
 | 
				
			||||||
    getStandardResponses,
 | 
					    getStandardResponses,
 | 
				
			||||||
} from '../../../openapi/util/standard-responses';
 | 
					} from '../../../openapi/util/standard-responses';
 | 
				
			||||||
import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi';
 | 
					import {
 | 
				
			||||||
 | 
					    BatchFeaturesSchema,
 | 
				
			||||||
 | 
					    createRequestSchema,
 | 
				
			||||||
 | 
					    createResponseSchema,
 | 
				
			||||||
 | 
					} from '../../../openapi';
 | 
				
			||||||
import Controller from '../../controller';
 | 
					import Controller from '../../controller';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
    TransactionCreator,
 | 
					    TransactionCreator,
 | 
				
			||||||
@ -25,6 +29,7 @@ import {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const PATH = '/:projectId';
 | 
					const PATH = '/:projectId';
 | 
				
			||||||
const PATH_ARCHIVE = `${PATH}/archive`;
 | 
					const PATH_ARCHIVE = `${PATH}/archive`;
 | 
				
			||||||
 | 
					const PATH_VALIDATE_ARCHIVE = `${PATH}/archive/validate`;
 | 
				
			||||||
const PATH_DELETE = `${PATH}/delete`;
 | 
					const PATH_DELETE = `${PATH}/delete`;
 | 
				
			||||||
const PATH_REVIVE = `${PATH}/revive`;
 | 
					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({
 | 
					        this.route({
 | 
				
			||||||
            method: 'post',
 | 
					            method: 'post',
 | 
				
			||||||
            path: PATH_ARCHIVE,
 | 
					            path: PATH_ARCHIVE,
 | 
				
			||||||
@ -169,6 +195,18 @@ export default class ProjectArchiveController extends Controller {
 | 
				
			|||||||
        await this.featureService.archiveToggles(features, req.user, projectId);
 | 
					        await this.featureService.archiveToggles(features, req.user, projectId);
 | 
				
			||||||
        res.status(202).end();
 | 
					        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;
 | 
					module.exports = ProjectArchiveController;
 | 
				
			||||||
 | 
				
			|||||||
@ -17,6 +17,7 @@ beforeAll(async () => {
 | 
				
			|||||||
            experimental: {
 | 
					            experimental: {
 | 
				
			||||||
                flags: {
 | 
					                flags: {
 | 
				
			||||||
                    strictSchemaValidation: true,
 | 
					                    strictSchemaValidation: true,
 | 
				
			||||||
 | 
					                    dependentFeatures: true,
 | 
				
			||||||
                    disableEnvsOnRevive: true,
 | 
					                    disableEnvsOnRevive: true,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
@ -249,3 +250,48 @@ test('Should be able to bulk archive features', async () => {
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
    expect(archivedFeatures).toHaveLength(2);
 | 
					    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