1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: Permissions update import (#3141)

This commit is contained in:
Jaanus Sellin 2023-02-17 12:58:55 +02:00 committed by GitHub
parent 350b55644a
commit 996fb1c104
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 542 additions and 131 deletions

View File

@ -0,0 +1,346 @@
import {
IUnleashTest,
setupAppWithAuth,
} from '../../test/e2e/helpers/test-helper';
import dbInit, { ITestDb } from '../../test/e2e/helpers/database-init';
import getLogger from '../../test/fixtures/no-logger';
import {
DEFAULT_PROJECT,
IEnvironmentStore,
IEventStore,
IFeatureToggleStore,
IProjectStore,
IUnleashStores,
RoleName,
} from '../types';
import { ImportTogglesSchema, VariantsSchema } from '../openapi';
import { IContextFieldDto } from '../types/stores/context-field-store';
import { AccessService } from '../services';
import { DEFAULT_ENV } from '../util';
let app: IUnleashTest;
let db: ITestDb;
let eventStore: IEventStore;
let environmentStore: IEnvironmentStore;
let projectStore: IProjectStore;
let toggleStore: IFeatureToggleStore;
let accessService: AccessService;
let adminRole;
let stores: IUnleashStores;
const regularUserName = 'import-user';
const adminUserName = 'admin-user';
const validateImport = (importPayload: ImportTogglesSchema, status = 200) =>
app.request
.post('/api/admin/features-batch/full-validate')
.send(importPayload)
.set('Content-Type', 'application/json')
.expect(status);
const createContextField = async (contextField: IContextFieldDto) => {
await app.request.post(`/api/admin/context`).send(contextField).expect(201);
};
const createFeature = async (featureName: string) => {
await app.request
.post(`/api/admin/projects/${DEFAULT_PROJECT}/features`)
.send({
name: featureName,
})
.set('Content-Type', 'application/json')
.expect(201);
};
const createFeatureToggleWithStrategy = async (featureName: string) => {
await createFeature(featureName);
return app.request
.post(
`/api/admin/projects/${DEFAULT_PROJECT}/features/${featureName}/environments/${DEFAULT_ENV}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200);
};
const archiveFeature = async (featureName: string) => {
await app.request
.delete(
`/api/admin/projects/${DEFAULT_PROJECT}/features/${featureName}`,
)
.set('Content-Type', 'application/json')
.expect(202);
};
const createProject = async () => {
await db.stores.environmentStore.create({
name: DEFAULT_ENV,
type: 'production',
});
await db.stores.projectStore.create({
name: DEFAULT_PROJECT,
description: '',
id: DEFAULT_PROJECT,
});
};
const newFeature = 'new_feature';
const archivedFeature = 'archived_feature';
const existingFeature = 'existing_feature';
const variants: VariantsSchema = [
{
name: 'variantA',
weight: 500,
payload: {
type: 'string',
value: 'payloadA',
},
overrides: [],
stickiness: 'default',
weightType: 'variable',
},
{
name: 'variantB',
weight: 500,
payload: {
type: 'string',
value: 'payloadB',
},
overrides: [],
stickiness: 'default',
weightType: 'variable',
},
];
const feature1: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project',
name: archivedFeature,
};
const feature2: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project',
name: newFeature,
};
const feature3: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project',
name: existingFeature,
};
const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constraints'] =
[
{
values: ['conduit'],
inverted: false,
operator: 'IN',
contextName: 'appName',
caseInsensitive: false,
},
];
const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = {
featureName: newFeature,
id: '798cb25a-2abd-47bd-8a95-40ec13472309',
name: 'default',
parameters: {},
constraints,
};
const tags = [
{
featureName: newFeature,
tagType: 'simple',
tagValue: 'tag1',
},
{
featureName: newFeature,
tagType: 'simple',
tagValue: 'tag2',
},
{
featureName: newFeature,
tagType: 'special_tag',
tagValue: 'feature_tagged',
},
];
const tagTypes = [
{ name: 'bestt', description: 'test' },
{ name: 'special_tag', description: 'this is my special tag' },
{ name: 'special_tag', description: 'this is my special tag' }, // deliberate duplicate
];
const importPayload: ImportTogglesSchema = {
data: {
features: [feature1, feature2, feature3],
featureStrategies: [exportedStrategy],
featureEnvironments: [
{
enabled: true,
environment: 'irrelevant',
featureName: newFeature,
name: newFeature,
variants,
},
],
featureTags: tags,
tagTypes,
contextFields: [],
segments: [],
},
project: DEFAULT_PROJECT,
environment: DEFAULT_ENV,
};
const createUserEditorAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({ name, email });
return user;
};
const createUserAdminAccess = async (name, email) => {
const { userStore } = stores;
const user = await userStore.insert({ name, email });
await accessService.addUserToRole(user.id, adminRole.id, 'default');
return user;
};
const loginRegularUser = () =>
app.request
.post(`/auth/demo/login`)
.send({
email: `${regularUserName}@getunleash.io`,
})
.expect(200);
const loginAdminUser = () =>
app.request
.post(`/auth/demo/login`)
.send({
email: `${adminUserName}@getunleash.io`,
})
.expect(200);
beforeAll(async () => {
db = await dbInit('export_import_permissions_api_serial', getLogger);
stores = db.stores;
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {
featuresExportImport: true,
},
},
},
db.rawDatabase,
);
eventStore = db.stores.eventStore;
environmentStore = db.stores.environmentStore;
projectStore = db.stores.projectStore;
toggleStore = db.stores.featureToggleStore;
accessService = app.services.accessService;
const roles = await accessService.getRootRoles();
adminRole = roles.find((role) => role.name === RoleName.ADMIN);
await createUserEditorAccess(
regularUserName,
`${regularUserName}@getunleash.io`,
);
await createUserAdminAccess(
adminUserName,
`${adminUserName}@getunleash.io`,
);
});
beforeEach(async () => {
await eventStore.deleteAll();
await toggleStore.deleteAll();
await projectStore.deleteAll();
await environmentStore.deleteAll();
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('validate import data', async () => {
await loginAdminUser();
await createProject();
const contextField: IContextFieldDto = {
name: 'validate_context_field',
legalValues: [{ value: 'Value1' }],
};
const createdContextField: IContextFieldDto = {
name: 'created_context_field',
legalValues: [{ value: 'new_value' }],
};
await createFeature(archivedFeature);
await archiveFeature(archivedFeature);
await createFeatureToggleWithStrategy(existingFeature);
await createContextField(contextField);
const importPayloadWithContextFields: ImportTogglesSchema = {
...importPayload,
data: {
...importPayload.data,
featureStrategies: [{ name: 'customStrategy' }],
segments: [{ id: 1, name: 'customSegment' }],
contextFields: [
{
...contextField,
legalValues: [{ value: 'Value2' }],
},
createdContextField,
],
},
};
await loginRegularUser();
const { body } = await validateImport(importPayloadWithContextFields, 200);
expect(body).toMatchObject({
errors: [
{
message:
'We detected the following custom strategy in the import file that needs to be created first:',
affectedItems: ['customStrategy'],
},
{
message:
'We detected the following context fields that do not have matching legal values with the imported ones:',
affectedItems: [contextField.name],
},
],
warnings: [
{
message:
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
affectedItems: [archivedFeature],
},
],
permissions: [
{
message:
'We detected you are missing the following permissions:',
affectedItems: [
'Create feature toggles',
'Update feature toggles',
'Update tag types',
'Create context fields',
'Create activation strategies',
'Delete activation strategies',
'Update variants on environment',
],
},
],
});
});

View File

@ -28,6 +28,7 @@ import {
UPDATE_FEATURE, UPDATE_FEATURE,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS, UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_TAG_TYPE, UPDATE_TAG_TYPE,
WithRequired,
} from '../types'; } from '../types';
import { import {
ExportResultSchema, ExportResultSchema,
@ -162,8 +163,8 @@ export default class ExportImportService {
const errors = this.compileErrors( const errors = this.compileErrors(
dto.project, dto.project,
unsupportedStrategies, unsupportedStrategies,
unsupportedContextFields,
otherProjectFeatures, otherProjectFeatures,
unsupportedContextFields,
); );
const warnings = this.compileWarnings( const warnings = this.compileWarnings(
usedCustomStrategies, usedCustomStrategies,
@ -224,24 +225,32 @@ export default class ExportImportService {
} }
private async importStrategies(dto: ImportTogglesSchema, user: User) { private async importStrategies(dto: ImportTogglesSchema, user: User) {
const hasFeatureName = (
featureStrategy: FeatureStrategySchema,
): featureStrategy is WithRequired<
FeatureStrategySchema,
'featureName'
> => Boolean(featureStrategy.featureName);
await Promise.all( await Promise.all(
dto.data.featureStrategies?.map((featureStrategy) => dto.data.featureStrategies
this.featureToggleService.createStrategy( ?.filter(hasFeatureName)
{ .map((featureStrategy) =>
name: featureStrategy.name, this.featureToggleService.createStrategy(
constraints: featureStrategy.constraints, {
parameters: featureStrategy.parameters, name: featureStrategy.name,
segments: featureStrategy.segments, constraints: featureStrategy.constraints,
sortOrder: featureStrategy.sortOrder, parameters: featureStrategy.parameters,
}, segments: featureStrategy.segments,
{ sortOrder: featureStrategy.sortOrder,
featureName: featureStrategy.featureName, },
environment: dto.environment, {
projectId: dto.project, featureName: featureStrategy.featureName,
}, environment: dto.environment,
extractUsernameFromUser(user), projectId: dto.project,
},
extractUsernameFromUser(user),
),
), ),
),
); );
} }
@ -268,7 +277,7 @@ export default class ExportImportService {
} }
private async importContextFields(dto: ImportTogglesSchema, user: User) { private async importContextFields(dto: ImportTogglesSchema, user: User) {
const newContextFields = await this.getNewContextFields(dto); const newContextFields = (await this.getNewContextFields(dto)) || [];
await Promise.all( await Promise.all(
newContextFields.map((contextField) => newContextFields.map((contextField) =>
this.contextService.createContextField( this.contextService.createContextField(
@ -297,9 +306,12 @@ export default class ExportImportService {
} }
private async importToggleVariants(dto: ImportTogglesSchema, user: User) { private async importToggleVariants(dto: ImportTogglesSchema, user: User) {
const featureEnvsWithVariants = dto.data.featureEnvironments?.filter( const featureEnvsWithVariants =
(featureEnvironment) => featureEnvironment.variants?.length > 0, dto.data.featureEnvironments?.filter(
); (featureEnvironment) =>
Array.isArray(featureEnvironment.variants) &&
featureEnvironment.variants.length > 0,
) || [];
await Promise.all( await Promise.all(
featureEnvsWithVariants.map((featureEnvironment) => featureEnvsWithVariants.map((featureEnvironment) =>
this.featureToggleService.saveVariantsOnEnv( this.featureToggleService.saveVariantsOnEnv(
@ -335,7 +347,10 @@ export default class ExportImportService {
const unsupportedContextFields = await this.getUnsupportedContextFields( const unsupportedContextFields = await this.getUnsupportedContextFields(
dto, dto,
); );
if (unsupportedContextFields.length > 0) { if (
Array.isArray(unsupportedContextFields) &&
unsupportedContextFields.length > 0
) {
throw new BadDataError( throw new BadDataError(
`Context fields with errors: ${unsupportedContextFields `Context fields with errors: ${unsupportedContextFields
.map((field) => field.name) .map((field) => field.name)
@ -387,9 +402,10 @@ export default class ExportImportService {
private async removeArchivedFeatures(dto: ImportTogglesSchema) { private async removeArchivedFeatures(dto: ImportTogglesSchema) {
const archivedFeatures = await this.getArchivedFeatures(dto); const archivedFeatures = await this.getArchivedFeatures(dto);
const featureTags = dto.data.featureTags.filter( const featureTags =
(tag) => !archivedFeatures.includes(tag.featureName), dto.data.featureTags?.filter(
); (tag) => !archivedFeatures.includes(tag.featureName),
) || [];
return { return {
...dto, ...dto,
data: { data: {
@ -397,12 +413,14 @@ export default class ExportImportService {
features: dto.data.features.filter( features: dto.data.features.filter(
(feature) => !archivedFeatures.includes(feature.name), (feature) => !archivedFeatures.includes(feature.name),
), ),
featureEnvironments: dto.data.featureEnvironments.filter( featureEnvironments: dto.data.featureEnvironments?.filter(
(environment) => (environment) =>
environment.featureName &&
!archivedFeatures.includes(environment.featureName), !archivedFeatures.includes(environment.featureName),
), ),
featureStrategies: dto.data.featureStrategies.filter( featureStrategies: dto.data.featureStrategies.filter(
(strategy) => (strategy) =>
strategy.featureName &&
!archivedFeatures.includes(strategy.featureName), !archivedFeatures.includes(strategy.featureName),
), ),
featureTags, featureTags,
@ -429,8 +447,8 @@ export default class ExportImportService {
private compileErrors( private compileErrors(
projectName: string, projectName: string,
strategies: FeatureStrategySchema[], strategies: FeatureStrategySchema[],
contextFields: IContextFieldDto[],
otherProjectFeatures: string[], otherProjectFeatures: string[],
contextFields?: IContextFieldDto[],
) { ) {
const errors: ImportTogglesValidateItemSchema[] = []; const errors: ImportTogglesValidateItemSchema[] = [];
@ -441,7 +459,7 @@ export default class ExportImportService {
affectedItems: strategies.map((strategy) => strategy.name), affectedItems: strategies.map((strategy) => strategy.name),
}); });
} }
if (contextFields.length > 0) { if (Array.isArray(contextFields) && contextFields.length > 0) {
errors.push({ errors.push({
message: message:
'We detected the following context fields that do not have matching legal values with the imported ones:', 'We detected the following context fields that do not have matching legal values with the imported ones:',
@ -558,25 +576,52 @@ export default class ExportImportService {
dto: ImportTogglesSchema, dto: ImportTogglesSchema,
user: User, user: User,
): Promise<string[]> { ): Promise<string[]> {
const requiredImportPermission = [
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE_STRATEGY,
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
];
const [newTagTypes, newContextFields] = await Promise.all([ const [newTagTypes, newContextFields] = await Promise.all([
this.getNewTagTypes(dto), this.getNewTagTypes(dto),
this.getNewContextFields(dto), this.getNewContextFields(dto),
]); ]);
const permissions = [...requiredImportPermission]; const permissions = [UPDATE_FEATURE];
if (newTagTypes.length > 0) { if (newTagTypes.length > 0) {
permissions.push(UPDATE_TAG_TYPE); permissions.push(UPDATE_TAG_TYPE);
} }
if (newContextFields.length > 0) { if (Array.isArray(newContextFields) && newContextFields.length > 0) {
permissions.push(CREATE_CONTEXT_FIELD); permissions.push(CREATE_CONTEXT_FIELD);
} }
const strategiesExistForFeatures =
await this.importTogglesStore.strategiesExistForFeatures(
dto.data.features.map((feature) => feature.name),
dto.environment,
);
if (strategiesExistForFeatures) {
permissions.push(DELETE_FEATURE_STRATEGY);
}
if (dto.data.featureStrategies.length > 0) {
permissions.push(CREATE_FEATURE_STRATEGY);
}
const featureEnvsWithVariants =
dto.data.featureEnvironments?.filter(
(featureEnvironment) =>
Array.isArray(featureEnvironment.variants) &&
featureEnvironment.variants.length > 0,
) || [];
if (featureEnvsWithVariants.length > 0) {
permissions.push(UPDATE_FEATURE_ENVIRONMENT_VARIANTS);
}
const existingFeatures =
await this.importTogglesStore.getExistingFeatures(
dto.data.features.map((feature) => feature.name),
);
if (existingFeatures.length < dto.data.features.length) {
permissions.push(CREATE_FEATURE);
}
const displayPermissions = const displayPermissions =
await this.importTogglesStore.getDisplayPermissions(permissions); await this.importTogglesStore.getDisplayPermissions(permissions);
@ -603,9 +648,10 @@ export default class ExportImportService {
const existingTagTypes = (await this.tagTypeService.getAll()).map( const existingTagTypes = (await this.tagTypeService.getAll()).map(
(tagType) => tagType.name, (tagType) => tagType.name,
); );
const newTagTypes = dto.data.tagTypes?.filter( const newTagTypes =
(tagType) => !existingTagTypes.includes(tagType.name), dto.data.tagTypes?.filter(
); (tagType) => !existingTagTypes.includes(tagType.name),
) || [];
const uniqueTagTypes = [ const uniqueTagTypes = [
...new Map(newTagTypes.map((item) => [item.name, item])).values(), ...new Map(newTagTypes.map((item) => [item.name, item])).values(),
]; ];
@ -657,10 +703,10 @@ export default class ExportImportService {
const filteredContextFields = contextFields.filter( const filteredContextFields = contextFields.filter(
(field) => (field) =>
featureEnvironments.some((featureEnv) => featureEnvironments.some((featureEnv) =>
featureEnv.variants.some( featureEnv.variants?.some(
(variant) => (variant) =>
variant.stickiness === field.name || variant.stickiness === field.name ||
variant.overrides.some( variant.overrides?.some(
(override) => (override) =>
override.contextName === field.name, override.contextName === field.name,
), ),
@ -677,7 +723,7 @@ export default class ExportImportService {
); );
const filteredSegments = segments.filter((segment) => const filteredSegments = segments.filter((segment) =>
featureStrategies.some((strategy) => featureStrategies.some((strategy) =>
strategy.segments.includes(segment.id), strategy.segments?.includes(segment.id),
), ),
); );
const filteredTagTypes = tagTypes.filter((tagType) => const filteredTagTypes = tagTypes.filter((tagType) =>

View File

@ -1,10 +1,11 @@
import { import {
IUnleashTest, IUnleashTest,
setupAppWithCustomConfig, setupAppWithCustomConfig,
} from '../../helpers/test-helper'; } from '../../test/e2e/helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init'; import dbInit, { ITestDb } from '../../test/e2e/helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../test/fixtures/no-logger';
import { import {
DEFAULT_PROJECT,
FeatureToggleDTO, FeatureToggleDTO,
IEnvironmentStore, IEnvironmentStore,
IEventStore, IEventStore,
@ -13,14 +14,15 @@ import {
ISegment, ISegment,
IStrategyConfig, IStrategyConfig,
IVariant, IVariant,
} from 'lib/types'; } from '../types';
import { DEFAULT_ENV } from '../../../../lib/util'; import { DEFAULT_ENV } from '../util';
import { import {
ContextFieldSchema, ContextFieldSchema,
ImportTogglesSchema, ImportTogglesSchema,
} from '../../../../lib/openapi'; VariantsSchema,
import User from '../../../../lib/types/user'; } from '../openapi';
import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store'; import User from '../types/user';
import { IContextFieldDto } from '../types/stores/context-field-store';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
@ -85,35 +87,30 @@ const createContext = async (context: ContextFieldSchema = defaultContext) => {
.expect(201); .expect(201);
}; };
const createVariants = async ( const createVariants = async (feature: string, variants: IVariant[]) => {
project: string,
feature: string,
environment: string,
variants: IVariant[],
) => {
await app.services.featureToggleService.saveVariantsOnEnv( await app.services.featureToggleService.saveVariantsOnEnv(
project, DEFAULT_PROJECT,
feature, feature,
environment, DEFAULT_ENV,
variants, variants,
new User({ id: 1 }), new User({ id: 1 }),
); );
}; };
const createProject = async (project: string, environment: string) => { const createProject = async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environment, name: DEFAULT_ENV,
type: 'production', type: 'production',
}); });
await db.stores.projectStore.create({ await db.stores.projectStore.create({
name: project, name: DEFAULT_PROJECT,
description: '', description: '',
id: project, id: DEFAULT_PROJECT,
}); });
await app.request await app.request
.post(`/api/admin/projects/${project}/environments`) .post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`)
.send({ .send({
environment, environment: DEFAULT_ENV,
}) })
.expect(200); .expect(200);
}; };
@ -128,9 +125,9 @@ const createContextField = async (contextField: IContextFieldDto) => {
await app.request.post(`/api/admin/context`).send(contextField).expect(201); await app.request.post(`/api/admin/context`).send(contextField).expect(201);
}; };
const createFeature = async (featureName: string, project: string) => { const createFeature = async (featureName: string) => {
await app.request await app.request
.post(`/api/admin/projects/${project}/features`) .post(`/api/admin/projects/${DEFAULT_PROJECT}/features`)
.send({ .send({
name: featureName, name: featureName,
}) })
@ -138,9 +135,11 @@ const createFeature = async (featureName: string, project: string) => {
.expect(201); .expect(201);
}; };
const archiveFeature = async (featureName: string, project: string) => { const archiveFeature = async (featureName: string) => {
await app.request await app.request
.delete(`/api/admin/projects/${project}/features/${featureName}`) .delete(
`/api/admin/projects/${DEFAULT_PROJECT}/features/${featureName}`,
)
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(202); .expect(202);
}; };
@ -188,7 +187,7 @@ afterAll(async () => {
test('exports features', async () => { test('exports features', async () => {
const segmentName = 'my-segment'; const segmentName = 'my-segment';
await createProject('default', 'default'); await createProject();
const segment = await createSegment({ name: segmentName, constraints: [] }); const segment = await createSegment({ name: segmentName, constraints: [] });
const strategy = { const strategy = {
name: 'default', name: 'default',
@ -249,7 +248,7 @@ test('exports features', async () => {
}); });
test('should export custom context fields from strategies and variants', async () => { test('should export custom context fields from strategies and variants', async () => {
await createProject('default', 'default'); await createProject();
const strategyContext = { const strategyContext = {
name: 'strategy-context', name: 'strategy-context',
legalValues: [ legalValues: [
@ -301,7 +300,7 @@ test('should export custom context fields from strategies and variants', async (
}; };
await createContext(variantStickinessContext); await createContext(variantStickinessContext);
await createContext(variantOverridesContext); await createContext(variantOverridesContext);
await createVariants('default', 'first_feature', 'default', [ await createVariants('first_feature', [
{ {
name: 'irrelevant', name: 'irrelevant',
weight: 1000, weight: 1000,
@ -351,7 +350,7 @@ test('should export custom context fields from strategies and variants', async (
test('should export tags', async () => { test('should export tags', async () => {
const featureName = 'first_feature'; const featureName = 'first_feature';
await createProject('default', 'default'); await createProject();
await createToggle( await createToggle(
{ {
name: featureName, name: featureName,
@ -390,7 +389,7 @@ test('should export tags', async () => {
}); });
test('returns no features, when no feature was requested', async () => { test('returns no features, when no feature was requested', async () => {
await createProject('default', 'default'); await createProject();
await createToggle({ await createToggle({
name: 'first_feature', name: 'first_feature',
description: 'the #1 feature', description: 'the #1 feature',
@ -424,34 +423,31 @@ const importToggles = (
.expect(expect); .expect(expect);
const defaultFeature = 'first_feature'; const defaultFeature = 'first_feature';
const defaultProject = 'default';
const defaultEnvironment = 'defalt';
const variants: ImportTogglesSchema['data']['featureEnvironments'][0]['variants'] = const variants: VariantsSchema = [
[ {
{ name: 'variantA',
name: 'variantA', weight: 500,
weight: 500, payload: {
payload: { type: 'string',
type: 'string', value: 'payloadA',
value: 'payloadA',
},
overrides: [],
stickiness: 'default',
weightType: 'variable',
}, },
{ overrides: [],
name: 'variantB', stickiness: 'default',
weight: 500, weightType: 'variable',
payload: { },
type: 'string', {
value: 'payloadB', name: 'variantB',
}, weight: 500,
overrides: [], payload: {
stickiness: 'default', type: 'string',
weightType: 'variable', value: 'payloadB',
}, },
]; overrides: [],
stickiness: 'default',
weightType: 'variable',
},
];
const exportedFeature: ImportTogglesSchema['data']['features'][0] = { const exportedFeature: ImportTogglesSchema['data']['features'][0] = {
project: 'old_project', project: 'old_project',
name: 'first_feature', name: 'first_feature',
@ -522,21 +518,17 @@ const defaultImportPayload: ImportTogglesSchema = {
contextFields: [], contextFields: [],
segments: [], segments: [],
}, },
project: defaultProject, project: DEFAULT_PROJECT,
environment: defaultEnvironment, environment: DEFAULT_ENV,
}; };
const getFeature = async (feature: string) => const getFeature = async (feature: string) =>
app.request.get(`/api/admin/features/${feature}`).expect(200); app.request.get(`/api/admin/features/${feature}`).expect(200);
const getFeatureEnvironment = ( const getFeatureEnvironment = (feature: string) =>
project: string,
feature: string,
environment: string,
) =>
app.request app.request
.get( .get(
`/api/admin/projects/${project}/features/${feature}/environments/${environment}`, `/api/admin/projects/${DEFAULT_PROJECT}/features/${feature}/environments/${DEFAULT_ENV}`,
) )
.expect(200); .expect(200);
@ -551,25 +543,23 @@ const validateImport = (importPayload: ImportTogglesSchema, status = 200) =>
.expect(status); .expect(status);
test('import features to existing project and environment', async () => { test('import features to existing project and environment', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
await importToggles(defaultImportPayload); await importToggles(defaultImportPayload);
const { body: importedFeature } = await getFeature(defaultFeature); const { body: importedFeature } = await getFeature(defaultFeature);
expect(importedFeature).toMatchObject({ expect(importedFeature).toMatchObject({
name: 'first_feature', name: 'first_feature',
project: defaultProject, project: DEFAULT_PROJECT,
variants, variants,
}); });
const { body: importedFeatureEnvironment } = await getFeatureEnvironment( const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
defaultProject,
defaultFeature, defaultFeature,
defaultEnvironment,
); );
expect(importedFeatureEnvironment).toMatchObject({ expect(importedFeatureEnvironment).toMatchObject({
name: defaultFeature, name: defaultFeature,
environment: defaultEnvironment, environment: DEFAULT_ENV,
enabled: true, enabled: true,
strategies: [ strategies: [
{ {
@ -589,26 +579,24 @@ test('import features to existing project and environment', async () => {
}); });
test('importing same JSON should work multiple times in a row', async () => { test('importing same JSON should work multiple times in a row', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
await importToggles(defaultImportPayload); await importToggles(defaultImportPayload);
await importToggles(defaultImportPayload); await importToggles(defaultImportPayload);
const { body: importedFeature } = await getFeature(defaultFeature); const { body: importedFeature } = await getFeature(defaultFeature);
expect(importedFeature).toMatchObject({ expect(importedFeature).toMatchObject({
name: 'first_feature', name: 'first_feature',
project: defaultProject, project: DEFAULT_PROJECT,
variants, variants,
}); });
const { body: importedFeatureEnvironment } = await getFeatureEnvironment( const { body: importedFeatureEnvironment } = await getFeatureEnvironment(
defaultProject,
defaultFeature, defaultFeature,
defaultEnvironment,
); );
expect(importedFeatureEnvironment).toMatchObject({ expect(importedFeatureEnvironment).toMatchObject({
name: defaultFeature, name: defaultFeature,
environment: defaultEnvironment, environment: DEFAULT_ENV,
enabled: true, enabled: true,
strategies: [ strategies: [
{ {
@ -623,7 +611,7 @@ test('importing same JSON should work multiple times in a row', async () => {
}); });
test('reject import with unknown context fields', async () => { test('reject import with unknown context fields', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
const contextField = { const contextField = {
name: 'ContextField1', name: 'ContextField1',
legalValues: [{ value: 'Value1', description: '' }], legalValues: [{ value: 'Value1', description: '' }],
@ -654,12 +642,14 @@ test('reject import with unknown context fields', async () => {
}); });
test('reject import with unsupported strategies', async () => { test('reject import with unsupported strategies', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
const importPayloadWithContextFields: ImportTogglesSchema = { const importPayloadWithContextFields: ImportTogglesSchema = {
...defaultImportPayload, ...defaultImportPayload,
data: { data: {
...defaultImportPayload.data, ...defaultImportPayload.data,
featureStrategies: [{ name: 'customStrategy' }], featureStrategies: [
{ name: 'customStrategy', featureName: 'featureName' },
],
}, },
}; };
@ -675,7 +665,7 @@ test('reject import with unsupported strategies', async () => {
}); });
test('validate import data', async () => { test('validate import data', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
const contextField: IContextFieldDto = { const contextField: IContextFieldDto = {
name: 'validate_context_field', name: 'validate_context_field',
legalValues: [{ value: 'Value1' }], legalValues: [{ value: 'Value1' }],
@ -686,8 +676,8 @@ test('validate import data', async () => {
legalValues: [{ value: 'new_value' }], legalValues: [{ value: 'new_value' }],
}; };
await createFeature(defaultFeature, defaultProject); await createFeature(defaultFeature);
await archiveFeature(defaultFeature, defaultProject); await archiveFeature(defaultFeature);
await createContextField(contextField); await createContextField(contextField);
const importPayloadWithContextFields: ImportTogglesSchema = { const importPayloadWithContextFields: ImportTogglesSchema = {
@ -733,7 +723,7 @@ test('validate import data', async () => {
}); });
test('should create new context', async () => { test('should create new context', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
const context = { const context = {
name: 'create-new-context', name: 'create-new-context',
legalValues: [{ value: 'Value1' }], legalValues: [{ value: 'Value1' }],
@ -753,10 +743,10 @@ test('should create new context', async () => {
}); });
test('should not import archived features tags', async () => { test('should not import archived features tags', async () => {
await createProject(defaultProject, defaultEnvironment); await createProject();
await importToggles(defaultImportPayload); await importToggles(defaultImportPayload);
await archiveFeature(defaultFeature, defaultProject); await archiveFeature(defaultFeature);
await importToggles({ await importToggles({
...defaultImportPayload, ...defaultImportPayload,

View File

@ -13,7 +13,14 @@ export interface IImportTogglesStore {
deleteTagsForFeatures(tags: string[]): Promise<void>; deleteTagsForFeatures(tags: string[]): Promise<void>;
strategiesExistForFeatures(
featureNames: string[],
environment: string,
): Promise<boolean>;
getDisplayPermissions( getDisplayPermissions(
names: string[], names: string[],
): Promise<{ name: string; displayName: string }[]>; ): Promise<{ name: string; displayName: string }[]>;
getExistingFeatures(featureNames: string[]): Promise<string[]>;
} }

View File

@ -4,7 +4,7 @@ import { Knex } from 'knex';
const T = { const T = {
featureStrategies: 'feature_strategies', featureStrategies: 'feature_strategies',
features: 'features', features: 'features',
feature_tag: 'feature_tag', featureTag: 'feature_tag',
}; };
export class ImportTogglesStore implements IImportTogglesStore { export class ImportTogglesStore implements IImportTogglesStore {
private db: Knex; private db: Knex;
@ -35,6 +35,20 @@ export class ImportTogglesStore implements IImportTogglesStore {
.del(); .del();
} }
async strategiesExistForFeatures(
featureNames: string[],
environment: string,
): Promise<boolean> {
const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM feature_strategies WHERE environment = ? and feature_name in (' +
featureNames.map(() => '?').join(',') +
')) AS present',
[environment, ...featureNames],
);
const { present } = result.rows[0];
return present;
}
async getArchivedFeatures(featureNames: string[]): Promise<string[]> { async getArchivedFeatures(featureNames: string[]): Promise<string[]> {
const rows = await this.db(T.features) const rows = await this.db(T.features)
.select('name') .select('name')
@ -43,6 +57,11 @@ export class ImportTogglesStore implements IImportTogglesStore {
return rows.map((row) => row.name); return rows.map((row) => row.name);
} }
async getExistingFeatures(featureNames: string[]): Promise<string[]> {
const rows = await this.db(T.features).whereIn('name', featureNames);
return rows.map((row) => row.name);
}
async getFeaturesInOtherProjects( async getFeaturesInOtherProjects(
featureNames: string[], featureNames: string[],
project: string, project: string,
@ -54,7 +73,7 @@ export class ImportTogglesStore implements IImportTogglesStore {
return rows.map((row) => ({ name: row.name, project: row.project })); return rows.map((row) => ({ name: row.name, project: row.project }));
} }
async deleteTagsForFeatures(tags: string[]): Promise<void> { async deleteTagsForFeatures(features: string[]): Promise<void> {
return this.db(T.feature_tag).whereIn('feature_name', tags).del(); return this.db(T.featureTag).whereIn('feature_name', features).del();
} }
} }

View File

@ -8,3 +8,5 @@ export type PartialDeep<T> = T extends object
// Mark one or more properties as optional. // Mark one or more properties as optional.
export type PartialSome<T, K extends keyof T> = Pick<Partial<T>, K> & export type PartialSome<T, K extends keyof T> = Pick<Partial<T>, K> &
Omit<T, K>; Omit<T, K>;
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };

View File

@ -70,8 +70,9 @@ export async function setupAppWithCustomConfig(
export async function setupAppWithAuth( export async function setupAppWithAuth(
stores: IUnleashStores, stores: IUnleashStores,
customOptions?: any, customOptions?: any,
db?: Db,
): Promise<IUnleashTest> { ): Promise<IUnleashTest> {
return createApp(stores, IAuthType.DEMO, undefined, customOptions); return createApp(stores, IAuthType.DEMO, undefined, customOptions, db);
} }
export async function setupAppWithCustomAuth( export async function setupAppWithCustomAuth(

View File

@ -27,7 +27,7 @@
/* Strict Type-Checking Options */ /* Strict Type-Checking Options */
// "strict": true, /* Enable all strict type-checking options. */ // "strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */ // "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */