Skip to content

Commit

Permalink
feat: validate archive dependent features (#5019)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Oct 13, 2023
1 parent 36ae842 commit 3eeafba
Show file tree
Hide file tree
Showing 6 changed files with 102 additions and 6 deletions.
5 changes: 5 additions & 0 deletions src/lib/db/transaction.ts
Expand Up @@ -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;
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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.',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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.',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/features/feature-toggle/feature-toggle-service.ts
Expand Up @@ -1533,6 +1533,10 @@ class FeatureToggleService {
);
}

async validateArchiveToggles(featureNames: string[]): Promise<string[]> {
return this.dependentFeaturesReadModel.getOrphanParents(featureNames);
}

async unprotectedArchiveToggles(
featureNames: string[],
createdBy: string,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/openapi/util/openapi-tags.ts
Expand Up @@ -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',
Expand Down
40 changes: 39 additions & 1 deletion src/lib/routes/admin-api/project/project-archive.ts
Expand Up @@ -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,
Expand All @@ -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`;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
46 changes: 46 additions & 0 deletions src/test/e2e/api/admin/feature-archive.e2e.test.ts
Expand Up @@ -17,6 +17,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
dependentFeatures: true,
disableEnvsOnRevive: true,
},
},
Expand Down Expand Up @@ -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]);
});

0 comments on commit 3eeafba

Please sign in to comment.