unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
1,346 lines • 110 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const database_init_1 = __importDefault(require("../../../../test/e2e/helpers/database-init"));
const test_helper_1 = require("../../../../test/e2e/helpers/test-helper");
const no_logger_1 = __importDefault(require("../../../../test/fixtures/no-logger"));
const constants_1 = require("../../../util/constants");
const events_1 = require("../../../types/events");
const api_user_1 = __importDefault(require("../../../types/api-user"));
const api_token_1 = require("../../../types/models/api-token");
const incompatible_project_error_1 = __importDefault(require("../../../error/incompatible-project-error"));
const model_1 = require("../../../types/model");
const uuid_1 = require("uuid");
const random_id_1 = require("../../../util/random-id");
const types_1 = require("../../../types");
const error_1 = require("../../../error");
let app;
let db;
let defaultToken;
const sortOrderFirst = 0;
const sortOrderSecond = 10;
const TESTUSERID = 3333;
const createSegment = async (segmentName) => {
const segment = await app.services.segmentService.create({
name: segmentName,
description: '',
constraints: [
{
contextName: 'appName',
operator: 'IN',
values: ['test'],
caseInsensitive: false,
inverted: false,
},
],
}, types_1.TEST_AUDIT_USER);
return segment;
};
const createStrategy = async (featureName, payload, expectedCode = 200) => {
return app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/default/strategies`)
.send(payload)
.expect(expectedCode);
};
const updateStrategy = async (featureName, strategyId, payload, expectedCode = 200) => {
const { body } = await app.request
.put(`/api/admin/projects/default/features/${featureName}/environments/default/strategies/${strategyId}`)
.send(payload)
.expect(expectedCode);
return body;
};
beforeAll(async () => {
db = await (0, database_init_1.default)('feature_strategy_api_serial', no_logger_1.default, {
dbInitMethod: 'legacy',
});
app = await (0, test_helper_1.setupAppWithCustomConfig)(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
featureCollaborators: true,
},
},
}, db.rawDatabase);
defaultToken =
await app.services.apiTokenService.createApiTokenWithProjects({
type: api_token_1.ApiTokenType.CLIENT,
projects: ['default'],
environment: 'default',
tokenName: 'tester',
});
});
afterEach(async () => {
const all = await db.stores.projectStore.getEnvironmentsForProject('default');
await Promise.all(all
.filter((env) => env.environment !== constants_1.DEFAULT_ENV)
.map(async (env) => db.stores.projectStore.deleteEnvironmentForProject('default', env.environment)));
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
async function addStrategies(featureName, envName) {
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
sortOrder: sortOrderFirst,
})
.expect(200);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
sortOrder: sortOrderSecond,
})
.expect(200);
}
test('Trying to add a strategy configuration to environment not connected to flag should fail', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'com.test.feature',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('com.test.feature');
expect(res.body.createdAt).toBeTruthy();
});
return app.request
.post('/api/admin/projects/default/features/com.test.feature/environments/dev/strategies')
.send({
name: 'default',
parameters: {
userId: '14',
},
})
.expect(400)
.expect((r) => {
expect(r.body.details[0].message.includes('environment')).toBeTruthy();
expect(r.body.details[0].message.includes('project')).toBeTruthy();
});
});
test('Can get project overview', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'project-overview',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('project-overview');
expect(res.body.createdAt).toBeTruthy();
});
await app.request
.get('/api/admin/projects/default')
.expect(200)
.expect((r) => {
expect(r.body.name).toBe('Default');
expect(r.body.features).toHaveLength(2);
expect(r.body.members).toBe(0);
});
});
test('should list dependencies and children', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
const { body: childFeature } = await app.getProjectFeatures('default', child);
const { body: parentFeature } = await app.getProjectFeatures('default', parent);
expect(childFeature).toMatchObject({
children: [],
dependencies: [{ feature: parent, enabled: true, variants: [] }],
});
expect(parentFeature).toMatchObject({
children: [child],
dependencies: [],
});
});
test('should not allow to change project with dependencies', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
const user = new api_user_1.default({
tokenName: 'project-changer',
permissions: ['ADMIN'],
project: '*',
type: api_token_1.ApiTokenType.ADMIN,
environment: '*',
secret: 'a',
});
await expect(async () => app.services.projectService.changeProject('default', child,
// @ts-ignore
user, 'default', types_1.TEST_AUDIT_USER)).rejects.toThrow(new error_1.ForbiddenError('Changing project not allowed. Feature has dependencies.'));
});
test('Should not allow to archive/delete feature with children', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
const { body: archiveBody } = await app.request
.delete(`/api/admin/projects/default/features/${parent}`)
.expect(403);
const { body: deleteBody } = await app.request
.post(`/api/admin/projects/default/delete`)
.set('Content-Type', 'application/json')
.send({ features: [parent] })
.expect(403);
expect(archiveBody.message).toBe('You can not archive/delete this feature since other features depend on it.');
expect(deleteBody.message).toBe('You can not archive/delete this feature since other features depend on it.');
});
test('Should allow to archive/delete feature with children if no orphans are left', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
await app.request
.post(`/api/admin/projects/default/delete`)
.set('Content-Type', 'application/json')
.send({ features: [parent, child] })
.expect(200);
});
test('Should not allow to archive/delete feature when orphans are left', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
const orphan = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.createFeature(orphan, 'default');
await app.addDependency(child, parent);
await app.addDependency(orphan, parent);
const { body: deleteBody } = await app.request
.post(`/api/admin/projects/default/delete`)
.set('Content-Type', 'application/json')
.send({ features: [parent, child] })
.expect(403);
expect(deleteBody.message).toBe('You can not archive/delete those features since other features depend on them.');
});
test('should clone feature with parent dependencies', async () => {
const parent = (0, uuid_1.v4)();
const child = (0, uuid_1.v4)();
const childClone = (0, uuid_1.v4)();
await app.createFeature(parent, 'default');
await app.createFeature(child, 'default');
await app.addDependency(child, parent);
await app.request
.post(`/api/admin/projects/default/features/${child}/clone`)
.send({ name: childClone, replaceGroupId: false })
.expect(201);
const { body: clonedFeature } = await app.getProjectFeatures('default', child);
expect(clonedFeature).toMatchObject({
children: [],
dependencies: [{ feature: parent, enabled: true, variants: [] }],
});
});
test('Can get features for project', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'features-for-project',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('features-for-project');
expect(res.body.createdAt).toBeTruthy();
});
await app.request
.get('/api/admin/projects/default/features')
.expect(200)
.expect((res) => {
expect(res.body.version).toBeTruthy();
expect(res.body.features.some((feature) => feature.name === 'features-for-project')).toBeTruthy();
});
});
test('Project overview includes environment connected to feature', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'com.test.environment',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('com.test.environment');
expect(res.body.createdAt).toBeTruthy();
});
await db.stores.environmentStore.create({
name: 'project-overview',
type: 'production',
});
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment: 'project-overview' })
.expect(200);
return app.request
.get('/api/admin/projects/default')
.expect(200)
.expect((r) => {
expect(r.body.features[0].environments[0].name).toBe(constants_1.DEFAULT_ENV);
expect(r.body.features[0].environments[1].name).toBe('project-overview');
});
});
test('Disconnecting environment from project, removes environment from features in project overview', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'com.test.disconnect.environment',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.expect((res) => {
expect(res.body.name).toBe('com.test.disconnect.environment');
expect(res.body.createdAt).toBeTruthy();
});
await db.stores.environmentStore.create({
name: 'dis-project-overview',
type: 'production',
});
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment: 'dis-project-overview' })
.expect(200);
await app.request
.delete('/api/admin/projects/default/environments/dis-project-overview')
.expect(200);
return app.request
.get('/api/admin/projects/default')
.expect(200)
.expect((r) => {
expect(r.body.features.some((e) => e.environment === 'dis-project-overview')).toBeFalsy();
});
});
test('Can enable/disable environment for feature with strategies', async () => {
const envName = 'enable-feature-environment';
const featureName = 'com.test.enable.environment';
const project = 'default';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post(`/api/admin/projects/${project}/environments`)
.send({
environment: envName,
})
.expect(200);
// Create feature
await app.createFeature(featureName).expect((res) => {
expect(res.body.name).toBe(featureName);
expect(res.body.createdAt).toBeTruthy();
});
// Add strategy to it
await app.request
.post(`/api/admin/projects/${project}/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.post(`/api/admin/projects/${project}/features/${featureName}/environments/${envName}/on`)
.set('Content-Type', 'application/json')
.expect(200);
await app.getProjectFeatures(project, featureName).expect((res) => {
const enabledFeatureEnv = res.body.environments.find((e) => e.name === 'enable-feature-environment');
expect(enabledFeatureEnv).toBeTruthy();
expect(enabledFeatureEnv.enabled).toBe(true);
});
await app.request
.post(`/api/admin/projects/${project}/features/${featureName}/environments/${envName}/off`)
.send({})
.expect(200);
await app.getProjectFeatures(project, featureName).expect((res) => {
const disabledFeatureEnv = res.body.environments.find((e) => e.name === 'enable-feature-environment');
expect(disabledFeatureEnv).toBeTruthy();
expect(disabledFeatureEnv.enabled).toBe(false);
});
});
test('Can bulk enable/disable environment for feature with strategies', async () => {
const envName = 'bulk-enable-feature-environment';
const featureName = 'com.test.bulk.enable.environment';
const project = 'default';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post(`/api/admin/projects/${project}/environments`)
.send({
environment: envName,
})
.expect(200);
// Create feature
await app.createFeature(featureName).expect((res) => {
expect(res.body.name).toBe(featureName);
expect(res.body.createdAt).toBeTruthy();
});
// Add strategy to it
await app.request
.post(`/api/admin/projects/${project}/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.post(`/api/admin/projects/${project}/bulk_features/environments/${envName}/on`)
.send({ features: [featureName] })
.set('Content-Type', 'application/json')
.expect(200);
await app.getProjectFeatures(project, featureName).expect((res) => {
const enabledFeatureEnv = res.body.environments.find((e) => e.name === envName);
expect(enabledFeatureEnv).toBeTruthy();
expect(enabledFeatureEnv.enabled).toBe(true);
});
await app.request
.post(`/api/admin/projects/${project}/bulk_features/environments/${envName}/off`)
.send({ features: [featureName] })
.expect(200);
await app.getProjectFeatures(project, featureName).expect((res) => {
const disabledFeatureEnv = res.body.environments.find((e) => e.name === envName);
expect(disabledFeatureEnv).toBeTruthy();
expect(disabledFeatureEnv.enabled).toBe(false);
});
});
test("Trying to get a project that doesn't exist yields 404", async () => {
await app.request.get('/api/admin/projects/nonexisting').expect(404);
});
test('Trying to get features for non-existing project also yields 404', async () => {
await app.request
.get('/api/admin/projects/nonexisting/features')
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(0);
});
});
test('Can use new project feature flag endpoint to create feature flag without strategies', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'new.flag.without.strategy',
})
.expect(201)
.expect((res) => {
expect(res.body.project).toBe('default');
});
});
test('Can create feature flag without strategies', async () => {
const name = 'new.flag.without.strategy.2';
await app.request
.post('/api/admin/projects/default/features')
.send({ name });
const { body: flag } = await app.request.get(`/api/admin/projects/default/features/${name}`);
expect(flag.environments).toHaveLength(1);
expect(flag.environments[0].strategies).toHaveLength(0);
});
test('Still validates feature flag input when creating', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'Some invalid name',
})
.expect(400);
});
test('Trying to create flag that already exists yield 409 error', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'already.exists.test',
})
.expect(201)
.expect((res) => {
expect(res.body.project).toBe('default');
});
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'already.exists.test',
})
.expect(409);
});
test('Trying to create flag under project that does not exist should fail', async () => {
await app.request
.post('/api/admin/projects/non-existing-secondary/features')
.send({
name: 'project.does.not.exist',
})
.expect(404);
});
test('Can get environment info for feature flag', async () => {
const envName = 'environment-info';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: 'environment.info' })
.expect(201);
await app.request
.get(`/api/admin/projects/default/features/environment.info/environments/${envName}`)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBe(false);
expect(res.body.environment).toBe(envName);
expect(res.body.strategies).toHaveLength(0);
});
});
test('Getting environment info for environment that does not exist yields 404', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({ name: 'non.existing.env' })
.expect(201);
await app.request
.get('/api/admin/projects/default/features/non.existing.env/environments/non.existing.environment')
.expect(404);
});
test('Trying to flag environment that does not exist yields 404', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({ name: 'flag.env' })
.expect(201);
await app.request
.post('/api/admin/projects/default/features/flag.env/environments/does-not-exist/on')
.send({})
.expect(404);
await app.request
.post('/api/admin/projects/default/features/flag.env/environments/does-not-exist/off')
.send({})
.expect(404);
});
test('Getting feature that does not exist should yield 404', async () => {
await app.request
.get('/api/admin/projects/default/features/non.existing.feature')
.expect(404);
});
describe('Interacting with features using project IDs that belong to other projects', () => {
const otherProject = 'project2';
const featureName = 'new-flag';
const nonExistingProject = 'this-is-not-a-project';
beforeAll(async () => {
const dummyAdmin = await app.services.userService.createUser({
name: 'Some Name',
email: 'test@getunleash.io',
rootRole: model_1.RoleName.ADMIN,
}, types_1.TEST_AUDIT_USER);
await app.services.projectService.createProject({
name: otherProject,
id: otherProject,
mode: 'open',
defaultStickiness: 'clientId',
}, dummyAdmin, types_1.TEST_AUDIT_USER);
// ensure the new project has been created
await app.request
.get(`/api/admin/projects/${otherProject}`)
.expect(200);
// create flag in default project
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
});
afterAll(async () => {
await db.stores.projectStore.delete(otherProject);
await db.stores.featureToggleStore.delete(featureName);
await db.stores.userStore.deleteAll();
});
test("Getting a feature yields 404 if the provided project id doesn't match the feature's project", async () => {
await app.request
.get(`/api/admin/projects/${otherProject}/features/${featureName}`)
.expect(404);
});
test("Getting a feature yields 404 if the provided project doesn't exist", async () => {
await app.request
.get(`/api/admin/projects/${nonExistingProject}/features/${featureName}`)
.expect(404);
});
test("Archiving a feature yields 404 if the provided project id doesn't match the feature's project", async () => {
await app.request
.delete(`/api/admin/projects/${otherProject}/features/${featureName}`)
.expect(404);
});
test("Archiving a feature yields 404 if the provided project doesn't exist", async () => {
await app.request
.delete(`/api/admin/projects/${nonExistingProject}/features/${featureName}`)
.expect(404);
});
test("Trying to archive a feature that doesn't exist should yield a 404, regardless of whether the project exists or not.", async () => {
await app.request
.delete(`/api/admin/projects/${nonExistingProject}/features/${featureName + featureName}`)
.expect(404);
await app.request
.delete(`/api/admin/projects/${otherProject}/features/${featureName + featureName}`)
.expect(404);
});
});
test('Should update feature flag', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.update';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.put(`${url}/${name}`)
.send({ name, description: 'updated', type: 'kill-switch' })
.expect(200);
const { body: flag } = await app.request.get(`${url}/${name}`);
expect(flag.name).toBe(name);
expect(flag.description).toBe('updated');
expect(flag.type).toBe('kill-switch');
expect(flag.archived).toBeFalsy();
});
test('Should not change name of feature flag', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.update.2';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.put(`${url}/${name}`)
.send({ name: 'new name', description: 'updated', type: 'kill-switch' })
.expect(400);
});
test('Should not change project of feature flag even if it is part of body', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.update.3';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
const { body } = await app.request
.put(`${url}/${name}`)
.send({
name,
description: 'updated',
type: 'kill-switch',
project: 'new',
})
.expect(200);
expect(body.project).toBe('default');
});
test('Should patch feature flag', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.patch';
await app.request
.post(url)
.send({
name,
description: 'some',
type: 'release',
impressionData: true,
})
.expect(201);
await app.request
.patch(`${url}/${name}`)
.send([
{ op: 'replace', path: '/description', value: 'New desc' },
{ op: 'replace', path: '/type', value: 'kill-switch' },
{ op: 'replace', path: '/impressionData', value: false },
])
.expect(200);
const { body: flag } = await app.request.get(`${url}/${name}`);
expect(flag.name).toBe(name);
expect(flag.description).toBe('New desc');
expect(flag.type).toBe('kill-switch');
expect(flag.impressionData).toBe(false);
expect(flag.archived).toBeFalsy();
const events = await db.stores.eventStore.getAll({
type: events_1.FEATURE_METADATA_UPDATED,
});
const updateForOurFlag = events.find((e) => e.data.name === name);
expect(updateForOurFlag).toBeTruthy();
expect(updateForOurFlag?.data.description).toBe('New desc');
expect(updateForOurFlag?.data.type).toBe('kill-switch');
});
test('Should patch feature flag and not remove variants', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.variants';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request
.put(`${url}/${name}/variants`)
.send([
{
name: 'variant1',
weightType: 'variable',
weight: 500,
stickiness: 'default',
},
{
name: 'variant2',
weightType: 'variable',
weight: 500,
stickiness: 'default',
},
])
.expect(200);
await app.request
.patch(`${url}/${name}`)
.send([
{ op: 'replace', path: '/description', value: 'New desc' },
{ op: 'replace', path: '/type', value: 'kill-switch' },
])
.expect(200);
const { body: flag } = await app.request.get(`${url}/${name}`);
expect(flag.name).toBe(name);
expect(flag.description).toBe('New desc');
expect(flag.type).toBe('kill-switch');
expect(flag.variants).toHaveLength(2);
});
test('Patching feature flags to stale should trigger FEATURE_STALE_ON event', async () => {
const url = '/api/admin/projects/default/features';
const name = 'flag.stale.on.patch';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release', stale: false })
.expect(201);
await app.request
.patch(`${url}/${name}`)
.send([{ op: 'replace', path: '/stale', value: true }])
.expect(200);
const { body: flag } = await app.request.get(`${url}/${name}`);
expect(flag.name).toBe(name);
expect(flag.archived).toBeFalsy();
expect(flag.stale).toBeTruthy();
const events = await db.stores.eventStore.getAll({
type: events_1.FEATURE_STALE_ON,
});
const updateForOurFlag = events.find((e) => e.featureName === name);
expect(updateForOurFlag).toBeTruthy();
});
test('Trying to patch variants on a feature flag should trigger an OperationDeniedError', async () => {
const url = '/api/admin/projects/default/features';
const name = 'flag.variants.on.patch';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release', stale: false });
await app.request
.patch(`${url}/${name}`)
.send([
{
op: 'add',
path: '/variants/1',
value: {
name: 'variant',
weightType: 'variable',
weight: 500,
stickiness: 'default',
},
},
])
.expect(403)
.expect((res) => {
expect(res.body.message.includes('PATCH')).toBeTruthy();
expect(res.body.message.includes('/api/admin/projects/:project/features/:feature/variants')).toBeTruthy();
});
});
test('Patching feature flags to active (turning stale to false) should trigger FEATURE_STALE_OFF event', async () => {
const url = '/api/admin/projects/default/features';
const name = 'flag.stale.off.patch';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release', stale: true })
.expect(201);
await app.request
.patch(`${url}/${name}`)
.send([{ op: 'replace', path: '/stale', value: false }])
.expect(200);
const { body: flag } = await app.request.get(`${url}/${name}`);
expect(flag.name).toBe(name);
expect(flag.archived).toBeFalsy();
expect(flag.stale).toBe(false);
const events = await db.stores.eventStore.getAll({
type: events_1.FEATURE_STALE_OFF,
});
const updateForOurFlag = events.find((e) => e.featureName === name);
expect(updateForOurFlag).toBeTruthy();
});
test('Should archive feature flag', async () => {
const url = '/api/admin/projects/default/features';
const name = 'new.flag.archive';
await app.request
.post(url)
.send({ name, description: 'some', type: 'release' })
.expect(201);
await app.request.delete(`${url}/${name}`);
await app.request.get(`${url}/${name}`).expect(404);
const { body } = await app.request
.get(`/api/admin/archive/features`)
.expect(200);
const flag = body.features.find((f) => f.name === name);
expect(flag).toBeDefined();
});
test('Can add strategy to feature flag to a "some-env-2"', async () => {
const envName = 'some-env-2';
const featureName = 'feature.strategy.flag';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { userId: 'string' } })
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}`)
.expect((res) => {
const env = res.body.environments.find((e) => e.name === envName);
expect(env.strategies).toHaveLength(1);
});
});
test('Can update strategy on feature flag', async () => {
const envName = 'default';
const featureName = 'feature.strategy.update.strat';
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
// create feature flag
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
// add strategy
const { body: strategy } = await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { userIds: '' } })
.expect(200);
// update strategy
await app.request
.put(`${featurePath}/environments/${envName}/strategies/${strategy.id}`)
.send({ name: 'default', parameters: { userIds: 1234 } })
.expect(200);
const { body } = await app.request.get(`${featurePath}`);
const defaultEnv = body.environments[0];
expect(body.environments).toHaveLength(1);
expect(defaultEnv.name).toBe(envName);
expect(defaultEnv.strategies).toHaveLength(1);
expect(defaultEnv.strategies[0].parameters).toStrictEqual({
userIds: '1234',
});
});
test('should coerce all strategy parameter values to strings', async () => {
const envName = 'default';
const featureName = (0, random_id_1.randomId)();
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { foo: 1234 } })
.expect(200);
const { body } = await app.request.get(`${featurePath}`);
const defaultEnv = body.environments[0];
expect(defaultEnv.strategies).toHaveLength(1);
expect(defaultEnv.strategies[0].parameters).toStrictEqual({
foo: '1234',
});
});
test('should NOT limit the length of parameter values', async () => {
const envName = 'default';
const featureName = (0, random_id_1.randomId)();
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { foo: 'x'.repeat(101) } })
.expect(200);
});
test('Can NOT delete strategy with wrong projectId', async () => {
const envName = 'default';
const featureName = 'feature.strategy.delete.strat.error';
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
// create feature flag
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
// add strategy
const { body: strategy } = await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userIds: '',
},
})
.expect(200);
// delete strategy
await app.request
.delete(`/api/admin/projects/wrongId/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.expect(403);
});
test('add strategy cannot use wrong projectId', async () => {
const envName = 'default';
const featureName = 'feature.strategy.add.strat.wrong.projectId';
// create feature flag
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
// add strategy
await app.request
.post(`/api/admin/projects/invalidId/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userIds: '',
},
})
.expect(404);
});
test('update strategy on feature flag cannot use wrong projectId', async () => {
const envName = 'default';
const featureName = 'feature.strategy.update.strat.wrong.projectId';
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
// create feature flag
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
// add strategy
const { body: strategy } = await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userIds: '',
},
})
.expect(200);
// update strategy
await app.request
.put(`/api/admin/projects/invalidId/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.send({
name: 'default',
parameters: {
userIds: '1234',
},
})
.expect(403);
});
test('Environments are returned in sortOrder', async () => {
const sortedSecond = 'sortedSecond';
const sortedLast = 'sortedLast';
const featureName = 'feature.strategy.flag.sortOrder';
// Create environments
await db.stores.environmentStore.create({
name: sortedLast,
type: 'production',
sortOrder: 8000,
});
await db.stores.environmentStore.create({
name: sortedSecond,
type: 'production',
sortOrder: 8,
});
// Connect environments to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: sortedSecond,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: sortedLast,
})
.expect(200);
/* Create feature flag */
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
/* create strategies connected to feature flag */
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${sortedSecond}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${sortedLast}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}`)
.expect(200)
.expect((res) => {
expect(res.body.environments).toHaveLength(3);
expect(res.body.environments[0].name).toBe(constants_1.DEFAULT_ENV);
expect(res.body.environments[1].name).toBe(sortedSecond);
expect(res.body.environments[2].name).toBe(sortedLast);
});
});
test('Can get strategies for feature and environment', async () => {
const envName = 'get-strategy';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
const featureName = 'feature.get.strategies';
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.expect(200)
.expect((res) => {
expect(res.body).toHaveLength(1);
expect(res.body[0].parameters.userId).toBe('string');
});
});
test('Getting strategies for environment that does not exist yields 404', async () => {
const featureName = 'feature.get.strategies.for.nonexisting';
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.get(`/api/admin/projects/default/features/${featureName}/environments/should.not.exist/strategies`)
.expect(404);
});
test('Can update a strategy based on id', async () => {
const envName = 'feature.update.strategies';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
const featureName = 'feature.update.strategies';
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
// biome-ignore lint/suspicious/noImplicitAnyLet: Due to assigning from res.body later on. we ignore the type here
let strategy;
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200)
.expect((res) => {
strategy = res.body;
});
await app.request
.put(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.send({ parameters: { userId: 'string', companyId: 'string' } })
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.expect(200)
.expect((res) => {
expect(res.body.parameters.companyId).toBeTruthy();
expect(res.body.parameters.userId).toBeTruthy();
});
});
test('Trying to update a non existing feature strategy should yield 404', async () => {
const envName = 'feature.non.existing.strategy';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
const featureName = 'feature.non.existing.strategy';
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.put(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/some-non-existing-id`)
.send({ parameters: { fancyField: 'string' } })
.expect(404);
});
test('Can patch a strategy based on id', async () => {
const BASE_URI = '/api/admin/projects/default';
const envName = 'feature.patch.strategies';
const featureName = 'feature.patch.strategies';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'test',
});
// Connect environment to project
await app.request
.post(`${BASE_URI}/environments`)
.send({
environment: envName,
})
.expect(200);
await app.request
.post(`${BASE_URI}/features`)
.send({ name: featureName })
.expect(201);
let strategy;
await app.request
.post(`${BASE_URI}/features/${featureName}/environments/${envName}/strategies`)
.send({
name: 'flexibleRollout',
parameters: {
groupId: 'demo',
rollout: 20,
stickiness: 'default',
},
})
.expect(200)
.expect((res) => {
strategy = res.body;
});
await app.request
.patch(`${BASE_URI}/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.send([{ op: 'replace', path: '/parameters/rollout', value: '42' }])
.expect(200);
await app.request
.get(`${BASE_URI}/features/${featureName}/environments/${envName}/strategies/${strategy.id}`)
.expect(200)
.expect((res) => {
expect(res.body.parameters.rollout).toBe('42');
});
});
test('Trying to get a non existing feature strategy should yield 404', async () => {
const envName = 'feature.non.existing.strategy.get';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'production',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
const featureName = 'feature.non.existing.strategy.get';
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
await app.request
.get(`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/some-non-existing-id`)
.expect(404);
});
test('Can enable environment for feature without strategies', async () => {
const environment = 'some-env';
const featureName = 'com.test.enable.environment.disabled';
// Create environment
await db.stores.environmentStore.create({
name: environment,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment })
.expect(200);
// Create feature
await app.request
.post('/api/admin/projects/default/features')
.send({
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${environment}/on`)
.set('Content-Type', 'application/json')
.expect(200);
await app.request
.get(`/api/admin/projects/default/features/${featureName}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
const enabledFeatureEnv = res.body.environments.find((e) => e.name === environment);
expect(enabledFeatureEnv.enabled).toBe(true);
expect(enabledFeatureEnv.type).toBe('test');
});
});
test('Deleting a strategy should include name of feature strategy was deleted from', async () => {
const environment = 'delete_strategy_env';
const featureName = 'delete_strategy_feature';
// Create environment
await db.stores.environmentStore.create({
name: environment,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment })
.expect(200);
// Create feature
await app.request
.post('/api/admin/projects/default/features')
.send({
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201);
let strategyId;
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`)
.send({ name: 'default', constraints: [] })
.expect(200)
.expect((res) => {
strategyId = res.body.id;
});
expect(strategyId).toBeTruthy();
// Delete strategy
await app.request
.delete(`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies/${strategyId}`)
.expect(200);
const events = await db.stores.eventStore.getAll({
type: events_1.FEATURE_STRATEGY_REMOVE,
});
expect(events).toHaveLength(1);
expect(events[0].featureName).toBe(featureName);
expect(events[0].environment).toBe(environment);
expect(events[0].preData.id).toBe(strategyId);
});
test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async () => {
const environment = 'environment_enabled_env';
const featureName = 'com.test.enable.environment.event.sent';
// Create environment
await db.stores.environmentStore.create({
name: environment,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({ environment })
.expect(200);
// Create feature
await app.request
.post('/api/admin/projects/default/features')
.send({
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`)
.send({ name: 'default', constraints: [] })
.expect(200);
await app.request
.post(`/api/admin/projects/default/features/${featureName}/environments/${environment}/on`)
.set('Content-Type', 'application/json')
.expect(200);
const events = await db.stores.eventSt