From 996fb1c1042e0156fcd14c33db1d6c632b5dbbd8 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 17 Feb 2023 12:58:55 +0200 Subject: [PATCH] feat: Permissions update import (#3141) --- .../export-import-permissions.e2e.test.ts | 346 ++++++++++++++++++ .../export-import-service.ts | 132 ++++--- .../export-import.e2e.test.ts | 156 ++++---- .../import-toggles-store-type.ts | 7 + .../import-toggles-store.ts | 25 +- src/lib/types/partial.ts | 2 + src/test/e2e/helpers/test-helper.ts | 3 +- tsconfig.json | 2 +- 8 files changed, 542 insertions(+), 131 deletions(-) create mode 100644 src/lib/export-import-toggles/export-import-permissions.e2e.test.ts rename src/{test/e2e/api/admin => lib/export-import-toggles}/export-import.e2e.test.ts (85%) diff --git a/src/lib/export-import-toggles/export-import-permissions.e2e.test.ts b/src/lib/export-import-toggles/export-import-permissions.e2e.test.ts new file mode 100644 index 0000000000..107b4f2864 --- /dev/null +++ b/src/lib/export-import-toggles/export-import-permissions.e2e.test.ts @@ -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', + ], + }, + ], + }); +}); diff --git a/src/lib/export-import-toggles/export-import-service.ts b/src/lib/export-import-toggles/export-import-service.ts index 9aa9dce2a3..da30733eac 100644 --- a/src/lib/export-import-toggles/export-import-service.ts +++ b/src/lib/export-import-toggles/export-import-service.ts @@ -28,6 +28,7 @@ import { UPDATE_FEATURE, UPDATE_FEATURE_ENVIRONMENT_VARIANTS, UPDATE_TAG_TYPE, + WithRequired, } from '../types'; import { ExportResultSchema, @@ -162,8 +163,8 @@ export default class ExportImportService { const errors = this.compileErrors( dto.project, unsupportedStrategies, - unsupportedContextFields, otherProjectFeatures, + unsupportedContextFields, ); const warnings = this.compileWarnings( usedCustomStrategies, @@ -224,24 +225,32 @@ export default class ExportImportService { } private async importStrategies(dto: ImportTogglesSchema, user: User) { + const hasFeatureName = ( + featureStrategy: FeatureStrategySchema, + ): featureStrategy is WithRequired< + FeatureStrategySchema, + 'featureName' + > => Boolean(featureStrategy.featureName); await Promise.all( - dto.data.featureStrategies?.map((featureStrategy) => - this.featureToggleService.createStrategy( - { - name: featureStrategy.name, - constraints: featureStrategy.constraints, - parameters: featureStrategy.parameters, - segments: featureStrategy.segments, - sortOrder: featureStrategy.sortOrder, - }, - { - featureName: featureStrategy.featureName, - environment: dto.environment, - projectId: dto.project, - }, - extractUsernameFromUser(user), + dto.data.featureStrategies + ?.filter(hasFeatureName) + .map((featureStrategy) => + this.featureToggleService.createStrategy( + { + name: featureStrategy.name, + constraints: featureStrategy.constraints, + parameters: featureStrategy.parameters, + segments: featureStrategy.segments, + sortOrder: featureStrategy.sortOrder, + }, + { + featureName: featureStrategy.featureName, + environment: dto.environment, + projectId: dto.project, + }, + extractUsernameFromUser(user), + ), ), - ), ); } @@ -268,7 +277,7 @@ export default class ExportImportService { } private async importContextFields(dto: ImportTogglesSchema, user: User) { - const newContextFields = await this.getNewContextFields(dto); + const newContextFields = (await this.getNewContextFields(dto)) || []; await Promise.all( newContextFields.map((contextField) => this.contextService.createContextField( @@ -297,9 +306,12 @@ export default class ExportImportService { } private async importToggleVariants(dto: ImportTogglesSchema, user: User) { - const featureEnvsWithVariants = dto.data.featureEnvironments?.filter( - (featureEnvironment) => featureEnvironment.variants?.length > 0, - ); + const featureEnvsWithVariants = + dto.data.featureEnvironments?.filter( + (featureEnvironment) => + Array.isArray(featureEnvironment.variants) && + featureEnvironment.variants.length > 0, + ) || []; await Promise.all( featureEnvsWithVariants.map((featureEnvironment) => this.featureToggleService.saveVariantsOnEnv( @@ -335,7 +347,10 @@ export default class ExportImportService { const unsupportedContextFields = await this.getUnsupportedContextFields( dto, ); - if (unsupportedContextFields.length > 0) { + if ( + Array.isArray(unsupportedContextFields) && + unsupportedContextFields.length > 0 + ) { throw new BadDataError( `Context fields with errors: ${unsupportedContextFields .map((field) => field.name) @@ -387,9 +402,10 @@ export default class ExportImportService { private async removeArchivedFeatures(dto: ImportTogglesSchema) { const archivedFeatures = await this.getArchivedFeatures(dto); - const featureTags = dto.data.featureTags.filter( - (tag) => !archivedFeatures.includes(tag.featureName), - ); + const featureTags = + dto.data.featureTags?.filter( + (tag) => !archivedFeatures.includes(tag.featureName), + ) || []; return { ...dto, data: { @@ -397,12 +413,14 @@ export default class ExportImportService { features: dto.data.features.filter( (feature) => !archivedFeatures.includes(feature.name), ), - featureEnvironments: dto.data.featureEnvironments.filter( + featureEnvironments: dto.data.featureEnvironments?.filter( (environment) => + environment.featureName && !archivedFeatures.includes(environment.featureName), ), featureStrategies: dto.data.featureStrategies.filter( (strategy) => + strategy.featureName && !archivedFeatures.includes(strategy.featureName), ), featureTags, @@ -429,8 +447,8 @@ export default class ExportImportService { private compileErrors( projectName: string, strategies: FeatureStrategySchema[], - contextFields: IContextFieldDto[], otherProjectFeatures: string[], + contextFields?: IContextFieldDto[], ) { const errors: ImportTogglesValidateItemSchema[] = []; @@ -441,7 +459,7 @@ export default class ExportImportService { affectedItems: strategies.map((strategy) => strategy.name), }); } - if (contextFields.length > 0) { + if (Array.isArray(contextFields) && contextFields.length > 0) { errors.push({ message: '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, user: User, ): Promise { - const requiredImportPermission = [ - CREATE_FEATURE, - UPDATE_FEATURE, - DELETE_FEATURE_STRATEGY, - CREATE_FEATURE_STRATEGY, - UPDATE_FEATURE_ENVIRONMENT_VARIANTS, - ]; const [newTagTypes, newContextFields] = await Promise.all([ this.getNewTagTypes(dto), this.getNewContextFields(dto), ]); - const permissions = [...requiredImportPermission]; + const permissions = [UPDATE_FEATURE]; if (newTagTypes.length > 0) { permissions.push(UPDATE_TAG_TYPE); } - if (newContextFields.length > 0) { + if (Array.isArray(newContextFields) && newContextFields.length > 0) { 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 = await this.importTogglesStore.getDisplayPermissions(permissions); @@ -603,9 +648,10 @@ export default class ExportImportService { const existingTagTypes = (await this.tagTypeService.getAll()).map( (tagType) => tagType.name, ); - const newTagTypes = dto.data.tagTypes?.filter( - (tagType) => !existingTagTypes.includes(tagType.name), - ); + const newTagTypes = + dto.data.tagTypes?.filter( + (tagType) => !existingTagTypes.includes(tagType.name), + ) || []; const uniqueTagTypes = [ ...new Map(newTagTypes.map((item) => [item.name, item])).values(), ]; @@ -657,10 +703,10 @@ export default class ExportImportService { const filteredContextFields = contextFields.filter( (field) => featureEnvironments.some((featureEnv) => - featureEnv.variants.some( + featureEnv.variants?.some( (variant) => variant.stickiness === field.name || - variant.overrides.some( + variant.overrides?.some( (override) => override.contextName === field.name, ), @@ -677,7 +723,7 @@ export default class ExportImportService { ); const filteredSegments = segments.filter((segment) => featureStrategies.some((strategy) => - strategy.segments.includes(segment.id), + strategy.segments?.includes(segment.id), ), ); const filteredTagTypes = tagTypes.filter((tagType) => diff --git a/src/test/e2e/api/admin/export-import.e2e.test.ts b/src/lib/export-import-toggles/export-import.e2e.test.ts similarity index 85% rename from src/test/e2e/api/admin/export-import.e2e.test.ts rename to src/lib/export-import-toggles/export-import.e2e.test.ts index 578994acd3..d1645ccc9e 100644 --- a/src/test/e2e/api/admin/export-import.e2e.test.ts +++ b/src/lib/export-import-toggles/export-import.e2e.test.ts @@ -1,10 +1,11 @@ import { IUnleashTest, setupAppWithCustomConfig, -} from '../../helpers/test-helper'; -import dbInit, { ITestDb } from '../../helpers/database-init'; -import getLogger from '../../../fixtures/no-logger'; +} 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, FeatureToggleDTO, IEnvironmentStore, IEventStore, @@ -13,14 +14,15 @@ import { ISegment, IStrategyConfig, IVariant, -} from 'lib/types'; -import { DEFAULT_ENV } from '../../../../lib/util'; +} from '../types'; +import { DEFAULT_ENV } from '../util'; import { ContextFieldSchema, ImportTogglesSchema, -} from '../../../../lib/openapi'; -import User from '../../../../lib/types/user'; -import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store'; + VariantsSchema, +} from '../openapi'; +import User from '../types/user'; +import { IContextFieldDto } from '../types/stores/context-field-store'; let app: IUnleashTest; let db: ITestDb; @@ -85,35 +87,30 @@ const createContext = async (context: ContextFieldSchema = defaultContext) => { .expect(201); }; -const createVariants = async ( - project: string, - feature: string, - environment: string, - variants: IVariant[], -) => { +const createVariants = async (feature: string, variants: IVariant[]) => { await app.services.featureToggleService.saveVariantsOnEnv( - project, + DEFAULT_PROJECT, feature, - environment, + DEFAULT_ENV, variants, new User({ id: 1 }), ); }; -const createProject = async (project: string, environment: string) => { +const createProject = async () => { await db.stores.environmentStore.create({ - name: environment, + name: DEFAULT_ENV, type: 'production', }); await db.stores.projectStore.create({ - name: project, + name: DEFAULT_PROJECT, description: '', - id: project, + id: DEFAULT_PROJECT, }); await app.request - .post(`/api/admin/projects/${project}/environments`) + .post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`) .send({ - environment, + environment: DEFAULT_ENV, }) .expect(200); }; @@ -128,9 +125,9 @@ const createContextField = async (contextField: IContextFieldDto) => { 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 - .post(`/api/admin/projects/${project}/features`) + .post(`/api/admin/projects/${DEFAULT_PROJECT}/features`) .send({ name: featureName, }) @@ -138,9 +135,11 @@ const createFeature = async (featureName: string, project: string) => { .expect(201); }; -const archiveFeature = async (featureName: string, project: string) => { +const archiveFeature = async (featureName: string) => { await app.request - .delete(`/api/admin/projects/${project}/features/${featureName}`) + .delete( + `/api/admin/projects/${DEFAULT_PROJECT}/features/${featureName}`, + ) .set('Content-Type', 'application/json') .expect(202); }; @@ -188,7 +187,7 @@ afterAll(async () => { test('exports features', async () => { const segmentName = 'my-segment'; - await createProject('default', 'default'); + await createProject(); const segment = await createSegment({ name: segmentName, constraints: [] }); const strategy = { name: 'default', @@ -249,7 +248,7 @@ test('exports features', async () => { }); test('should export custom context fields from strategies and variants', async () => { - await createProject('default', 'default'); + await createProject(); const strategyContext = { name: 'strategy-context', legalValues: [ @@ -301,7 +300,7 @@ test('should export custom context fields from strategies and variants', async ( }; await createContext(variantStickinessContext); await createContext(variantOverridesContext); - await createVariants('default', 'first_feature', 'default', [ + await createVariants('first_feature', [ { name: 'irrelevant', weight: 1000, @@ -351,7 +350,7 @@ test('should export custom context fields from strategies and variants', async ( test('should export tags', async () => { const featureName = 'first_feature'; - await createProject('default', 'default'); + await createProject(); await createToggle( { name: featureName, @@ -390,7 +389,7 @@ test('should export tags', async () => { }); test('returns no features, when no feature was requested', async () => { - await createProject('default', 'default'); + await createProject(); await createToggle({ name: 'first_feature', description: 'the #1 feature', @@ -424,34 +423,31 @@ const importToggles = ( .expect(expect); const defaultFeature = 'first_feature'; -const defaultProject = 'default'; -const defaultEnvironment = 'defalt'; -const variants: ImportTogglesSchema['data']['featureEnvironments'][0]['variants'] = - [ - { - name: 'variantA', - weight: 500, - payload: { - type: 'string', - value: 'payloadA', - }, - overrides: [], - stickiness: 'default', - weightType: 'variable', +const variants: VariantsSchema = [ + { + name: 'variantA', + weight: 500, + payload: { + type: 'string', + value: 'payloadA', }, - { - name: 'variantB', - weight: 500, - payload: { - type: 'string', - value: 'payloadB', - }, - overrides: [], - stickiness: 'default', - weightType: 'variable', + overrides: [], + stickiness: 'default', + weightType: 'variable', + }, + { + name: 'variantB', + weight: 500, + payload: { + type: 'string', + value: 'payloadB', }, - ]; + overrides: [], + stickiness: 'default', + weightType: 'variable', + }, +]; const exportedFeature: ImportTogglesSchema['data']['features'][0] = { project: 'old_project', name: 'first_feature', @@ -522,21 +518,17 @@ const defaultImportPayload: ImportTogglesSchema = { contextFields: [], segments: [], }, - project: defaultProject, - environment: defaultEnvironment, + project: DEFAULT_PROJECT, + environment: DEFAULT_ENV, }; const getFeature = async (feature: string) => app.request.get(`/api/admin/features/${feature}`).expect(200); -const getFeatureEnvironment = ( - project: string, - feature: string, - environment: string, -) => +const getFeatureEnvironment = (feature: string) => app.request .get( - `/api/admin/projects/${project}/features/${feature}/environments/${environment}`, + `/api/admin/projects/${DEFAULT_PROJECT}/features/${feature}/environments/${DEFAULT_ENV}`, ) .expect(200); @@ -551,25 +543,23 @@ const validateImport = (importPayload: ImportTogglesSchema, status = 200) => .expect(status); test('import features to existing project and environment', async () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); await importToggles(defaultImportPayload); const { body: importedFeature } = await getFeature(defaultFeature); expect(importedFeature).toMatchObject({ name: 'first_feature', - project: defaultProject, + project: DEFAULT_PROJECT, variants, }); const { body: importedFeatureEnvironment } = await getFeatureEnvironment( - defaultProject, defaultFeature, - defaultEnvironment, ); expect(importedFeatureEnvironment).toMatchObject({ name: defaultFeature, - environment: defaultEnvironment, + environment: DEFAULT_ENV, enabled: true, 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 () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); await importToggles(defaultImportPayload); await importToggles(defaultImportPayload); const { body: importedFeature } = await getFeature(defaultFeature); expect(importedFeature).toMatchObject({ name: 'first_feature', - project: defaultProject, + project: DEFAULT_PROJECT, variants, }); const { body: importedFeatureEnvironment } = await getFeatureEnvironment( - defaultProject, defaultFeature, - defaultEnvironment, ); expect(importedFeatureEnvironment).toMatchObject({ name: defaultFeature, - environment: defaultEnvironment, + environment: DEFAULT_ENV, enabled: true, 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 () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); const contextField = { name: 'ContextField1', legalValues: [{ value: 'Value1', description: '' }], @@ -654,12 +642,14 @@ test('reject import with unknown context fields', async () => { }); test('reject import with unsupported strategies', async () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); const importPayloadWithContextFields: ImportTogglesSchema = { ...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 () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); const contextField: IContextFieldDto = { name: 'validate_context_field', legalValues: [{ value: 'Value1' }], @@ -686,8 +676,8 @@ test('validate import data', async () => { legalValues: [{ value: 'new_value' }], }; - await createFeature(defaultFeature, defaultProject); - await archiveFeature(defaultFeature, defaultProject); + await createFeature(defaultFeature); + await archiveFeature(defaultFeature); await createContextField(contextField); const importPayloadWithContextFields: ImportTogglesSchema = { @@ -733,7 +723,7 @@ test('validate import data', async () => { }); test('should create new context', async () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); const context = { name: 'create-new-context', legalValues: [{ value: 'Value1' }], @@ -753,10 +743,10 @@ test('should create new context', async () => { }); test('should not import archived features tags', async () => { - await createProject(defaultProject, defaultEnvironment); + await createProject(); await importToggles(defaultImportPayload); - await archiveFeature(defaultFeature, defaultProject); + await archiveFeature(defaultFeature); await importToggles({ ...defaultImportPayload, diff --git a/src/lib/export-import-toggles/import-toggles-store-type.ts b/src/lib/export-import-toggles/import-toggles-store-type.ts index 52daeb8972..1277e8e61b 100644 --- a/src/lib/export-import-toggles/import-toggles-store-type.ts +++ b/src/lib/export-import-toggles/import-toggles-store-type.ts @@ -13,7 +13,14 @@ export interface IImportTogglesStore { deleteTagsForFeatures(tags: string[]): Promise; + strategiesExistForFeatures( + featureNames: string[], + environment: string, + ): Promise; + getDisplayPermissions( names: string[], ): Promise<{ name: string; displayName: string }[]>; + + getExistingFeatures(featureNames: string[]): Promise; } diff --git a/src/lib/export-import-toggles/import-toggles-store.ts b/src/lib/export-import-toggles/import-toggles-store.ts index 93cfce34ae..8561954e8f 100644 --- a/src/lib/export-import-toggles/import-toggles-store.ts +++ b/src/lib/export-import-toggles/import-toggles-store.ts @@ -4,7 +4,7 @@ import { Knex } from 'knex'; const T = { featureStrategies: 'feature_strategies', features: 'features', - feature_tag: 'feature_tag', + featureTag: 'feature_tag', }; export class ImportTogglesStore implements IImportTogglesStore { private db: Knex; @@ -35,6 +35,20 @@ export class ImportTogglesStore implements IImportTogglesStore { .del(); } + async strategiesExistForFeatures( + featureNames: string[], + environment: string, + ): Promise { + 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 { const rows = await this.db(T.features) .select('name') @@ -43,6 +57,11 @@ export class ImportTogglesStore implements IImportTogglesStore { return rows.map((row) => row.name); } + async getExistingFeatures(featureNames: string[]): Promise { + const rows = await this.db(T.features).whereIn('name', featureNames); + return rows.map((row) => row.name); + } + async getFeaturesInOtherProjects( featureNames: string[], project: string, @@ -54,7 +73,7 @@ export class ImportTogglesStore implements IImportTogglesStore { return rows.map((row) => ({ name: row.name, project: row.project })); } - async deleteTagsForFeatures(tags: string[]): Promise { - return this.db(T.feature_tag).whereIn('feature_name', tags).del(); + async deleteTagsForFeatures(features: string[]): Promise { + return this.db(T.featureTag).whereIn('feature_name', features).del(); } } diff --git a/src/lib/types/partial.ts b/src/lib/types/partial.ts index d00e93cbfc..a77af316f8 100644 --- a/src/lib/types/partial.ts +++ b/src/lib/types/partial.ts @@ -8,3 +8,5 @@ export type PartialDeep = T extends object // Mark one or more properties as optional. export type PartialSome = Pick, K> & Omit; + +export type WithRequired = T & { [P in K]-?: T[P] }; diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 69ea5d0d54..752b70e8fc 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -70,8 +70,9 @@ export async function setupAppWithCustomConfig( export async function setupAppWithAuth( stores: IUnleashStores, customOptions?: any, + db?: Db, ): Promise { - return createApp(stores, IAuthType.DEMO, undefined, customOptions); + return createApp(stores, IAuthType.DEMO, undefined, customOptions, db); } export async function setupAppWithCustomAuth( diff --git a/tsconfig.json b/tsconfig.json index bc401bce09..0454acc4f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ /* 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. */ - // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */