diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 7981566790..aba5ce3aa0 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -145,11 +145,6 @@ export const Project = () => { setModalOpen(true)} tooltipProps={{ title: 'Import' }} data-testid={IMPORT_BUTTON} @@ -159,34 +154,42 @@ export const Project = () => { } /> - - navigate(`/projects/${projectId}/edit`) + + navigate( + `/projects/${projectId}/edit` + ) + } + tooltipProps={{ title: 'Edit project' }} + data-loading + > + + } - tooltipProps={{ title: 'Edit project' }} - data-loading - > - - - { - setShowDelDialog(true); - }} - tooltipProps={{ title: 'Delete project' }} - data-loading - > - - + /> + { + setShowDelDialog(true); + }} + tooltipProps={{ + title: 'Delete project', + }} + data-loading + > + + + } + /> { + const { eventBus, getLogger } = config; + const eventStore = new EventStore(db, getLogger); + const groupStore = new GroupStore(db); + const accountStore = new AccountStore(db, getLogger); + const roleStore = new RoleStore(db, eventBus, getLogger); + const environmentStore = new EnvironmentStore(db, eventBus, getLogger); + const accessStore = new AccessStore(db, eventBus, getLogger); + const groupService = new GroupService( + { groupStore, eventStore, accountStore }, + { getLogger }, + ); + + return new AccessService( + { accessStore, accountStore, roleStore, environmentStore }, + { getLogger }, + groupService, + ); +}; + +export const createFakeAccessService = ( + config: IUnleashConfig, +): AccessService => { + const { getLogger } = config; + const eventStore = new FakeEventStore(); + const groupStore = new FakeGroupStore(); + const accountStore = new FakeAccountStore(); + const roleStore = new FakeRoleStore(); + const environmentStore = new FakeEnvironmentStore(); + const accessStore = new FakeAccessStore(); + const groupService = new GroupService( + { groupStore, eventStore, accountStore }, + { getLogger }, + ); + + return new AccessService( + { accessStore, accountStore, roleStore, environmentStore }, + { getLogger }, + groupService, + ); +}; diff --git a/src/lib/app.ts b/src/lib/app.ts index 2be2c17110..232c8549ed 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -169,7 +169,7 @@ export default async function getApp( } // Setup API routes - app.use(`${baseUriPath}/`, new IndexRouter(config, services).router); + app.use(`${baseUriPath}/`, new IndexRouter(config, services, db).router); if (services.openApiService) { services.openApiService.useErrorHandler(app); diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 5c8ff3e13a..ac7a18be1a 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -36,6 +36,7 @@ import { FavoriteProjectsStore } from './favorite-projects-store'; import { AccountStore } from './account-store'; import ProjectStatsStore from './project-stats-store'; import { Db } from './db'; +import { ImportTogglesStore } from '../export-import-toggles/import-toggles-store'; export const createStores = ( config: IUnleashConfig, @@ -115,6 +116,7 @@ export const createStores = ( getLogger, ), projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger), + importTogglesStore: new ImportTogglesStore(db), }; }; diff --git a/src/lib/db/transaction.ts b/src/lib/db/transaction.ts new file mode 100644 index 0000000000..7e8150fdb7 --- /dev/null +++ b/src/lib/db/transaction.ts @@ -0,0 +1,22 @@ +import { Knex } from 'knex'; + +export type KnexTransaction = Knex.Transaction; + +export type MockTransaction = null; + +export type UnleashTransaction = KnexTransaction | MockTransaction; + +export type TransactionCreator = ( + scope: (trx: S) => void | Promise, +) => Promise; + +export const createKnexTransactionStarter = ( + knex: Knex, +): TransactionCreator => { + function transaction( + scope: (trx: KnexTransaction) => void | Promise, + ) { + return knex.transaction(scope); + } + return transaction; +}; diff --git a/src/lib/export-import-toggles/export-import-controller.ts b/src/lib/export-import-toggles/export-import-controller.ts new file mode 100644 index 0000000000..5e1cbb1c97 --- /dev/null +++ b/src/lib/export-import-toggles/export-import-controller.ts @@ -0,0 +1,177 @@ +import { Response } from 'express'; +import { Knex } from 'knex'; +import Controller from '../routes/controller'; +import { Logger } from '../logger'; +import ExportImportService from './export-import-service'; +import { OpenApiService } from '../services'; +import { TransactionCreator, UnleashTransaction } from '../db/transaction'; +import { + IUnleashConfig, + IUnleashServices, + NONE, + serializeDates, +} from '../types'; +import { + createRequestSchema, + createResponseSchema, + emptyResponse, + ExportQuerySchema, + exportResultSchema, + ImportTogglesSchema, + importTogglesValidateSchema, +} from '../openapi'; +import { IAuthRequest } from '../routes/unleash-types'; +import { extractUsername } from '../util'; +import { InvalidOperationError } from '../error'; + +class ExportImportController extends Controller { + private logger: Logger; + + private exportImportService: ExportImportService; + + private transactionalExportImportService: ( + db: Knex.Transaction, + ) => ExportImportService; + + private openApiService: OpenApiService; + + private readonly startTransaction: TransactionCreator; + + constructor( + config: IUnleashConfig, + { + exportImportService, + transactionalExportImportService, + openApiService, + }: Pick< + IUnleashServices, + | 'exportImportService' + | 'openApiService' + | 'transactionalExportImportService' + >, + startTransaction: TransactionCreator, + ) { + super(config); + this.logger = config.getLogger('/admin-api/export-import.ts'); + this.exportImportService = exportImportService; + this.transactionalExportImportService = + transactionalExportImportService; + this.startTransaction = startTransaction; + this.openApiService = openApiService; + this.route({ + method: 'post', + path: '/export', + permission: NONE, + handler: this.export, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'exportFeatures', + requestBody: createRequestSchema('exportQuerySchema'), + responses: { + 200: createResponseSchema('exportResultSchema'), + }, + }), + ], + }); + this.route({ + method: 'post', + path: '/full-validate', + permission: NONE, + handler: this.validateImport, + middleware: [ + openApiService.validPath({ + summary: + 'Validate import of feature toggles for an environment in the project', + description: `Unleash toggles exported from a different instance can be imported into a new project and environment`, + tags: ['Unstable'], + operationId: 'validateImport', + requestBody: createRequestSchema('importTogglesSchema'), + responses: { + 200: createResponseSchema( + 'importTogglesValidateSchema', + ), + }, + }), + ], + }); + this.route({ + method: 'post', + path: '/full-import', + permission: NONE, + handler: this.importData, + middleware: [ + openApiService.validPath({ + summary: + 'Import feature toggles for an environment in the project', + description: `Unleash toggles exported from a different instance can be imported into a new project and environment`, + tags: ['Unstable'], + operationId: 'importToggles', + requestBody: createRequestSchema('importTogglesSchema'), + responses: { + 200: emptyResponse, + }, + }), + ], + }); + } + + async export( + req: IAuthRequest, + res: Response, + ): Promise { + this.verifyExportImportEnabled(); + const query = req.body; + const userName = extractUsername(req); + const data = await this.exportImportService.export(query, userName); + + this.openApiService.respondWithValidation( + 200, + res, + exportResultSchema.$id, + serializeDates(data), + ); + } + + async validateImport( + req: IAuthRequest, + res: Response, + ): Promise { + this.verifyExportImportEnabled(); + const dto = req.body; + const { user } = req; + const validation = await this.startTransaction(async (tx) => + this.transactionalExportImportService(tx).validate(dto, user), + ); + + this.openApiService.respondWithValidation( + 200, + res, + importTogglesValidateSchema.$id, + validation, + ); + } + + async importData( + req: IAuthRequest, + res: Response, + ): Promise { + this.verifyExportImportEnabled(); + const dto = req.body; + const { user } = req; + await this.startTransaction(async (tx) => + this.transactionalExportImportService(tx).import(dto, user), + ); + + res.status(200).end(); + } + + private verifyExportImportEnabled() { + if (!this.config.flagResolver.isEnabled('featuresExportImport')) { + throw new InvalidOperationError( + 'Feature export/import is not enabled', + ); + } + } +} +export default ExportImportController; diff --git a/src/lib/export-import-toggles/export-import-service.ts b/src/lib/export-import-toggles/export-import-service.ts new file mode 100644 index 0000000000..9aa9dce2a3 --- /dev/null +++ b/src/lib/export-import-toggles/export-import-service.ts @@ -0,0 +1,744 @@ +import { IUnleashConfig } from '../types/option'; +import { + FeatureToggleDTO, + IFeatureStrategy, + IFeatureStrategySegment, + IVariant, +} from '../types/model'; +import { Logger } from '../logger'; +import { IFeatureTagStore } from '../types/stores/feature-tag-store'; +import { ITagTypeStore } from '../types/stores/tag-type-store'; +import { IEventStore } from '../types/stores/event-store'; +import { IStrategy } from '../types/stores/strategy-store'; +import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; +import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; +import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; +import { IContextFieldStore, IUnleashStores } from '../types/stores'; +import { ISegmentStore } from '../types/stores/segment-store'; +import { ExportQuerySchema } from '../openapi/spec/export-query-schema'; +import { + CREATE_CONTEXT_FIELD, + CREATE_FEATURE, + CREATE_FEATURE_STRATEGY, + DELETE_FEATURE_STRATEGY, + FEATURES_EXPORTED, + FEATURES_IMPORTED, + IFlagResolver, + IUnleashServices, + UPDATE_FEATURE, + UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + UPDATE_TAG_TYPE, +} from '../types'; +import { + ExportResultSchema, + FeatureStrategySchema, + ImportTogglesValidateItemSchema, + ImportTogglesValidateSchema, +} from '../openapi'; +import { ImportTogglesSchema } from '../openapi/spec/import-toggles-schema'; +import User from '../types/user'; +import { IContextFieldDto } from '../types/stores/context-field-store'; +import { BadDataError, InvalidOperationError } from '../error'; +import { extractUsernameFromUser } from '../util'; +import { + AccessService, + ContextService, + FeatureTagService, + FeatureToggleService, + StrategyService, + TagTypeService, +} from '../services'; +import { isValidField } from './import-context-validation'; +import { IImportTogglesStore } from './import-toggles-store-type'; + +export default class ExportImportService { + private logger: Logger; + + private toggleStore: IFeatureToggleStore; + + private featureStrategiesStore: IFeatureStrategiesStore; + + private eventStore: IEventStore; + + private importTogglesStore: IImportTogglesStore; + + private tagTypeStore: ITagTypeStore; + + private featureEnvironmentStore: IFeatureEnvironmentStore; + + private featureTagStore: IFeatureTagStore; + + private segmentStore: ISegmentStore; + + private flagResolver: IFlagResolver; + + private featureToggleService: FeatureToggleService; + + private contextFieldStore: IContextFieldStore; + + private strategyService: StrategyService; + + private contextService: ContextService; + + private accessService: AccessService; + + private tagTypeService: TagTypeService; + + private featureTagService: FeatureTagService; + + constructor( + stores: Pick< + IUnleashStores, + | 'importTogglesStore' + | 'eventStore' + | 'featureStrategiesStore' + | 'featureToggleStore' + | 'featureEnvironmentStore' + | 'tagTypeStore' + | 'featureTagStore' + | 'segmentStore' + | 'contextFieldStore' + >, + { + getLogger, + flagResolver, + }: Pick, + { + featureToggleService, + strategyService, + contextService, + accessService, + tagTypeService, + featureTagService, + }: Pick< + IUnleashServices, + | 'featureToggleService' + | 'strategyService' + | 'contextService' + | 'accessService' + | 'tagTypeService' + | 'featureTagService' + >, + ) { + this.eventStore = stores.eventStore; + this.toggleStore = stores.featureToggleStore; + this.importTogglesStore = stores.importTogglesStore; + this.featureStrategiesStore = stores.featureStrategiesStore; + this.featureEnvironmentStore = stores.featureEnvironmentStore; + this.tagTypeStore = stores.tagTypeStore; + this.featureTagStore = stores.featureTagStore; + this.segmentStore = stores.segmentStore; + this.flagResolver = flagResolver; + this.featureToggleService = featureToggleService; + this.contextFieldStore = stores.contextFieldStore; + this.strategyService = strategyService; + this.contextService = contextService; + this.accessService = accessService; + this.tagTypeService = tagTypeService; + this.featureTagService = featureTagService; + this.logger = getLogger('services/state-service.js'); + } + + async validate( + dto: ImportTogglesSchema, + user: User, + ): Promise { + const [ + unsupportedStrategies, + usedCustomStrategies, + unsupportedContextFields, + archivedFeatures, + otherProjectFeatures, + missingPermissions, + ] = await Promise.all([ + this.getUnsupportedStrategies(dto), + this.getUsedCustomStrategies(dto), + this.getUnsupportedContextFields(dto), + this.getArchivedFeatures(dto), + this.getOtherProjectFeatures(dto), + this.getMissingPermissions(dto, user), + ]); + + const errors = this.compileErrors( + dto.project, + unsupportedStrategies, + unsupportedContextFields, + otherProjectFeatures, + ); + const warnings = this.compileWarnings( + usedCustomStrategies, + archivedFeatures, + ); + const permissions = this.compilePermissionErrors(missingPermissions); + + return { + errors, + warnings, + permissions, + }; + } + + async import(dto: ImportTogglesSchema, user: User): Promise { + const cleanedDto = await this.cleanData(dto); + + await Promise.all([ + this.verifyStrategies(cleanedDto), + this.verifyContextFields(cleanedDto), + this.verifyPermissions(dto, user), + this.verifyFeatures(dto), + ]); + await this.createToggles(cleanedDto, user); + await this.importToggleVariants(dto, user); + await this.importTagTypes(cleanedDto, user); + await this.importTags(cleanedDto, user); + await this.importContextFields(dto, user); + + await this.importDefault(cleanedDto, user); + await this.eventStore.store({ + project: cleanedDto.project, + environment: cleanedDto.environment, + type: FEATURES_IMPORTED, + createdBy: extractUsernameFromUser(user), + }); + } + + private async importDefault(dto: ImportTogglesSchema, user: User) { + await this.deleteStrategies(dto); + await this.importStrategies(dto, user); + await this.importToggleStatuses(dto, user); + } + + private async importToggleStatuses(dto: ImportTogglesSchema, user: User) { + await Promise.all( + dto.data.featureEnvironments?.map((featureEnvironment) => + this.featureToggleService.updateEnabled( + dto.project, + featureEnvironment.name, + dto.environment, + featureEnvironment.enabled, + extractUsernameFromUser(user), + user, + ), + ), + ); + } + + private async importStrategies(dto: ImportTogglesSchema, user: User) { + 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), + ), + ), + ); + } + + private async deleteStrategies(dto: ImportTogglesSchema) { + return this.importTogglesStore.deleteStrategiesForFeatures( + dto.data.features.map((feature) => feature.name), + dto.environment, + ); + } + + private async importTags(dto: ImportTogglesSchema, user: User) { + await this.importTogglesStore.deleteTagsForFeatures( + dto.data.features.map((feature) => feature.name), + ); + return Promise.all( + dto.data.featureTags?.map((tag) => + this.featureTagService.addTag( + tag.featureName, + { type: tag.tagType, value: tag.tagValue }, + extractUsernameFromUser(user), + ), + ), + ); + } + + private async importContextFields(dto: ImportTogglesSchema, user: User) { + const newContextFields = await this.getNewContextFields(dto); + await Promise.all( + newContextFields.map((contextField) => + this.contextService.createContextField( + { + name: contextField.name, + description: contextField.description, + legalValues: contextField.legalValues, + stickiness: contextField.stickiness, + }, + extractUsernameFromUser(user), + ), + ), + ); + } + + private async importTagTypes(dto: ImportTogglesSchema, user: User) { + const newTagTypes = await this.getNewTagTypes(dto); + return Promise.all( + newTagTypes.map((tagType) => + this.tagTypeService.createTagType( + tagType, + extractUsernameFromUser(user), + ), + ), + ); + } + + private async importToggleVariants(dto: ImportTogglesSchema, user: User) { + const featureEnvsWithVariants = dto.data.featureEnvironments?.filter( + (featureEnvironment) => featureEnvironment.variants?.length > 0, + ); + await Promise.all( + featureEnvsWithVariants.map((featureEnvironment) => + this.featureToggleService.saveVariantsOnEnv( + dto.project, + featureEnvironment.featureName, + dto.environment, + featureEnvironment.variants as IVariant[], + user, + ), + ), + ); + } + + private async createToggles(dto: ImportTogglesSchema, user: User) { + await Promise.all( + dto.data.features.map((feature) => + this.featureToggleService + .validateName(feature.name) + .then(() => { + const { archivedAt, createdAt, ...rest } = feature; + return this.featureToggleService.createFeatureToggle( + dto.project, + rest as FeatureToggleDTO, + extractUsernameFromUser(user), + ); + }) + .catch(() => {}), + ), + ); + } + + private async verifyContextFields(dto: ImportTogglesSchema) { + const unsupportedContextFields = await this.getUnsupportedContextFields( + dto, + ); + if (unsupportedContextFields.length > 0) { + throw new BadDataError( + `Context fields with errors: ${unsupportedContextFields + .map((field) => field.name) + .join(', ')}`, + ); + } + } + + private async verifyPermissions(dto: ImportTogglesSchema, user: User) { + const missingPermissions = await this.getMissingPermissions(dto, user); + if (missingPermissions.length > 0) { + throw new InvalidOperationError( + 'You are missing permissions to import', + ); + } + } + + private async verifyFeatures(dto: ImportTogglesSchema) { + const otherProjectFeatures = await this.getOtherProjectFeatures(dto); + if (otherProjectFeatures.length > 0) { + throw new BadDataError( + `These features exist already in other projects: ${otherProjectFeatures.join( + ', ', + )}`, + ); + } + } + + private async cleanData(dto: ImportTogglesSchema) { + const removedFeaturesDto = await this.removeArchivedFeatures(dto); + const remappedDto = this.remapSegments(removedFeaturesDto); + return remappedDto; + } + + private async remapSegments(dto: ImportTogglesSchema) { + return { + ...dto, + data: { + ...dto.data, + featureStrategies: dto.data.featureStrategies.map( + (strategy) => ({ + ...strategy, + segments: [], + }), + ), + }, + }; + } + + private async removeArchivedFeatures(dto: ImportTogglesSchema) { + const archivedFeatures = await this.getArchivedFeatures(dto); + const featureTags = dto.data.featureTags.filter( + (tag) => !archivedFeatures.includes(tag.featureName), + ); + return { + ...dto, + data: { + ...dto.data, + features: dto.data.features.filter( + (feature) => !archivedFeatures.includes(feature.name), + ), + featureEnvironments: dto.data.featureEnvironments.filter( + (environment) => + !archivedFeatures.includes(environment.featureName), + ), + featureStrategies: dto.data.featureStrategies.filter( + (strategy) => + !archivedFeatures.includes(strategy.featureName), + ), + featureTags, + tagTypes: dto.data.tagTypes?.filter((tagType) => + featureTags + .map((tag) => tag.tagType) + .includes(tagType.name), + ), + }, + }; + } + + private async verifyStrategies(dto: ImportTogglesSchema) { + const unsupportedStrategies = await this.getUnsupportedStrategies(dto); + if (unsupportedStrategies.length > 0) { + throw new BadDataError( + `Unsupported strategies: ${unsupportedStrategies + .map((strategy) => strategy.name) + .join(', ')}`, + ); + } + } + + private compileErrors( + projectName: string, + strategies: FeatureStrategySchema[], + contextFields: IContextFieldDto[], + otherProjectFeatures: string[], + ) { + const errors: ImportTogglesValidateItemSchema[] = []; + + if (strategies.length > 0) { + errors.push({ + message: + 'We detected the following custom strategy in the import file that needs to be created first:', + affectedItems: strategies.map((strategy) => strategy.name), + }); + } + if (contextFields.length > 0) { + errors.push({ + message: + 'We detected the following context fields that do not have matching legal values with the imported ones:', + affectedItems: contextFields.map( + (contextField) => contextField.name, + ), + }); + } + if (otherProjectFeatures.length > 0) { + errors.push({ + message: `You cannot import a features that already exist in other projects. You already have the following features defined outside of project ${projectName}:`, + affectedItems: otherProjectFeatures, + }); + } + + return errors; + } + + private compileWarnings( + usedCustomStrategies: string[], + archivedFeatures: string[], + ) { + const warnings: ImportTogglesValidateItemSchema[] = []; + if (usedCustomStrategies.length > 0) { + warnings.push({ + message: + 'The following strategy types will be used in import. Please make sure the strategy type parameters are configured as in source environment:', + affectedItems: usedCustomStrategies, + }); + } + if (archivedFeatures.length > 0) { + warnings.push({ + message: + 'The following features will not be imported as they are currently archived. To import them, please unarchive them first:', + affectedItems: archivedFeatures, + }); + } + return warnings; + } + + private compilePermissionErrors(missingPermissions: string[]) { + const errors: ImportTogglesValidateItemSchema[] = []; + if (missingPermissions.length > 0) { + errors.push({ + message: + 'We detected you are missing the following permissions:', + affectedItems: missingPermissions, + }); + } + + return errors; + } + + private async getUnsupportedStrategies( + dto: ImportTogglesSchema, + ): Promise { + const supportedStrategies = await this.strategyService.getStrategies(); + return dto.data.featureStrategies.filter( + (featureStrategy) => + !supportedStrategies.find( + (strategy) => featureStrategy.name === strategy.name, + ), + ); + } + + private async getUsedCustomStrategies(dto: ImportTogglesSchema) { + const supportedStrategies = await this.strategyService.getStrategies(); + const uniqueFeatureStrategies = [ + ...new Set( + dto.data.featureStrategies.map((strategy) => strategy.name), + ), + ]; + return uniqueFeatureStrategies.filter( + this.isCustomStrategy(supportedStrategies), + ); + } + + isCustomStrategy = ( + supportedStrategies: IStrategy[], + ): ((x: string) => boolean) => { + const customStrategies = supportedStrategies + .filter((s) => s.editable) + .map((strategy) => strategy.name); + return (featureStrategy) => customStrategies.includes(featureStrategy); + }; + + private async getUnsupportedContextFields(dto: ImportTogglesSchema) { + const availableContextFields = await this.contextService.getAll(); + + return dto.data.contextFields?.filter( + (contextField) => + !isValidField(contextField, availableContextFields), + ); + } + + private async getArchivedFeatures(dto: ImportTogglesSchema) { + return this.importTogglesStore.getArchivedFeatures( + dto.data.features.map((feature) => feature.name), + ); + } + + private async getOtherProjectFeatures(dto: ImportTogglesSchema) { + const otherProjectsFeatures = + await this.importTogglesStore.getFeaturesInOtherProjects( + dto.data.features.map((feature) => feature.name), + dto.project, + ); + return otherProjectsFeatures.map( + (it) => `${it.name} (in project ${it.project})`, + ); + } + + private async getMissingPermissions( + 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]; + if (newTagTypes.length > 0) { + permissions.push(UPDATE_TAG_TYPE); + } + if (newContextFields.length > 0) { + permissions.push(CREATE_CONTEXT_FIELD); + } + + const displayPermissions = + await this.importTogglesStore.getDisplayPermissions(permissions); + + const results = await Promise.all( + displayPermissions.map((permission) => + this.accessService + .hasPermission( + user, + permission.name, + dto.project, + dto.environment, + ) + .then( + (hasPermission) => [permission, hasPermission] as const, + ), + ), + ); + return results + .filter(([, hasAccess]) => !hasAccess) + .map(([permission]) => permission.displayName); + } + + private async getNewTagTypes(dto: ImportTogglesSchema) { + const existingTagTypes = (await this.tagTypeService.getAll()).map( + (tagType) => tagType.name, + ); + const newTagTypes = dto.data.tagTypes?.filter( + (tagType) => !existingTagTypes.includes(tagType.name), + ); + const uniqueTagTypes = [ + ...new Map(newTagTypes.map((item) => [item.name, item])).values(), + ]; + return uniqueTagTypes; + } + + private async getNewContextFields(dto: ImportTogglesSchema) { + const availableContextFields = await this.contextService.getAll(); + + return dto.data.contextFields?.filter( + (contextField) => + !availableContextFields.some( + (availableField) => + availableField.name === contextField.name, + ), + ); + } + + async export( + query: ExportQuerySchema, + userName: string, + ): Promise { + const [ + features, + featureEnvironments, + featureStrategies, + strategySegments, + contextFields, + featureTags, + segments, + tagTypes, + ] = await Promise.all([ + this.toggleStore.getAllByNames(query.features), + await this.featureEnvironmentStore.getAllByFeatures( + query.features, + query.environment, + ), + this.featureStrategiesStore.getAllByFeatures( + query.features, + query.environment, + ), + this.segmentStore.getAllFeatureStrategySegments(), + this.contextFieldStore.getAll(), + this.featureTagStore.getAllByFeatures(query.features), + this.segmentStore.getAll(), + this.tagTypeStore.getAll(), + ]); + this.addSegmentsToStrategies(featureStrategies, strategySegments); + const filteredContextFields = contextFields.filter( + (field) => + featureEnvironments.some((featureEnv) => + featureEnv.variants.some( + (variant) => + variant.stickiness === field.name || + variant.overrides.some( + (override) => + override.contextName === field.name, + ), + ), + ) || + featureStrategies.some( + (strategy) => + strategy.parameters.stickiness === field.name || + strategy.constraints.some( + (constraint) => + constraint.contextName === field.name, + ), + ), + ); + const filteredSegments = segments.filter((segment) => + featureStrategies.some((strategy) => + strategy.segments.includes(segment.id), + ), + ); + const filteredTagTypes = tagTypes.filter((tagType) => + featureTags.map((tag) => tag.tagType).includes(tagType.name), + ); + const result = { + features: features.map((item) => { + const { createdAt, archivedAt, lastSeenAt, ...rest } = item; + return rest; + }), + featureStrategies: featureStrategies.map((item) => { + const name = item.strategyName; + const { + createdAt, + projectId, + environment, + strategyName, + ...rest + } = item; + return { + name, + ...rest, + }; + }), + featureEnvironments: featureEnvironments.map((item) => ({ + ...item, + name: item.featureName, + })), + contextFields: filteredContextFields.map((item) => { + const { createdAt, ...rest } = item; + return rest; + }), + featureTags, + segments: filteredSegments.map((item) => { + const { id, name } = item; + return { id, name }; + }), + tagTypes: filteredTagTypes, + }; + await this.eventStore.store({ + type: FEATURES_EXPORTED, + createdBy: userName, + data: result, + }); + + return result; + } + + addSegmentsToStrategies( + featureStrategies: IFeatureStrategy[], + strategySegments: IFeatureStrategySegment[], + ): void { + featureStrategies.forEach((featureStrategy) => { + featureStrategy.segments = strategySegments + .filter( + (segment) => + segment.featureStrategyId === featureStrategy.id, + ) + .map((segment) => segment.segmentId); + }); + } +} + +module.exports = ExportImportService; diff --git a/src/lib/export-import-toggles/import-context-validation.test.ts b/src/lib/export-import-toggles/import-context-validation.test.ts new file mode 100644 index 0000000000..27ed908b7e --- /dev/null +++ b/src/lib/export-import-toggles/import-context-validation.test.ts @@ -0,0 +1,33 @@ +import { isValidField } from './import-context-validation'; + +test('has value context field', () => { + expect( + isValidField( + { name: 'contextField', legalValues: [{ value: 'value1' }] }, + [{ name: 'contextField', legalValues: [{ value: 'value1' }] }], + ), + ).toBe(true); +}); + +test('no matching field value', () => { + expect( + isValidField( + { name: 'contextField', legalValues: [{ value: 'value1' }] }, + [{ name: 'contextField', legalValues: [{ value: 'value2' }] }], + ), + ).toBe(false); +}); + +test('subset field value', () => { + expect( + isValidField( + { name: 'contextField', legalValues: [{ value: 'value1' }] }, + [ + { + name: 'contextField', + legalValues: [{ value: 'value2' }, { value: 'value1' }], + }, + ], + ), + ).toBe(true); +}); diff --git a/src/lib/export-import-toggles/import-context-validation.ts b/src/lib/export-import-toggles/import-context-validation.ts new file mode 100644 index 0000000000..b96e016d5f --- /dev/null +++ b/src/lib/export-import-toggles/import-context-validation.ts @@ -0,0 +1,16 @@ +import { IContextFieldDto } from '../types/stores/context-field-store'; + +export const isValidField = ( + importedField: IContextFieldDto, + existingFields: IContextFieldDto[], +): boolean => { + const matchingExistingField = existingFields.find( + (field) => field.name === importedField.name, + ); + if (!matchingExistingField) { + return true; + } + return importedField.legalValues.every((value) => + matchingExistingField.legalValues.find((v) => v.value === value.value), + ); +}; diff --git a/src/lib/export-import-toggles/import-toggles-store-type.ts b/src/lib/export-import-toggles/import-toggles-store-type.ts new file mode 100644 index 0000000000..52daeb8972 --- /dev/null +++ b/src/lib/export-import-toggles/import-toggles-store-type.ts @@ -0,0 +1,19 @@ +export interface IImportTogglesStore { + deleteStrategiesForFeatures( + featureNames: string[], + environment: string, + ): Promise; + + getArchivedFeatures(featureNames: string[]): Promise; + + getFeaturesInOtherProjects( + featureNames: string[], + project: string, + ): Promise<{ name: string; project: string }[]>; + + deleteTagsForFeatures(tags: string[]): Promise; + + getDisplayPermissions( + names: string[], + ): Promise<{ name: string; displayName: string }[]>; +} diff --git a/src/lib/export-import-toggles/import-toggles-store.ts b/src/lib/export-import-toggles/import-toggles-store.ts new file mode 100644 index 0000000000..93cfce34ae --- /dev/null +++ b/src/lib/export-import-toggles/import-toggles-store.ts @@ -0,0 +1,60 @@ +import { IImportTogglesStore } from './import-toggles-store-type'; +import { Knex } from 'knex'; + +const T = { + featureStrategies: 'feature_strategies', + features: 'features', + feature_tag: 'feature_tag', +}; +export class ImportTogglesStore implements IImportTogglesStore { + private db: Knex; + + constructor(db: Knex) { + this.db = db; + } + + async getDisplayPermissions( + names: string[], + ): Promise<{ name: string; displayName: string }[]> { + const rows = await this.db + .from('permissions') + .whereIn('permission', names); + return rows.map((row) => ({ + name: row.permission, + displayName: row.display_name, + })); + } + + async deleteStrategiesForFeatures( + featureNames: string[], + environment: string, + ): Promise { + return this.db(T.featureStrategies) + .where({ environment }) + .whereIn('feature_name', featureNames) + .del(); + } + + async getArchivedFeatures(featureNames: string[]): Promise { + const rows = await this.db(T.features) + .select('name') + .whereNot('archived_at', null) + .whereIn('name', featureNames); + return rows.map((row) => row.name); + } + + async getFeaturesInOtherProjects( + featureNames: string[], + project: string, + ): Promise<{ name: string; project: string }[]> { + const rows = await this.db(T.features) + .select(['name', 'project']) + .whereNot('project', project) + .whereIn('name', featureNames); + 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(); + } +} diff --git a/src/lib/export-import-toggles/index.ts b/src/lib/export-import-toggles/index.ts new file mode 100644 index 0000000000..f65c3285df --- /dev/null +++ b/src/lib/export-import-toggles/index.ts @@ -0,0 +1,192 @@ +import { Db } from '../db/db'; +import { IUnleashConfig } from '../types'; +import ExportImportService from './export-import-service'; +import { ImportTogglesStore } from './import-toggles-store'; +import FeatureToggleStore from '../db/feature-toggle-store'; +import TagStore from '../db/tag-store'; +import TagTypeStore from '../db/tag-type-store'; +import ProjectStore from '../db/project-store'; +import FeatureTagStore from '../db/feature-tag-store'; +import StrategyStore from '../db/strategy-store'; +import ContextFieldStore from '../db/context-field-store'; +import EventStore from '../db/event-store'; +import FeatureStrategiesStore from '../db/feature-strategy-store'; +import { + ContextService, + FeatureTagService, + StrategyService, + TagTypeService, +} from '../services'; +import { createAccessService, createFakeAccessService } from '../access'; +import { + createFakeFeatureToggleService, + createFeatureToggleService, +} from '../feature-toggle'; +import SegmentStore from '../db/segment-store'; +import { FeatureEnvironmentStore } from '../db/feature-environment-store'; +import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store'; +import FakeTagStore from '../../test/fixtures/fake-tag-store'; +import FakeTagTypeStore from '../../test/fixtures/fake-tag-type-store'; +import FakeSegmentStore from '../../test/fixtures/fake-segment-store'; +import FakeProjectStore from '../../test/fixtures/fake-project-store'; +import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store'; +import FakeContextFieldStore from '../../test/fixtures/fake-context-field-store'; +import FakeEventStore from '../../test/fixtures/fake-event-store'; +import FakeFeatureStrategiesStore from '../../test/fixtures/fake-feature-strategies-store'; +import FakeFeatureEnvironmentStore from '../../test/fixtures/fake-feature-environment-store'; +import FakeStrategiesStore from '../../test/fixtures/fake-strategies-store'; + +export const createFakeExportImportTogglesService = ( + config: IUnleashConfig, +): ExportImportService => { + const { getLogger } = config; + const importTogglesStore = {} as ImportTogglesStore; + const featureToggleStore = new FakeFeatureToggleStore(); + const tagStore = new FakeTagStore(); + const tagTypeStore = new FakeTagTypeStore(); + const segmentStore = new FakeSegmentStore(); + const projectStore = new FakeProjectStore(); + const featureTagStore = new FakeFeatureTagStore(); + const strategyStore = new FakeStrategiesStore(); + const contextFieldStore = new FakeContextFieldStore(); + const eventStore = new FakeEventStore(); + const featureStrategiesStore = new FakeFeatureStrategiesStore(); + const featureEnvironmentStore = new FakeFeatureEnvironmentStore(); + const accessService = createFakeAccessService(config); + const featureToggleService = createFakeFeatureToggleService(config); + + const featureTagService = new FeatureTagService( + { + tagStore, + featureTagStore, + eventStore, + featureToggleStore, + }, + { getLogger }, + ); + const contextService = new ContextService( + { + projectStore, + eventStore, + contextFieldStore, + }, + { getLogger }, + ); + const strategyService = new StrategyService( + { strategyStore, eventStore }, + { getLogger }, + ); + const tagTypeService = new TagTypeService( + { tagTypeStore, eventStore }, + { getLogger }, + ); + const exportImportService = new ExportImportService( + { + eventStore, + importTogglesStore, + featureStrategiesStore, + contextFieldStore, + featureToggleStore, + featureTagStore, + segmentStore, + tagTypeStore, + featureEnvironmentStore, + }, + config, + { + featureToggleService, + featureTagService, + accessService, + contextService, + strategyService, + tagTypeService, + }, + ); + + return exportImportService; +}; + +export const createExportImportTogglesService = ( + db: Db, + config: IUnleashConfig, +): ExportImportService => { + const { eventBus, getLogger, flagResolver } = config; + const importTogglesStore = new ImportTogglesStore(db); + const featureToggleStore = new FeatureToggleStore(db, eventBus, getLogger); + const tagStore = new TagStore(db, eventBus, getLogger); + const tagTypeStore = new TagTypeStore(db, eventBus, getLogger); + const segmentStore = new SegmentStore(db, eventBus, getLogger); + const projectStore = new ProjectStore( + db, + eventBus, + getLogger, + flagResolver, + ); + const featureTagStore = new FeatureTagStore(db, eventBus, getLogger); + const strategyStore = new StrategyStore(db, getLogger); + const contextFieldStore = new ContextFieldStore(db, getLogger); + const eventStore = new EventStore(db, getLogger); + const featureStrategiesStore = new FeatureStrategiesStore( + db, + eventBus, + getLogger, + flagResolver, + ); + const featureEnvironmentStore = new FeatureEnvironmentStore( + db, + eventBus, + getLogger, + ); + const accessService = createAccessService(db, config); + const featureToggleService = createFeatureToggleService(db, config); + + const featureTagService = new FeatureTagService( + { + tagStore, + featureTagStore, + eventStore, + featureToggleStore, + }, + { getLogger }, + ); + const contextService = new ContextService( + { + projectStore, + eventStore, + contextFieldStore, + }, + { getLogger }, + ); + const strategyService = new StrategyService( + { strategyStore, eventStore }, + { getLogger }, + ); + const tagTypeService = new TagTypeService( + { tagTypeStore, eventStore }, + { getLogger }, + ); + const exportImportService = new ExportImportService( + { + eventStore, + importTogglesStore, + featureStrategiesStore, + contextFieldStore, + featureToggleStore, + featureTagStore, + segmentStore, + tagTypeStore, + featureEnvironmentStore, + }, + config, + { + featureToggleService, + featureTagService, + accessService, + contextService, + strategyService, + tagTypeService, + }, + ); + + return exportImportService; +}; diff --git a/src/lib/feature-toggle/index.ts b/src/lib/feature-toggle/index.ts new file mode 100644 index 0000000000..5a3769a829 --- /dev/null +++ b/src/lib/feature-toggle/index.ts @@ -0,0 +1,155 @@ +import { + AccessService, + FeatureToggleService, + GroupService, + SegmentService, +} from '../services'; +import EventStore from '../db/event-store'; +import FeatureStrategiesStore from '../db/feature-strategy-store'; +import FeatureToggleStore from '../db/feature-toggle-store'; +import FeatureToggleClientStore from '../db/feature-toggle-client-store'; +import ProjectStore from '../db/project-store'; +import FeatureTagStore from '../db/feature-tag-store'; +import { FeatureEnvironmentStore } from '../db/feature-environment-store'; +import SegmentStore from '../db/segment-store'; +import ContextFieldStore from '../db/context-field-store'; +import GroupStore from '../db/group-store'; +import { AccountStore } from '../db/account-store'; +import { AccessStore } from '../db/access-store'; +import RoleStore from '../db/role-store'; +import EnvironmentStore from '../db/environment-store'; +import { Db } from '../db/db'; +import { IUnleashConfig } from '../types'; +import FakeEventStore from '../../test/fixtures/fake-event-store'; +import FakeFeatureStrategiesStore from '../../test/fixtures/fake-feature-strategies-store'; +import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store'; +import FakeFeatureToggleClientStore from '../../test/fixtures/fake-feature-toggle-client-store'; +import FakeProjectStore from '../../test/fixtures/fake-project-store'; +import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store'; +import FakeFeatureEnvironmentStore from '../../test/fixtures/fake-feature-environment-store'; +import FakeSegmentStore from '../../test/fixtures/fake-segment-store'; +import FakeContextFieldStore from '../../test/fixtures/fake-context-field-store'; +import FakeGroupStore from '../../test/fixtures/fake-group-store'; +import { FakeAccountStore } from '../../test/fixtures/fake-account-store'; +import FakeAccessStore from '../../test/fixtures/fake-access-store'; +import FakeRoleStore from '../../test/fixtures/fake-role-store'; +import FakeEnvironmentStore from '../../test/fixtures/fake-environment-store'; + +export const createFeatureToggleService = ( + db: Db, + config: IUnleashConfig, +): FeatureToggleService => { + const { getLogger, eventBus, flagResolver } = config; + const eventStore = new EventStore(db, getLogger); + const featureStrategiesStore = new FeatureStrategiesStore( + db, + eventBus, + getLogger, + flagResolver, + ); + const featureToggleStore = new FeatureToggleStore(db, eventBus, getLogger); + const featureToggleClientStore = new FeatureToggleClientStore( + db, + eventBus, + getLogger, + config.inlineSegmentConstraints, + flagResolver, + ); + const projectStore = new ProjectStore( + db, + eventBus, + getLogger, + flagResolver, + ); + const featureTagStore = new FeatureTagStore(db, eventBus, getLogger); + const featureEnvironmentStore = new FeatureEnvironmentStore( + db, + eventBus, + getLogger, + ); + const segmentStore = new SegmentStore(db, eventBus, getLogger); + const contextFieldStore = new ContextFieldStore(db, getLogger); + const groupStore = new GroupStore(db); + const accountStore = new AccountStore(db, getLogger); + const accessStore = new AccessStore(db, eventBus, getLogger); + const roleStore = new RoleStore(db, eventBus, getLogger); + const environmentStore = new EnvironmentStore(db, eventBus, getLogger); + const groupService = new GroupService( + { groupStore, eventStore, accountStore }, + { getLogger }, + ); + const accessService = new AccessService( + { accessStore, accountStore, roleStore, environmentStore }, + { getLogger }, + groupService, + ); + const segmentService = new SegmentService( + { segmentStore, featureStrategiesStore, eventStore }, + config, + ); + const featureToggleService = new FeatureToggleService( + { + featureStrategiesStore, + featureToggleStore, + featureToggleClientStore, + projectStore, + eventStore, + featureTagStore, + featureEnvironmentStore, + contextFieldStore, + }, + { getLogger, flagResolver }, + segmentService, + accessService, + ); + return featureToggleService; +}; + +export const createFakeFeatureToggleService = ( + config: IUnleashConfig, +): FeatureToggleService => { + const { getLogger, flagResolver } = config; + const eventStore = new FakeEventStore(); + const featureStrategiesStore = new FakeFeatureStrategiesStore(); + const featureToggleStore = new FakeFeatureToggleStore(); + const featureToggleClientStore = new FakeFeatureToggleClientStore(); + const projectStore = new FakeProjectStore(); + const featureTagStore = new FakeFeatureTagStore(); + const featureEnvironmentStore = new FakeFeatureEnvironmentStore(); + const segmentStore = new FakeSegmentStore(); + const contextFieldStore = new FakeContextFieldStore(); + const groupStore = new FakeGroupStore(); + const accountStore = new FakeAccountStore(); + const accessStore = new FakeAccessStore(); + const roleStore = new FakeRoleStore(); + const environmentStore = new FakeEnvironmentStore(); + const groupService = new GroupService( + { groupStore, eventStore, accountStore }, + { getLogger }, + ); + const accessService = new AccessService( + { accessStore, accountStore, roleStore, environmentStore }, + { getLogger }, + groupService, + ); + const segmentService = new SegmentService( + { segmentStore, featureStrategiesStore, eventStore }, + config, + ); + const featureToggleService = new FeatureToggleService( + { + featureStrategiesStore, + featureToggleStore, + featureToggleClientStore, + projectStore, + eventStore, + featureTagStore, + featureEnvironmentStore, + contextFieldStore, + }, + { getLogger, flagResolver }, + segmentService, + accessService, + ); + return featureToggleService; +}; diff --git a/src/lib/internals.ts b/src/lib/internals.ts index 985024012c..afd3c85967 100644 --- a/src/lib/internals.ts +++ b/src/lib/internals.ts @@ -13,3 +13,6 @@ export * from './services'; export * from './types'; export * from './util'; export * from './error'; +export * from './access'; +export * from './export-import-toggles'; +export * from './feature-toggle'; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 24436bf6f2..713038d3b0 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -128,6 +128,9 @@ import { variantsSchema, versionSchema, projectOverviewSchema, + importTogglesSchema, + importTogglesValidateSchema, + importTogglesValidateItemSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -271,6 +274,9 @@ export const schemas = { variantsSchema, versionSchema, projectOverviewSchema, + importTogglesSchema, + importTogglesValidateSchema, + importTogglesValidateItemSchema, }; // Schemas must have an $id property on the form "#/components/schemas/mySchema". diff --git a/src/lib/openapi/spec/import-toggles-schema.ts b/src/lib/openapi/spec/import-toggles-schema.ts new file mode 100644 index 0000000000..ca82dd8062 --- /dev/null +++ b/src/lib/openapi/spec/import-toggles-schema.ts @@ -0,0 +1,53 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { exportResultSchema } from './export-result-schema'; +import { featureSchema } from './feature-schema'; +import { featureStrategySchema } from './feature-strategy-schema'; +import { contextFieldSchema } from './context-field-schema'; +import { featureTagSchema } from './feature-tag-schema'; +import { segmentSchema } from './segment-schema'; +import { variantsSchema } from './variants-schema'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; +import { constraintSchema } from './constraint-schema'; +import { parametersSchema } from './parameters-schema'; +import { legalValueSchema } from './legal-value-schema'; +import { tagTypeSchema } from './tag-type-schema'; +import { featureEnvironmentSchema } from './feature-environment-schema'; + +export const importTogglesSchema = { + $id: '#/components/schemas/importTogglesSchema', + type: 'object', + required: ['project', 'environment', 'data'], + additionalProperties: false, + properties: { + project: { + type: 'string', + }, + environment: { + type: 'string', + }, + data: { + $ref: '#/components/schemas/exportResultSchema', + }, + }, + components: { + schemas: { + exportResultSchema, + featureSchema, + featureStrategySchema, + featureEnvironmentSchema, + contextFieldSchema, + featureTagSchema, + segmentSchema, + variantsSchema, + variantSchema, + overrideSchema, + constraintSchema, + parametersSchema, + legalValueSchema, + tagTypeSchema, + }, + }, +} as const; + +export type ImportTogglesSchema = FromSchema; diff --git a/src/lib/openapi/spec/import-toggles-validate-item-schema.ts b/src/lib/openapi/spec/import-toggles-validate-item-schema.ts new file mode 100644 index 0000000000..5b43cbea2a --- /dev/null +++ b/src/lib/openapi/spec/import-toggles-validate-item-schema.ts @@ -0,0 +1,26 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const importTogglesValidateItemSchema = { + $id: '#/components/schemas/importTogglesValidateItemSchema', + type: 'object', + required: ['message', 'affectedItems'], + additionalProperties: false, + properties: { + message: { + type: 'string', + }, + affectedItems: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type ImportTogglesValidateItemSchema = FromSchema< + typeof importTogglesValidateItemSchema +>; diff --git a/src/lib/openapi/spec/import-toggles-validate-schema.ts b/src/lib/openapi/spec/import-toggles-validate-schema.ts new file mode 100644 index 0000000000..4b5883e70e --- /dev/null +++ b/src/lib/openapi/spec/import-toggles-validate-schema.ts @@ -0,0 +1,38 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { importTogglesValidateItemSchema } from './import-toggles-validate-item-schema'; + +export const importTogglesValidateSchema = { + $id: '#/components/schemas/importTogglesValidateSchema', + type: 'object', + required: ['errors', 'warnings'], + additionalProperties: false, + properties: { + errors: { + type: 'array', + items: { + $ref: '#/components/schemas/importTogglesValidateItemSchema', + }, + }, + warnings: { + type: 'array', + items: { + $ref: '#/components/schemas/importTogglesValidateItemSchema', + }, + }, + permissions: { + type: 'array', + items: { + $ref: '#/components/schemas/importTogglesValidateItemSchema', + }, + }, + }, + components: { + schemas: { + importTogglesValidateItemSchema, + }, + }, +} as const; + +export type ImportTogglesValidateSchema = FromSchema< + typeof importTogglesValidateSchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 3b72892fa2..d08c8da12f 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -127,3 +127,6 @@ export * from './export-query-schema'; export * from './push-variants-schema'; export * from './project-stats-schema'; export * from './project-overview-schema'; +export * from './import-toggles-validate-item-schema'; +export * from './import-toggles-validate-schema'; +export * from './import-toggles-schema'; diff --git a/src/lib/routes/admin-api/export-import.ts b/src/lib/routes/admin-api/export-import.ts deleted file mode 100644 index 08692f9f9f..0000000000 --- a/src/lib/routes/admin-api/export-import.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Response } from 'express'; -import Controller from '../controller'; -import { NONE } from '../../types/permissions'; -import { IUnleashConfig } from '../../types/option'; -import { IUnleashServices } from '../../types/services'; -import { Logger } from '../../logger'; -import { OpenApiService } from '../../services/openapi-service'; -import ExportImportService from 'lib/services/export-import-service'; -import { InvalidOperationError } from '../../error'; -import { createRequestSchema, createResponseSchema } from '../../openapi'; -import { exportResultSchema } from '../../openapi/spec/export-result-schema'; -import { ExportQuerySchema } from '../../openapi/spec/export-query-schema'; -import { serializeDates } from '../../types'; -import { IAuthRequest } from '../unleash-types'; -import { format as formatDate } from 'date-fns'; -import { extractUsername } from '../../util'; - -class ExportImportController extends Controller { - private logger: Logger; - - private exportImportService: ExportImportService; - - private openApiService: OpenApiService; - - constructor( - config: IUnleashConfig, - { - exportImportService, - openApiService, - }: Pick, - ) { - super(config); - this.logger = config.getLogger('/admin-api/export-import.ts'); - this.exportImportService = exportImportService; - this.openApiService = openApiService; - this.route({ - method: 'post', - path: '/export', - permission: NONE, - handler: this.export, - middleware: [ - this.openApiService.validPath({ - tags: ['Unstable'], - operationId: 'exportFeatures', - requestBody: createRequestSchema('exportQuerySchema'), - responses: { - 200: createResponseSchema('exportResultSchema'), - }, - }), - ], - }); - } - - async export( - req: IAuthRequest, - res: Response, - ): Promise { - this.verifyExportImportEnabled(); - const query = req.body; - const userName = extractUsername(req); - const data = await this.exportImportService.export(query, userName); - - this.openApiService.respondWithValidation( - 200, - res, - exportResultSchema.$id, - serializeDates(data), - ); - } - - private getFormattedDate(millis: number): string { - return formatDate(millis, 'yyyy-MM-dd_HH-mm-ss'); - } - - private verifyExportImportEnabled() { - if (!this.config.flagResolver.isEnabled('featuresExportImport')) { - throw new InvalidOperationError( - 'Feature export/import is not enabled', - ); - } - } -} -export default ExportImportController; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index f272c7c2f3..6bffa9c8e3 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -28,10 +28,12 @@ import { PublicSignupController } from './public-signup'; import InstanceAdminController from './instance-admin'; import FavoritesController from './favorites'; import MaintenanceController from './maintenance'; -import ExportImportController from './export-import'; +import { createKnexTransactionStarter } from '../../db/transaction'; +import { Db } from '../../db/db'; +import ExportImportController from '../../export-import-toggles/export-import-controller'; class AdminApi extends Controller { - constructor(config: IUnleashConfig, services: IUnleashServices) { + constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { super(config); this.app.use( @@ -80,7 +82,11 @@ class AdminApi extends Controller { this.app.use('/state', new StateController(config, services).router); this.app.use( '/features-batch', - new ExportImportController(config, services).router, + new ExportImportController( + config, + services, + createKnexTransactionStarter(db), + ).router, ); this.app.use('/tags', new TagController(config, services).router); this.app.use( diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 276946608a..3cee6a9805 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -13,9 +13,10 @@ import ProxyController from './proxy-api'; import { conditionalMiddleware } from '../middleware'; import EdgeController from './edge-api'; import { PublicInviteController } from './public-invite'; +import { Db } from '../db/db'; class IndexRouter extends Controller { - constructor(config: IUnleashConfig, services: IUnleashServices) { + constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { super(config); this.use('/health', new HealthCheckController(config, services).router); @@ -40,7 +41,7 @@ class IndexRouter extends Controller { new ResetPasswordController(config, services).router, ); - this.use('/api/admin', new AdminApi(config, services).router); + this.use('/api/admin', new AdminApi(config, services, db).router); this.use('/api/client', new ClientApi(config, services).router); this.use( diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts index 7e3b7d903d..d67fd3b20c 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -42,7 +42,7 @@ async function createApp( const serverVersion = version; const db = createDb(config); const stores = createStores(config, db); - const services = createServices(stores, config); + const services = createServices(stores, config, db); scheduleServices(services, config); const metricsMonitor = createMetricsMonitor(); diff --git a/src/lib/services/export-import-service.ts b/src/lib/services/export-import-service.ts deleted file mode 100644 index 02f0c85713..0000000000 --- a/src/lib/services/export-import-service.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { IUnleashConfig } from '../types/option'; -import { IFeatureStrategy, IFeatureStrategySegment } from '../types/model'; -import { Logger } from '../logger'; -import { IFeatureTagStore } from '../types/stores/feature-tag-store'; -import { IProjectStore } from '../types/stores/project-store'; -import { ITagTypeStore } from '../types/stores/tag-type-store'; -import { ITagStore } from '../types/stores/tag-store'; -import { IEventStore } from '../types/stores/event-store'; -import { IStrategyStore } from '../types/stores/strategy-store'; -import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; -import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store'; -import { IEnvironmentStore } from '../types/stores/environment-store'; -import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; -import { IContextFieldStore, IUnleashStores } from '../types/stores'; -import { ISegmentStore } from '../types/stores/segment-store'; -import FeatureToggleService from './feature-toggle-service'; -import { ExportQuerySchema } from '../openapi/spec/export-query-schema'; -import { FEATURES_EXPORTED, IFlagResolver, IUnleashServices } from '../types'; -import { ExportResultSchema } from '../openapi'; - -export default class ExportImportService { - private logger: Logger; - - private toggleStore: IFeatureToggleStore; - - private featureStrategiesStore: IFeatureStrategiesStore; - - private strategyStore: IStrategyStore; - - private eventStore: IEventStore; - - private tagStore: ITagStore; - - private tagTypeStore: ITagTypeStore; - - private projectStore: IProjectStore; - - private featureEnvironmentStore: IFeatureEnvironmentStore; - - private featureTagStore: IFeatureTagStore; - - private environmentStore: IEnvironmentStore; - - private segmentStore: ISegmentStore; - - private flagResolver: IFlagResolver; - - private featureToggleService: FeatureToggleService; - - private contextFieldStore: IContextFieldStore; - - constructor( - stores: IUnleashStores, - { - getLogger, - flagResolver, - }: Pick, - { - featureToggleService, - }: Pick, - ) { - this.eventStore = stores.eventStore; - this.toggleStore = stores.featureToggleStore; - this.strategyStore = stores.strategyStore; - this.tagStore = stores.tagStore; - this.featureStrategiesStore = stores.featureStrategiesStore; - this.featureEnvironmentStore = stores.featureEnvironmentStore; - this.tagTypeStore = stores.tagTypeStore; - this.projectStore = stores.projectStore; - this.featureTagStore = stores.featureTagStore; - this.environmentStore = stores.environmentStore; - this.segmentStore = stores.segmentStore; - this.flagResolver = flagResolver; - this.featureToggleService = featureToggleService; - this.contextFieldStore = stores.contextFieldStore; - this.logger = getLogger('services/state-service.js'); - } - - async export( - query: ExportQuerySchema, - userName: string, - ): Promise { - const [ - features, - featureEnvironments, - featureStrategies, - strategySegments, - contextFields, - featureTags, - segments, - tagTypes, - ] = await Promise.all([ - this.toggleStore.getAllByNames(query.features), - await this.featureEnvironmentStore.getAllByFeatures( - query.features, - query.environment, - ), - this.featureStrategiesStore.getAllByFeatures( - query.features, - query.environment, - ), - this.segmentStore.getAllFeatureStrategySegments(), - this.contextFieldStore.getAll(), - this.featureTagStore.getAllByFeatures(query.features), - this.segmentStore.getAll(), - this.tagTypeStore.getAll(), - ]); - this.addSegmentsToStrategies(featureStrategies, strategySegments); - const filteredContextFields = contextFields.filter( - (field) => - featureEnvironments.some((featureEnv) => - featureEnv.variants.some( - (variant) => - variant.stickiness === field.name || - variant.overrides.some( - (override) => - override.contextName === field.name, - ), - ), - ) || - featureStrategies.some( - (strategy) => - strategy.parameters.stickiness === field.name || - strategy.constraints.some( - (constraint) => - constraint.contextName === field.name, - ), - ), - ); - const filteredSegments = segments.filter((segment) => - featureStrategies.some((strategy) => - strategy.segments.includes(segment.id), - ), - ); - const filteredTagTypes = tagTypes.filter((tagType) => - featureTags.map((tag) => tag.tagType).includes(tagType.name), - ); - const result = { - features: features.map((item) => { - const { createdAt, archivedAt, lastSeenAt, ...rest } = item; - return rest; - }), - featureStrategies: featureStrategies.map((item) => { - const name = item.strategyName; - const { - createdAt, - projectId, - environment, - strategyName, - ...rest - } = item; - return { - name, - ...rest, - }; - }), - featureEnvironments: featureEnvironments.map((item) => ({ - ...item, - name: item.featureName, - })), - contextFields: filteredContextFields.map((item) => { - const { createdAt, ...rest } = item; - return rest; - }), - featureTags, - segments: filteredSegments.map((item) => { - const { id, name } = item; - return { id, name }; - }), - tagTypes: filteredTagTypes, - }; - await this.eventStore.store({ - type: FEATURES_EXPORTED, - createdBy: userName, - data: result, - }); - - return result; - } - - addSegmentsToStrategies( - featureStrategies: IFeatureStrategy[], - strategySegments: IFeatureStrategySegment[], - ): void { - featureStrategies.forEach((featureStrategy) => { - featureStrategy.segments = strategySegments - .filter( - (segment) => - segment.featureStrategyId === featureStrategy.id, - ) - .map((segment) => segment.segmentId); - }); - } -} - -module.exports = ExportImportService; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index b368641625..7e337122c3 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -39,10 +39,15 @@ import { LastSeenService } from './client-metrics/last-seen-service'; import { InstanceStatsService } from './instance-stats-service'; import { FavoritesService } from './favorites-service'; import MaintenanceService from './maintenance-service'; -import ExportImportService from './export-import-service'; import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import { AccountService } from './account-service'; import { SchedulerService } from './scheduler-service'; +import { Knex } from 'knex'; +import { + createExportImportTogglesService, + createFakeExportImportTogglesService, +} from '../export-import-toggles'; +import { Db } from '../db/db'; // TODO: will be moved to scheduler feature directory export const scheduleServices = ( @@ -96,6 +101,7 @@ export const scheduleServices = ( export const createServices = ( stores: IUnleashStores, config: IUnleashConfig, + db?: Db, ): IUnleashServices => { const groupService = new GroupService(stores, config); const accessService = new AccessService(stores, config, groupService); @@ -139,9 +145,6 @@ export const createServices = ( segmentService, accessService, ); - const exportImportService = new ExportImportService(stores, config, { - featureToggleService: featureToggleServiceV2, - }); const environmentService = new EnvironmentService(stores, config); const featureTagService = new FeatureTagService(stores, config); const favoritesService = new FavoritesService(stores, config); @@ -158,6 +161,11 @@ export const createServices = ( config, projectService, ); + const exportImportService = db + ? createExportImportTogglesService(db, config) + : createFakeExportImportTogglesService(config); + const transactionalExportImportService = (txDb: Knex.Transaction) => + createExportImportTogglesService(txDb, config); const userSplashService = new UserSplashService(stores, config); const openApiService = new OpenApiService(config); const clientSpecService = new ClientSpecService(config); @@ -239,6 +247,7 @@ export const createServices = ( favoritesService, maintenanceService, exportImportService, + transactionalExportImportService, schedulerService, }; }; @@ -282,6 +291,5 @@ export { LastSeenService, InstanceStatsService, FavoritesService, - ExportImportService, SchedulerService, }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index cd7a31a16f..79944d3fce 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -37,9 +37,10 @@ import { LastSeenService } from '../services/client-metrics/last-seen-service'; import { InstanceStatsService } from '../services/instance-stats-service'; import { FavoritesService } from '../services/favorites-service'; import MaintenanceService from '../services/maintenance-service'; -import ExportImportService from 'lib/services/export-import-service'; +import ExportImportService from '../export-import-toggles/export-import-service'; import { AccountService } from '../services/account-service'; import { SchedulerService } from '../services/scheduler-service'; +import { Knex } from 'knex'; export interface IUnleashServices { accessService: AccessService; @@ -85,4 +86,7 @@ export interface IUnleashServices { maintenanceService: MaintenanceService; exportImportService: ExportImportService; schedulerService: SchedulerService; + transactionalExportImportService: ( + db: Knex.Transaction, + ) => ExportImportService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 20e01a39eb..61b27232fb 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -32,6 +32,7 @@ import { IFavoriteFeaturesStore } from './stores/favorite-features'; import { IFavoriteProjectsStore } from './stores/favorite-projects'; import { IAccountStore } from './stores/account-store'; import { IProjectStatsStore } from './stores/project-stats-store-type'; +import { IImportTogglesStore } from '../export-import-toggles/import-toggles-store-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -68,6 +69,7 @@ export interface IUnleashStores { favoriteFeaturesStore: IFavoriteFeaturesStore; favoriteProjectsStore: IFavoriteProjectsStore; projectStatsStore: IProjectStatsStore; + importTogglesStore: IImportTogglesStore; } export { @@ -104,4 +106,5 @@ export { IUserStore, IFavoriteFeaturesStore, IFavoriteProjectsStore, + IImportTogglesStore, }; diff --git a/src/test/e2e/api/admin/export-import.e2e.test.ts b/src/test/e2e/api/admin/export-import.e2e.test.ts index c4d63b7c92..578994acd3 100644 --- a/src/test/e2e/api/admin/export-import.e2e.test.ts +++ b/src/test/e2e/api/admin/export-import.e2e.test.ts @@ -15,8 +15,12 @@ import { IVariant, } from 'lib/types'; import { DEFAULT_ENV } from '../../../../lib/util'; -import { ContextFieldSchema } from '../../../../lib/openapi'; +import { + ContextFieldSchema, + ImportTogglesSchema, +} from '../../../../lib/openapi'; import User from '../../../../lib/types/user'; +import { IContextFieldDto } from '../../../../lib/types/stores/context-field-store'; let app: IUnleashTest; let db: ITestDb; @@ -120,15 +124,50 @@ const createSegment = (postData: object): Promise => { }); }; +const createContextField = async (contextField: IContextFieldDto) => { + await app.request.post(`/api/admin/context`).send(contextField).expect(201); +}; + +const createFeature = async (featureName: string, project: string) => { + await app.request + .post(`/api/admin/projects/${project}/features`) + .send({ + name: featureName, + }) + .set('Content-Type', 'application/json') + .expect(201); +}; + +const archiveFeature = async (featureName: string, project: string) => { + await app.request + .delete(`/api/admin/projects/${project}/features/${featureName}`) + .set('Content-Type', 'application/json') + .expect(202); +}; + +const unArchiveFeature = async (featureName: string) => { + await app.request + .post(`/api/admin/archive/revive/${featureName}`) + .set('Content-Type', 'application/json') + .expect(200); +}; + +const getContextField = (name: string) => + app.request.get(`/api/admin/context/${name}`).expect(200); + beforeAll(async () => { db = await dbInit('export_import_api_serial', getLogger); - app = await setupAppWithCustomConfig(db.stores, { - experimental: { - flags: { - featuresExportImport: true, + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + featuresExportImport: true, + }, }, }, - }); + db.rawDatabase, + ); eventStore = db.stores.eventStore; environmentStore = db.stores.environmentStore; projectStore = db.stores.projectStore; @@ -371,3 +410,371 @@ test('returns no features, when no feature was requested', async () => { expect(body.features).toHaveLength(0); }); + +const importToggles = ( + importPayload: ImportTogglesSchema, + status = 200, + expect: (response) => void = () => {}, +) => + app.request + .post('/api/admin/features-batch/full-import') + .send(importPayload) + .set('Content-Type', 'application/json') + .expect(status) + .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', + }, + { + 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', +}; +const constraints: ImportTogglesSchema['data']['featureStrategies'][0]['constraints'] = + [ + { + values: ['conduit'], + inverted: false, + operator: 'IN', + contextName: 'appName', + caseInsensitive: false, + }, + ]; +const exportedStrategy: ImportTogglesSchema['data']['featureStrategies'][0] = { + featureName: defaultFeature, + id: '798cb25a-2abd-47bd-8a95-40ec13472309', + name: 'default', + parameters: {}, + constraints, +}; + +const tags = [ + { + featureName: defaultFeature, + tagType: 'simple', + tagValue: 'tag1', + }, + { + featureName: defaultFeature, + tagType: 'simple', + tagValue: 'tag2', + }, + { + featureName: defaultFeature, + tagType: 'special_tag', + tagValue: 'feature_tagged', + }, +]; + +const resultTags = [ + { value: 'tag1', type: 'simple' }, + { value: 'tag2', type: 'simple' }, + { value: 'feature_tagged', type: 'special_tag' }, +]; + +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 defaultImportPayload: ImportTogglesSchema = { + data: { + features: [exportedFeature], + featureStrategies: [exportedStrategy], + featureEnvironments: [ + { + enabled: true, + environment: 'irrelevant', + featureName: defaultFeature, + name: defaultFeature, + variants, + }, + ], + featureTags: tags, + tagTypes, + contextFields: [], + segments: [], + }, + project: defaultProject, + environment: defaultEnvironment, +}; + +const getFeature = async (feature: string) => + app.request.get(`/api/admin/features/${feature}`).expect(200); + +const getFeatureEnvironment = ( + project: string, + feature: string, + environment: string, +) => + app.request + .get( + `/api/admin/projects/${project}/features/${feature}/environments/${environment}`, + ) + .expect(200); + +const getTags = (feature: string) => + app.request.get(`/api/admin/features/${feature}/tags`).expect(200); + +const validateImport = (importPayload: ImportTogglesSchema, status = 200) => + app.request + .post('/api/admin/features-batch/full-validate') + .send(importPayload) + .set('Content-Type', 'application/json') + .expect(status); + +test('import features to existing project and environment', async () => { + await createProject(defaultProject, defaultEnvironment); + + await importToggles(defaultImportPayload); + + const { body: importedFeature } = await getFeature(defaultFeature); + expect(importedFeature).toMatchObject({ + name: 'first_feature', + project: defaultProject, + variants, + }); + + const { body: importedFeatureEnvironment } = await getFeatureEnvironment( + defaultProject, + defaultFeature, + defaultEnvironment, + ); + expect(importedFeatureEnvironment).toMatchObject({ + name: defaultFeature, + environment: defaultEnvironment, + enabled: true, + strategies: [ + { + featureName: defaultFeature, + parameters: {}, + constraints, + sortOrder: 9999, + name: 'default', + }, + ], + }); + + const { body: importedTags } = await getTags(defaultFeature); + expect(importedTags).toMatchObject({ + tags: resultTags, + }); +}); + +test('importing same JSON should work multiple times in a row', async () => { + await createProject(defaultProject, defaultEnvironment); + await importToggles(defaultImportPayload); + await importToggles(defaultImportPayload); + + const { body: importedFeature } = await getFeature(defaultFeature); + expect(importedFeature).toMatchObject({ + name: 'first_feature', + project: defaultProject, + variants, + }); + + const { body: importedFeatureEnvironment } = await getFeatureEnvironment( + defaultProject, + defaultFeature, + defaultEnvironment, + ); + + expect(importedFeatureEnvironment).toMatchObject({ + name: defaultFeature, + environment: defaultEnvironment, + enabled: true, + strategies: [ + { + featureName: defaultFeature, + parameters: {}, + constraints, + sortOrder: 9999, + name: 'default', + }, + ], + }); +}); + +test('reject import with unknown context fields', async () => { + await createProject(defaultProject, defaultEnvironment); + const contextField = { + name: 'ContextField1', + legalValues: [{ value: 'Value1', description: '' }], + }; + await createContextField(contextField); + const importPayloadWithContextFields: ImportTogglesSchema = { + ...defaultImportPayload, + data: { + ...defaultImportPayload.data, + contextFields: [ + { + ...contextField, + legalValues: [{ value: 'Value2', description: '' }], + }, + ], + }, + }; + + const { body } = await importToggles(importPayloadWithContextFields, 400); + + expect(body).toMatchObject({ + details: [ + { + message: 'Context fields with errors: ContextField1', + }, + ], + }); +}); + +test('reject import with unsupported strategies', async () => { + await createProject(defaultProject, defaultEnvironment); + const importPayloadWithContextFields: ImportTogglesSchema = { + ...defaultImportPayload, + data: { + ...defaultImportPayload.data, + featureStrategies: [{ name: 'customStrategy' }], + }, + }; + + const { body } = await importToggles(importPayloadWithContextFields, 400); + + expect(body).toMatchObject({ + details: [ + { + message: 'Unsupported strategies: customStrategy', + }, + ], + }); +}); + +test('validate import data', async () => { + await createProject(defaultProject, defaultEnvironment); + const contextField: IContextFieldDto = { + name: 'validate_context_field', + legalValues: [{ value: 'Value1' }], + }; + + const createdContextField: IContextFieldDto = { + name: 'created_context_field', + legalValues: [{ value: 'new_value' }], + }; + + await createFeature(defaultFeature, defaultProject); + await archiveFeature(defaultFeature, defaultProject); + + await createContextField(contextField); + const importPayloadWithContextFields: ImportTogglesSchema = { + ...defaultImportPayload, + data: { + ...defaultImportPayload.data, + featureStrategies: [{ name: 'customStrategy' }], + segments: [{ id: 1, name: 'customSegment' }], + contextFields: [ + { + ...contextField, + legalValues: [{ value: 'Value2' }], + }, + createdContextField, + ], + }, + }; + + 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: [defaultFeature], + }, + ], + permissions: [], + }); +}); + +test('should create new context', async () => { + await createProject(defaultProject, defaultEnvironment); + const context = { + name: 'create-new-context', + legalValues: [{ value: 'Value1' }], + }; + const importPayloadWithContextFields: ImportTogglesSchema = { + ...defaultImportPayload, + data: { + ...defaultImportPayload.data, + contextFields: [context], + }, + }; + + await importToggles(importPayloadWithContextFields, 200); + + const { body } = await getContextField(context.name); + expect(body).toMatchObject(context); +}); + +test('should not import archived features tags', async () => { + await createProject(defaultProject, defaultEnvironment); + await importToggles(defaultImportPayload); + + await archiveFeature(defaultFeature, defaultProject); + + await importToggles({ + ...defaultImportPayload, + data: { + ...defaultImportPayload.data, + featureTags: [ + { + featureName: defaultFeature, + tagType: 'simple', + tagValue: 'tag2', + }, + ], + }, + }); + await unArchiveFeature(defaultFeature); + + const { body: importedTags } = await getTags(defaultFeature); + expect(importedTags).toMatchObject({ + tags: resultTags, + }); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 4bb2ce059b..3a1e50e5a6 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1874,6 +1874,73 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "importTogglesSchema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/exportResultSchema", + }, + "environment": { + "type": "string", + }, + "project": { + "type": "string", + }, + }, + "required": [ + "project", + "environment", + "data", + ], + "type": "object", + }, + "importTogglesValidateItemSchema": { + "additionalProperties": false, + "properties": { + "affectedItems": { + "items": { + "type": "string", + }, + "type": "array", + }, + "message": { + "type": "string", + }, + }, + "required": [ + "message", + "affectedItems", + ], + "type": "object", + }, + "importTogglesValidateSchema": { + "additionalProperties": false, + "properties": { + "errors": { + "items": { + "$ref": "#/components/schemas/importTogglesValidateItemSchema", + }, + "type": "array", + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/importTogglesValidateItemSchema", + }, + "type": "array", + }, + "warnings": { + "items": { + "$ref": "#/components/schemas/importTogglesValidateItemSchema", + }, + "type": "array", + }, + }, + "required": [ + "errors", + "warnings", + ], + "type": "object", + }, "instanceAdminStatsSchema": { "additionalProperties": false, "properties": { @@ -5062,6 +5129,65 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/features-batch/full-import": { + "post": { + "description": "Unleash toggles exported from a different instance can be imported into a new project and environment", + "operationId": "importToggles", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/importTogglesSchema", + }, + }, + }, + "description": "importTogglesSchema", + "required": true, + }, + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "summary": "Import feature toggles for an environment in the project", + "tags": [ + "Unstable", + ], + }, + }, + "/api/admin/features-batch/full-validate": { + "post": { + "description": "Unleash toggles exported from a different instance can be imported into a new project and environment", + "operationId": "validateImport", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/importTogglesSchema", + }, + }, + }, + "description": "importTogglesSchema", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/importTogglesValidateSchema", + }, + }, + }, + "description": "importTogglesValidateSchema", + }, + }, + "summary": "Validate import of feature toggles for an environment in the project", + "tags": [ + "Unstable", + ], + }, + }, "/api/admin/features/validate": { "post": { "operationId": "validateFeature", diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 65fc875f76..69ea5d0d54 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -9,6 +9,7 @@ import { createServices } from '../../../lib/services'; import sessionDb from '../../../lib/middleware/session-db'; import { IUnleashStores } from '../../../lib/types'; import { IUnleashServices } from '../../../lib/types/services'; +import { Db } from '../../../lib/db/db'; process.env.NODE_ENV = 'test'; @@ -24,6 +25,7 @@ async function createApp( adminAuthentication = IAuthType.NONE, preHook?: Function, customOptions?: any, + db?: Db, ): Promise { const config = createTestConfig({ authentication: { @@ -35,11 +37,11 @@ async function createApp( }, ...customOptions, }); - const services = createServices(stores, config); + const services = createServices(stores, config, db); const unleashSession = sessionDb(config, undefined); const emitter = new EventEmitter(); emitter.setMaxListeners(0); - const app = await getApp(config, stores, services, unleashSession); + const app = await getApp(config, stores, services, unleashSession, db); const request = supertest.agent(app); const destroy = async () => { @@ -60,8 +62,9 @@ export async function setupApp(stores: IUnleashStores): Promise { export async function setupAppWithCustomConfig( stores: IUnleashStores, customOptions: any, + db?: Db, ): Promise { - return createApp(stores, undefined, undefined, customOptions); + return createApp(stores, undefined, undefined, customOptions, db); } export async function setupAppWithAuth( diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 58f599b658..5e0bccd225 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -15,7 +15,7 @@ import FakeUserFeedbackStore from './fake-user-feedback-store'; import FakeFeatureTagStore from './fake-feature-tag-store'; import FakeEnvironmentStore from './fake-environment-store'; import FakeStrategiesStore from './fake-strategies-store'; -import { IUnleashStores } from '../../lib/types'; +import { IImportTogglesStore, IUnleashStores } from '../../lib/types'; import FakeSessionStore from './fake-session-store'; import FakeFeatureEnvironmentStore from './fake-feature-environment-store'; import FakeApiTokenStore from './fake-api-token-store'; @@ -34,13 +34,13 @@ import FakeFavoriteProjectsStore from './fake-favorite-projects-store'; import { FakeAccountStore } from './fake-account-store'; import FakeProjectStatsStore from './fake-project-stats-store'; -const createStores: () => IUnleashStores = () => { - const db = { - select: () => ({ - from: () => Promise.resolve(), - }), - }; +const db = { + select: () => ({ + from: () => Promise.resolve(), + }), +}; +const createStores: () => IUnleashStores = () => { return { db, clientApplicationsStore: new FakeClientApplicationsStore(), @@ -77,6 +77,7 @@ const createStores: () => IUnleashStores = () => { favoriteFeaturesStore: new FakeFavoriteFeaturesStore(), favoriteProjectsStore: new FakeFavoriteProjectsStore(), projectStatsStore: new FakeProjectStatsStore(), + importTogglesStore: {} as IImportTogglesStore, }; };