diff --git a/src/lib/features/project-environments/environment-service.ts b/src/lib/features/project-environments/environment-service.ts index 58b47e38c8..09a62c1fcb 100644 --- a/src/lib/features/project-environments/environment-service.ts +++ b/src/lib/features/project-environments/environment-service.ts @@ -16,7 +16,7 @@ import { import type { Logger } from '../../logger'; import { BadDataError, UNIQUE_CONSTRAINT_VIOLATION } from '../../error'; import NameExistsError from '../../error/name-exists-error'; -import { sortOrderSchema } from '../../services/state-schema'; +import { sortOrderSchema } from '../../services/sort-order-schema'; import NotFoundError from '../../error/notfound-error'; import type { IProjectStore } from '../../features/project/project-store-type'; import MinimumOneEnvironmentError from '../../error/minimum-one-environment-error'; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 5e24718c14..fdd1b04235 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -167,7 +167,6 @@ export * from './set-ui-config-schema'; export * from './sort-order-schema'; export * from './splash-request-schema'; export * from './splash-response-schema'; -export * from './state-schema'; export * from './strategies-schema'; export * from './strategy-schema'; export * from './strategy-variant-schema'; diff --git a/src/lib/openapi/spec/state-schema.ts b/src/lib/openapi/spec/state-schema.ts deleted file mode 100644 index e2dd4b8583..0000000000 --- a/src/lib/openapi/spec/state-schema.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { FromSchema } from 'json-schema-to-ts'; -import { featureSchema } from './feature-schema'; -import { tagSchema } from './tag-schema'; -import { tagTypeSchema } from './tag-type-schema'; -import { featureTagSchema } from './feature-tag-schema'; -import { projectSchema } from './project-schema'; -import { featureStrategySchema } from './feature-strategy-schema'; -import { featureEnvironmentSchema } from './feature-environment-schema'; -import { environmentSchema } from './environment-schema'; -import { segmentSchema } from './segment-schema'; -import { featureStrategySegmentSchema } from './feature-strategy-segment-schema'; -import { strategySchema } from './strategy-schema'; -import { strategyVariantSchema } from './strategy-variant-schema'; - -export const stateSchema = { - $id: '#/components/schemas/stateSchema', - type: 'object', - deprecated: true, - description: - 'The application state as used by the deprecated export/import APIs.', - required: ['version'], - properties: { - version: { - type: 'integer', - description: 'The version of the schema used to describe the state', - example: 1, - }, - features: { - type: 'array', - description: 'A list of features', - items: { - $ref: '#/components/schemas/featureSchema', - }, - }, - strategies: { - type: 'array', - description: 'A list of strategies', - items: { - $ref: '#/components/schemas/strategySchema', - }, - }, - tags: { - type: 'array', - description: 'A list of tags', - items: { - $ref: '#/components/schemas/tagSchema', - }, - }, - tagTypes: { - type: 'array', - description: 'A list of tag types', - items: { - $ref: '#/components/schemas/tagTypeSchema', - }, - }, - featureTags: { - type: 'array', - description: 'A list of tags applied to features', - items: { - $ref: '#/components/schemas/featureTagSchema', - }, - }, - projects: { - type: 'array', - description: 'A list of projects', - items: { - $ref: '#/components/schemas/projectSchema', - }, - }, - featureStrategies: { - type: 'array', - description: 'A list of feature strategies as applied to features', - items: { - $ref: '#/components/schemas/featureStrategySchema', - }, - }, - featureEnvironments: { - type: 'array', - description: 'A list of feature environment configurations', - items: { - $ref: '#/components/schemas/featureEnvironmentSchema', - }, - }, - environments: { - type: 'array', - description: 'A list of environments', - items: { - $ref: '#/components/schemas/environmentSchema', - }, - }, - segments: { - type: 'array', - description: 'A list of segments', - items: { - $ref: '#/components/schemas/segmentSchema', - }, - }, - featureStrategySegments: { - type: 'array', - description: 'A list of segment/strategy pairings', - items: { - $ref: '#/components/schemas/featureStrategySegmentSchema', - }, - }, - }, - components: { - schemas: { - featureSchema, - tagSchema, - tagTypeSchema, - featureTagSchema, - projectSchema, - featureStrategySchema, - strategyVariantSchema, - featureEnvironmentSchema, - environmentSchema, - segmentSchema, - featureStrategySegmentSchema, - strategySchema, - }, - }, -} as const; - -export type StateSchema = FromSchema; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 0feffdcbf0..907d510fe5 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -11,7 +11,6 @@ import UserController from './user/user'; import ConfigController from './config'; import { ContextController } from './context'; import ClientMetricsController from '../../features/metrics/client-metrics/client-metrics'; -import StateController from './state'; import TagController from './tag'; import TagTypeController from '../../features/tag-type/tag-type'; import AddonController from './addon'; @@ -89,7 +88,6 @@ export class AdminApi extends Controller { '/context', new ContextController(config, services).router, ); - this.app.use('/state', new StateController(config, services).router); this.app.use( '/features-batch', new ExportImportController(config, services).router, diff --git a/src/lib/routes/admin-api/state.ts b/src/lib/routes/admin-api/state.ts deleted file mode 100644 index d42f616b0b..0000000000 --- a/src/lib/routes/admin-api/state.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as mime from 'mime'; -import YAML from 'js-yaml'; -import multer from 'multer'; -import { format as formatDate } from 'date-fns'; -import type { Request, Response } from 'express'; -import Controller from '../controller'; -import { ADMIN } from '../../types/permissions'; -import { extractUsername } from '../../util/extract-user'; -import type { IUnleashConfig } from '../../types/option'; -import type { IUnleashServices } from '../../types/services'; -import type { Logger } from '../../logger'; -import type StateService from '../../services/state-service'; -import type { IAuthRequest } from '../unleash-types'; -import type { OpenApiService } from '../../services/openapi-service'; -import { createRequestSchema } from '../../openapi/util/create-request-schema'; -import { createResponseSchema } from '../../openapi/util/create-response-schema'; -import { - exportQueryParameters, - type ExportQueryParameters, -} from '../../openapi/spec/export-query-parameters'; -import { emptyResponse } from '../../openapi/util/standard-responses'; -import type { OpenAPIV3 } from 'openapi-types'; - -const upload = multer({ limits: { fileSize: 5242880 } }); -const paramToBool = (param, def) => { - if (typeof param === 'boolean') { - return param; - } - - if (param === null || param === undefined) { - return def; - } - const nu = Number.parseInt(param, 10); - if (Number.isNaN(nu)) { - return param.toLowerCase() === 'true'; - } - return Boolean(nu); -}; -class StateController extends Controller { - private logger: Logger; - - private stateService: StateService; - - private openApiService: OpenApiService; - - constructor( - config: IUnleashConfig, - { - stateService, - openApiService, - }: Pick, - ) { - super(config); - this.logger = config.getLogger('/admin-api/state.ts'); - this.stateService = stateService; - this.openApiService = openApiService; - this.fileupload('/import', upload.single('file'), this.import, ADMIN); - this.route({ - method: 'post', - path: '/import', - permission: ADMIN, - handler: this.import, - middleware: [ - this.openApiService.validPath({ - tags: ['Import/Export'], - operationId: 'import', - deprecated: true, - summary: 'Import state (deprecated)', - description: - 'Imports state into the system. Deprecated in favor of /api/admin/features-batch/import', - responses: { - 202: emptyResponse, - }, - requestBody: createRequestSchema('stateSchema'), - }), - ], - }); - this.route({ - method: 'get', - path: '/export', - permission: ADMIN, - handler: this.export, - middleware: [ - this.openApiService.validPath({ - tags: ['Import/Export'], - operationId: 'export', - deprecated: true, - summary: 'Export state (deprecated)', - description: - 'Exports the current state of the system. Deprecated in favor of /api/admin/features-batch/export', - responses: { - 200: createResponseSchema('stateSchema'), - }, - parameters: - exportQueryParameters as unknown as OpenAPIV3.ParameterObject[], - }), - ], - }); - } - - async import(req: IAuthRequest, res: Response): Promise { - const userName = extractUsername(req); - const { drop, keep } = req.query; - // TODO: Should override request type so file is a type on request - // biome-ignore lint/suspicious/noImplicitAnyLet: - let data; - // @ts-expect-error - if (req.file) { - // @ts-expect-error - if (mime.getType(req.file.originalname) === 'text/yaml') { - // @ts-expect-error - data = YAML.load(req.file.buffer); - } else { - // @ts-expect-error - data = JSON.parse(req.file.buffer); - } - } else { - data = req.body; - } - - await this.stateService.import({ - data, - userName, - dropBeforeImport: paramToBool(drop, false), - keepExisting: paramToBool(keep, true), - auditUser: req.audit, - }); - res.sendStatus(202); - } - - async export( - req: Request, - res: Response, - ): Promise { - const { format } = req.query; - - const downloadFile = paramToBool(req.query.download, false); - const includeStrategies = paramToBool(req.query.strategies, true); - const includeFeatureToggles = paramToBool( - req.query.featureToggles, - true, - ); - const includeProjects = paramToBool(req.query.projects, true); - const includeTags = paramToBool(req.query.tags, true); - const includeEnvironments = paramToBool(req.query.environments, true); - - const data = await this.stateService.export({ - includeStrategies, - includeFeatureToggles, - includeProjects, - includeTags, - includeEnvironments, - }); - const timestamp = formatDate(Date.now(), 'yyyy-MM-dd_HH-mm-ss'); - if (format === 'yaml') { - if (downloadFile) { - res.attachment(`export-${timestamp}.yml`); - } - res.type('yaml').send(YAML.dump(data, { skipInvalid: true })); - } else { - if (downloadFile) { - res.attachment(`export-${timestamp}.json`); - } - res.json(data); - } - } -} -export default StateController; diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts index 1ed4ba6937..79a236e077 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -19,7 +19,6 @@ import { type IUnleashOptions, type IUnleashServices, RoleName, - SYSTEM_USER_AUDIT, } from './types'; import User, { type IAuditUser, type IUser } from './types/user'; @@ -98,13 +97,7 @@ async function createApp( }; if (config.import.file) { - await services.stateService.importFile({ - file: config.import.file, - dropBeforeImport: config.import.dropBeforeImport, - userName: 'import', - keepExisting: config.import.keepExisting, - auditUser: SYSTEM_USER_AUDIT, - }); + // TODO: stateservice was here } if ( diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 95060a8780..0350a9c2a0 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -8,7 +8,6 @@ import EventService from '../features/events/event-service'; import HealthService from './health-service'; import ProjectService from '../features/project/project-service'; -import StateService from './state-service'; import ClientInstanceService from '../features/metrics/instance/instance-service'; import ClientMetricsServiceV2 from '../features/metrics/client-metrics/metrics-service-v2'; import TagTypeService from '../features/tag-type/tag-type-service'; @@ -180,7 +179,6 @@ export const createServices = ( eventService, ); const resetTokenService = new ResetTokenService(stores, config); - const stateService = new StateService(stores, config, eventService); const strategyService = new StrategyService(stores, config, eventService); const tagService = new TagService(stores, config, eventService); const transactionalTagTypeService = db @@ -379,7 +377,6 @@ export const createServices = ( featureTypeService, healthService, projectService, - stateService, strategyService, tagTypeService, transactionalTagTypeService, @@ -437,7 +434,6 @@ export { EventService, HealthService, ProjectService, - StateService, ClientInstanceService, ClientMetricsServiceV2, TagTypeService, diff --git a/src/lib/services/sort-order-schema.ts b/src/lib/services/sort-order-schema.ts new file mode 100644 index 0000000000..93490aae2d --- /dev/null +++ b/src/lib/services/sort-order-schema.ts @@ -0,0 +1,3 @@ +import joi from 'joi'; + +export const sortOrderSchema = joi.object().pattern(/^/, joi.number()); diff --git a/src/lib/services/state-schema.ts b/src/lib/services/state-schema.ts deleted file mode 100644 index c0b0f74251..0000000000 --- a/src/lib/services/state-schema.ts +++ /dev/null @@ -1,70 +0,0 @@ -import joi from 'joi'; -import { - featureSchema, - featureTagSchema, - variantsSchema, -} from '../schema/feature-schema'; -import strategySchema from './strategy-schema'; -import { tagSchema } from './tag-schema'; -import { tagTypeSchema } from './tag-type-schema'; -import { projectSchema } from './project-schema'; -import { nameType } from '../routes/util'; -import { featureStrategySegmentSchema, segmentSchema } from './segment-schema'; - -export const featureStrategySchema = joi - .object() - .keys({ - id: joi.string().optional(), - featureName: joi.string(), - projectId: joi.string(), - environment: joi.string(), - parameters: joi.object().optional().allow(null), - constraints: joi.array().optional(), - strategyName: joi.string(), - }) - .options({ stripUnknown: true }); - -export const featureEnvironmentsSchema = joi.object().keys({ - environment: joi.string(), - featureName: joi.string(), - enabled: joi.boolean(), - variants: joi.array().items(variantsSchema).optional(), -}); - -export const environmentSchema = joi.object().keys({ - name: nameType, - displayName: joi.string().optional().allow(''), - type: joi.string().required(), - sortOrder: joi.number().optional(), - enabled: joi.boolean().optional(), - protected: joi.boolean().optional(), -}); - -export const updateEnvironmentSchema = joi.object().keys({ - displayName: joi.string().optional().allow(''), - type: joi.string().optional(), - sortOrder: joi.number().optional(), -}); - -export const sortOrderSchema = joi.object().pattern(/^/, joi.number()); - -export const stateSchema = joi.object().keys({ - version: joi.number(), - features: joi.array().optional().items(featureSchema), - strategies: joi.array().optional().items(strategySchema), - tags: joi.array().optional().items(tagSchema), - tagTypes: joi.array().optional().items(tagTypeSchema), - featureTags: joi.array().optional().items(featureTagSchema), - projects: joi.array().optional().items(projectSchema), - featureStrategies: joi.array().optional().items(featureStrategySchema), - featureEnvironments: joi - .array() - .optional() - .items(featureEnvironmentsSchema), - environments: joi.array().optional().items(environmentSchema), - segments: joi.array().optional().items(segmentSchema), - featureStrategySegments: joi - .array() - .optional() - .items(featureStrategySegmentSchema), -}); diff --git a/src/lib/services/state-service-export-v1.json b/src/lib/services/state-service-export-v1.json deleted file mode 100644 index 4c8d4f0d93..0000000000 --- a/src/lib/services/state-service-export-v1.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "version": 1, - "features": [ - { - "name": "this-is-fun", - "description": "", - "type": "release", - "project": "default", - "enabled": true, - "stale": false, - "strategies": [ - { - "name": "default", - "parameters": {} - } - ], - "variants": [], - "createdAt": "2021-03-08T11:39:39.780Z", - "lastSeenAt": null - }, - { - "name": "this-is-not-fun", - "description": "", - "type": "operational", - "project": "default", - "enabled": true, - "stale": false, - "strategies": [ - { - "name": "default", - "parameters": {} - } - ], - "variants": [], - "createdAt": "2021-03-08T11:40:10.537Z", - "lastSeenAt": null - } - ], - "strategies": [], - "projects": [ - { - "id": "default", - "name": "Default", - "description": "Default project", - "createdAt": "2021-03-08T12:26:23.690Z" - } - ], - "tagTypes": [ - { - "name": "simple", - "description": "Used to simplify filtering of features", - "icon": "#" - } - ], - "tags": [ - { - "type": "simple", - "value": "hello" - }, - { - "type": "simple", - "value": "goodbye" - }, - { - "type": "simple", - "value": "something" - }, - { - "type": "simple", - "value": "yesterday" - } - ], - "featureTags": [ - { - "featureName": "this-is-fun", - "type": "simple", - "value": "hello" - }, - { - "featureName": "this-is-fun", - "type": "simple", - "value": "goodbye" - }, - { - "featureName": "this-is-not-fun", - "type": "simple", - "value": "something" - }, - { - "featureName": "this-is-not-fun", - "type": "simple", - "value": "yesterday" - } - ] -} diff --git a/src/lib/services/state-service.test.ts b/src/lib/services/state-service.test.ts deleted file mode 100644 index 3c8061c4d7..0000000000 --- a/src/lib/services/state-service.test.ts +++ /dev/null @@ -1,908 +0,0 @@ -import createStores from '../../test/fixtures/store'; -import getLogger from '../../test/fixtures/no-logger'; - -import StateService from './state-service'; -import { - FEATURE_IMPORT, - DROP_FEATURES, - STRATEGY_IMPORT, - DROP_STRATEGIES, - TAG_TYPE_IMPORT, - TAG_IMPORT, - PROJECT_IMPORT, -} from '../types/events'; -import { GLOBAL_ENV } from '../types/environment'; -import variantsExportV3 from '../../test/examples/variantsexport_v3.json'; -import EventService from '../features/events/event-service'; -import { SYSTEM_USER_AUDIT } from '../types'; -import { EventEmitter } from 'stream'; -const oldExportExample = require('./state-service-export-v1.json'); -const TESTUSERID = 3333; - -function getSetup() { - const stores = createStores(); - const eventService = new EventService(stores, { - getLogger, - eventBus: new EventEmitter(), - }); - return { - stateService: new StateService( - stores, - { - getLogger, - }, - eventService, - ), - stores, - }; -} - -async function setupV3VariantsCompatibilityScenario( - envs = [ - { name: 'env-2', enabled: true }, - { name: 'env-3', enabled: true }, - { name: 'env-1', enabled: true }, - ], -) { - const stores = createStores(); - await stores.featureToggleStore.create('some-project', { - name: 'Feature-with-variants', - createdByUserId: 9999, - }); - let sortOrder = 1; - envs.forEach(async (env) => { - await stores.environmentStore.create({ - name: env.name, - type: 'production', - sortOrder: sortOrder++, - }); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'Feature-with-variants', - env.name, - env.enabled, - ); - await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment( - 'Feature-with-variants', - env.name, - [ - { - name: `${env.name}-variant`, - stickiness: 'default', - weight: 1000, - weightType: 'variable', - }, - ], - ); - }); - const eventService = new EventService(stores, { - getLogger, - eventBus: new EventEmitter(), - }); - return { - stateService: new StateService( - stores, - { - getLogger, - }, - eventService, - ), - stores, - }; -} - -test('should import a feature', async () => { - const { stateService, stores } = getSetup(); - - const data = { - features: [ - { - name: 'new-feature', - enabled: true, - strategies: [{ name: 'default' }], - }, - ], - }; - - await stateService.import({ auditUser: SYSTEM_USER_AUDIT, data }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(1); - expect(events[0].type).toBe(FEATURE_IMPORT); - expect(events[0].data.name).toBe('new-feature'); -}); - -test('should not import an existing feature', async () => { - const { stateService, stores } = getSetup(); - - const data = { - features: [ - { - name: 'new-feature', - enabled: true, - strategies: [{ name: 'default' }], - createdByUserId: 9999, - }, - ], - }; - - await stores.featureToggleStore.create('default', data.features[0]); - - await stateService.import({ - data, - keepExisting: true, - auditUser: SYSTEM_USER_AUDIT, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(0); -}); - -test('should not keep existing feature if drop-before-import', async () => { - const { stateService, stores } = getSetup(); - - const data = { - features: [ - { - name: 'new-feature', - enabled: true, - strategies: [{ name: 'default' }], - createdByUserId: 9999, - }, - ], - }; - - await stores.featureToggleStore.create('default', data.features[0]); - - await stateService.import({ - data, - keepExisting: true, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(2); - expect(events[0].type).toBe(DROP_FEATURES); - expect(events[1].type).toBe(FEATURE_IMPORT); -}); - -test('should drop feature before import if specified', async () => { - const { stateService, stores } = getSetup(); - - const data = { - features: [ - { - name: 'new-feature', - enabled: true, - strategies: [{ name: 'default' }], - }, - ], - }; - - await stateService.import({ - data, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(2); - expect(events[0].type).toBe(DROP_FEATURES); - expect(events[1].type).toBe(FEATURE_IMPORT); - expect(events[1].data.name).toBe('new-feature'); -}); - -test('should import a strategy', async () => { - const { stateService, stores } = getSetup(); - - const data = { - strategies: [ - { - name: 'new-strategy', - parameters: [], - }, - ], - }; - - await stateService.import({ auditUser: SYSTEM_USER_AUDIT, data }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(1); - expect(events[0].type).toBe(STRATEGY_IMPORT); - expect(events[0].data.name).toBe('new-strategy'); -}); - -test('should not import an existing strategy', async () => { - const { stateService, stores } = getSetup(); - - const data = { - strategies: [ - { - name: 'new-strategy', - parameters: [], - }, - ], - }; - - await stores.strategyStore.createStrategy(data.strategies[0]); - - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - keepExisting: true, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(0); -}); - -test('should drop strategies before import if specified', async () => { - const { stateService, stores } = getSetup(); - - const data = { - strategies: [ - { - name: 'new-strategy', - parameters: [], - }, - ], - }; - - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(2); - expect(events[0].type).toBe(DROP_STRATEGIES); - expect(events[1].type).toBe(STRATEGY_IMPORT); - expect(events[1].data.name).toBe('new-strategy'); -}); - -test('should drop neither features nor strategies when neither is imported', async () => { - const { stateService, stores } = getSetup(); - - const data = {}; - - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(0); -}); - -test('should not accept gibberish', async () => { - const { stateService } = getSetup(); - - const data1 = { - type: 'gibberish', - flags: { evil: true }, - }; - const data2 = '{somerandomtext/'; - - await expect(async () => - stateService.import({ auditUser: SYSTEM_USER_AUDIT, data: data1 }), - ).rejects.toThrow(); - - await expect(async () => - stateService.import({ auditUser: SYSTEM_USER_AUDIT, data: data2 }), - ).rejects.toThrow(); -}); - -test('should export featureToggles', async () => { - const { stateService, stores } = getSetup(); - - await stores.featureToggleStore.create('default', { - name: 'a-feature', - createdByUserId: 9999, - }); - - const data = await stateService.export({ includeFeatureToggles: true }); - - expect(data.features).toHaveLength(1); - expect(data.features[0].name).toBe('a-feature'); -}); - -test('archived feature flags should not be included', async () => { - const { stateService, stores } = getSetup(); - - await stores.featureToggleStore.create('default', { - name: 'a-feature', - archived: true, - createdByUserId: 9999, - }); - const data = await stateService.export({ includeFeatureToggles: true }); - - expect(data.features).toHaveLength(0); -}); - -test('featureStrategy connected to an archived feature flag should not be included', async () => { - const { stateService, stores } = getSetup(); - const featureName = 'fstrat-archived-feature'; - await stores.featureToggleStore.create('default', { - name: featureName, - archived: true, - createdByUserId: 9999, - }); - - await stores.featureStrategiesStore.createStrategyFeatureEnv({ - featureName, - strategyName: 'fstrat-archived-strat', - environment: GLOBAL_ENV, - constraints: [], - parameters: {}, - projectId: 'default', - }); - const data = await stateService.export({ includeFeatureToggles: true }); - expect(data.featureStrategies).toHaveLength(0); -}); - -test('featureStrategy connected to a feature should be included', async () => { - const { stateService, stores } = getSetup(); - const featureName = 'fstrat-feature'; - await stores.featureToggleStore.create('default', { - name: featureName, - createdByUserId: 9999, - }); - - await stores.featureStrategiesStore.createStrategyFeatureEnv({ - featureName, - strategyName: 'fstrat-strat', - environment: GLOBAL_ENV, - constraints: [], - parameters: {}, - projectId: 'default', - }); - const data = await stateService.export({ includeFeatureToggles: true }); - expect(data.featureStrategies).toHaveLength(1); -}); - -test('should export strategies', async () => { - const { stateService, stores } = getSetup(); - - await stores.strategyStore.createStrategy({ - name: 'a-strategy', - editable: true, - parameters: [], - }); - - const data = await stateService.export({ includeStrategies: true }); - - expect(data.strategies).toHaveLength(1); - expect(data.strategies[0].name).toBe('a-strategy'); -}); - -test('should import a tag and tag type', async () => { - const { stateService, stores } = getSetup(); - const data = { - tagTypes: [ - { name: 'simple', description: 'some description', icon: '#' }, - ], - tags: [{ type: 'simple', value: 'test' }], - }; - - await stateService.import({ auditUser: SYSTEM_USER_AUDIT, data }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(2); - expect(events[0].type).toBe(TAG_TYPE_IMPORT); - expect(events[0].data.name).toBe('simple'); - expect(events[1].type).toBe(TAG_IMPORT); - expect(events[1].data.value).toBe('test'); -}); - -test('Should not import an existing tag', async () => { - const { stateService, stores } = getSetup(); - const data = { - tagTypes: [ - { name: 'simple', description: 'some description', icon: '#' }, - ], - tags: [{ type: 'simple', value: 'test' }], - featureTags: [ - { - featureName: 'demo-feature', - tagType: 'simple', - tagValue: 'test', - }, - ], - }; - await stores.tagTypeStore.createTagType(data.tagTypes[0]); - await stores.tagStore.createTag(data.tags[0]); - await stores.featureTagStore.tagFeature( - data.featureTags[0].featureName, - { - type: data.featureTags[0].tagType, - value: data.featureTags[0].tagValue, - }, - TESTUSERID, - ); - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - keepExisting: true, - }); - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(0); -}); - -test('Should not keep existing tags if drop-before-import', async () => { - const { stateService, stores } = getSetup(); - const notSoSimple = { - name: 'notsosimple', - description: 'some other description', - icon: '#', - }; - const slack = { - name: 'slack', - description: 'slack tags', - icon: '#', - }; - - await stores.tagTypeStore.createTagType(notSoSimple); - await stores.tagTypeStore.createTagType(slack); - const data = { - tagTypes: [ - { name: 'simple', description: 'some description', icon: '#' }, - ], - tags: [{ type: 'simple', value: 'test' }], - featureTags: [ - { - featureName: 'demo-feature', - tagType: 'simple', - tagValue: 'test', - }, - ], - }; - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - const tagTypes = await stores.tagTypeStore.getAll(); - expect(tagTypes).toHaveLength(1); -}); - -test('should export tag, tagtypes but not feature tags if the feature is not exported', async () => { - const { stateService, stores } = getSetup(); - - const data = { - tagTypes: [ - { name: 'simple', description: 'some description', icon: '#' }, - ], - tags: [{ type: 'simple', value: 'test' }], - featureTags: [ - { - featureName: 'demo-feature', - tagType: 'simple', - tagValue: 'test', - }, - ], - }; - await stores.tagTypeStore.createTagType(data.tagTypes[0]); - await stores.tagStore.createTag(data.tags[0]); - await stores.featureTagStore.tagFeature( - data.featureTags[0].featureName, - { - type: data.featureTags[0].tagType, - value: data.featureTags[0].tagValue, - }, - TESTUSERID, - ); - - const exported = await stateService.export({ - includeFeatureToggles: false, - includeStrategies: false, - includeTags: true, - includeProjects: false, - }); - - expect(exported.tags).toHaveLength(1); - expect(exported.tags[0].type).toBe(data.tags[0].type); - expect(exported.tags[0].value).toBe(data.tags[0].value); - expect(exported.tagTypes).toHaveLength(1); - expect(exported.tagTypes[0].name).toBe(data.tagTypes[0].name); - expect(exported.featureTags).toHaveLength(0); -}); - -test('should export tag, tagtypes, featureTags and features', async () => { - const { stateService, stores } = getSetup(); - - const data = { - tagTypes: [ - { name: 'simple', description: 'some description', icon: '#' }, - ], - tags: [{ type: 'simple', value: 'test' }], - featureTags: [ - { - featureName: 'demo-feature', - tagType: 'simple', - tagValue: 'test', - }, - ], - }; - await stores.tagTypeStore.createTagType(data.tagTypes[0]); - await stores.tagStore.createTag(data.tags[0]); - await stores.featureTagStore.tagFeature( - data.featureTags[0].featureName, - { - type: data.featureTags[0].tagType, - value: data.featureTags[0].tagValue, - }, - TESTUSERID, - ); - - const exported = await stateService.export({ - includeFeatureToggles: true, - includeStrategies: false, - includeTags: true, - includeProjects: false, - }); - - expect(exported.tags).toHaveLength(1); - expect(exported.tags[0].type).toBe(data.tags[0].type); - expect(exported.tags[0].value).toBe(data.tags[0].value); - expect(exported.tagTypes).toHaveLength(1); - expect(exported.tagTypes[0].name).toBe(data.tagTypes[0].name); - expect(exported.featureTags).toHaveLength(1); - - expect(exported.featureTags[0].featureName).toBe( - data.featureTags[0].featureName, - ); - expect(exported.featureTags[0].tagType).toBe(data.featureTags[0].tagType); - expect(exported.featureTags[0].tagValue).toBe(data.featureTags[0].tagValue); -}); - -test('should import a project', async () => { - const { stateService, stores } = getSetup(); - - const data = { - projects: [ - { - id: 'default', - name: 'default', - description: 'Some fancy description for project', - }, - ], - }; - - await stateService.import({ auditUser: SYSTEM_USER_AUDIT, data }); - - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(1); - expect(events[0].type).toBe(PROJECT_IMPORT); - expect(events[0].data.name).toBe('default'); -}); - -test('Should not import an existing project', async () => { - const { stateService, stores } = getSetup(); - - const data = { - projects: [ - { - id: 'default', - name: 'default', - description: 'Some fancy description for project', - mode: 'open' as const, - }, - ], - }; - await stores.projectStore.create(data.projects[0]); - - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - keepExisting: true, - }); - const events = await stores.eventStore.getEvents(); - expect(events).toHaveLength(0); - - await stateService.import({ auditUser: SYSTEM_USER_AUDIT, data }); -}); - -test('Should drop projects before import if specified', async () => { - const { stateService, stores } = getSetup(); - - const data = { - projects: [ - { - id: 'default', - name: 'default', - description: 'Some fancy description for project', - }, - ], - }; - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'Not expected to be seen after import', - mode: 'open' as const, - }); - await stateService.import({ - data, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - const hasProject = await stores.projectStore.hasProject('fancy'); - expect(hasProject).toBe(false); -}); - -test('Should export projects', async () => { - const { stateService, stores } = getSetup(); - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'No surprises here', - mode: 'open' as const, - }); - const exported = await stateService.export({ - includeFeatureToggles: false, - includeStrategies: false, - includeTags: false, - includeProjects: true, - }); - expect(exported.projects[0].id).toBe('fancy'); - expect(exported.projects[0].name).toBe('extra'); - expect(exported.projects[0].description).toBe('No surprises here'); -}); - -test('exporting to new format works', async () => { - const stores = createStores(); - const eventService = new EventService(stores, { - getLogger, - eventBus: new EventEmitter(), - }); - const stateService = new StateService( - stores, - { - getLogger, - }, - eventService, - ); - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'No surprises here', - mode: 'open' as const, - }); - await stores.environmentStore.create({ - name: 'dev', - type: 'development', - }); - await stores.environmentStore.create({ - name: 'prod', - type: 'production', - }); - await stores.featureToggleStore.create('fancy', { - name: 'Some-feature', - createdByUserId: 9999, - }); - await stores.strategyStore.createStrategy({ - name: 'format', - parameters: [], - }); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'Some-feature', - 'dev', - true, - ); - await stores.featureStrategiesStore.createStrategyFeatureEnv({ - featureName: 'Some-feature', - projectId: 'fancy', - strategyName: 'format', - environment: 'dev', - parameters: {}, - constraints: [], - }); - await stores.featureTagStore.tagFeature( - 'Some-feature', - { - type: 'simple', - value: 'Test', - }, - TESTUSERID, - ); - const exported = await stateService.export({}); - expect(exported.featureStrategies).toHaveLength(1); -}); - -test('exporting variants to v4 format should not include variants in features', async () => { - const { stateService } = await setupV3VariantsCompatibilityScenario(); - const exported = await stateService.export({}); - - expect(exported.features).toHaveLength(1); - expect(exported.features[0].variants).toBeUndefined(); - - exported.featureEnvironments.forEach((fe) => { - expect(fe.variants).toHaveLength(1); - expect(fe.variants?.[0].name).toBe(`${fe.environment}-variant`); - }); - expect(exported.environments).toHaveLength(3); -}); - -test('featureStrategies can keep existing', async () => { - const { stateService, stores } = getSetup(); - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'No surprises here', - mode: 'open' as const, - }); - await stores.environmentStore.create({ - name: 'dev', - type: 'development', - }); - await stores.environmentStore.create({ - name: 'prod', - type: 'production', - }); - await stores.featureToggleStore.create('fancy', { - name: 'Some-feature', - createdByUserId: 9999, - }); - await stores.strategyStore.createStrategy({ - name: 'format', - parameters: [], - }); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'Some-feature', - 'dev', - true, - ); - await stores.featureStrategiesStore.createStrategyFeatureEnv({ - featureName: 'Some-feature', - projectId: 'fancy', - strategyName: 'format', - environment: 'dev', - parameters: {}, - constraints: [], - }); - await stores.featureTagStore.tagFeature( - 'Some-feature', - { - type: 'simple', - value: 'Test', - }, - TESTUSERID, - ); - - const exported = await stateService.export({}); - await stateService.import({ - data: exported, - auditUser: SYSTEM_USER_AUDIT, - keepExisting: true, - }); - expect(await stores.featureStrategiesStore.getAll()).toHaveLength(1); -}); - -test('featureStrategies should not keep existing if dropBeforeImport', async () => { - const { stateService, stores } = getSetup(); - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'No surprises here', - mode: 'open' as const, - }); - await stores.environmentStore.create({ - name: 'dev', - type: 'development', - }); - await stores.environmentStore.create({ - name: 'prod', - type: 'production', - }); - await stores.featureToggleStore.create('fancy', { - name: 'Some-feature', - createdByUserId: 9999, - }); - await stores.strategyStore.createStrategy({ - name: 'format', - parameters: [], - }); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'Some-feature', - 'dev', - true, - ); - await stores.featureStrategiesStore.createStrategyFeatureEnv({ - featureName: 'Some-feature', - projectId: 'fancy', - strategyName: 'format', - environment: 'dev', - parameters: {}, - constraints: [], - }); - await stores.featureTagStore.tagFeature( - 'Some-feature', - { - type: 'simple', - value: 'Test', - }, - TESTUSERID, - ); - - const exported = await stateService.export({}); - exported.featureStrategies = []; - await stateService.import({ - data: exported, - auditUser: SYSTEM_USER_AUDIT, - keepExisting: true, - dropBeforeImport: true, - }); - expect(await stores.featureStrategiesStore.getAll()).toHaveLength(0); -}); - -test('Import v1 and exporting v2 should work', async () => { - const { stateService } = getSetup(); - await stateService.import({ - data: oldExportExample, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - const exported = await stateService.export({}); - const strategiesCount = oldExportExample.features.reduce( - (acc, f) => acc + f.strategies.length, - 0, - ); - expect( - exported.features.every((f) => - oldExportExample.features.some((old) => old.name === f.name), - ), - ).toBeTruthy(); - expect(exported.featureStrategies).toHaveLength(strategiesCount); -}); - -test('Importing states with deprecated strategies should keep their deprecated state', async () => { - const { stateService, stores } = getSetup(); - const deprecatedStrategyExample = { - version: 4, - features: [], - strategies: [ - { - name: 'deprecatedstrat', - description: 'This should be deprecated when imported', - deprecated: true, - parameters: [], - builtIn: false, - sortOrder: 9999, - displayName: 'Deprecated strategy', - }, - ], - featureStrategies: [], - }; - await stateService.import({ - data: deprecatedStrategyExample, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - keepExisting: false, - }); - const deprecatedStrategy = - await stores.strategyStore.get('deprecatedstrat'); - expect(deprecatedStrategy.deprecated).toBe(true); -}); - -test('Exporting a deprecated strategy and then importing it should keep correct state', async () => { - const { stateService, stores } = getSetup(); - await stateService.import({ - data: variantsExportV3, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - dropBeforeImport: true, - }); - const rolloutRandom = await stores.strategyStore.get( - 'gradualRolloutRandom', - ); - expect(rolloutRandom.deprecated).toBe(true); - const rolloutSessionId = await stores.strategyStore.get( - 'gradualRolloutSessionId', - ); - expect(rolloutSessionId.deprecated).toBe(true); - const rolloutUserId = await stores.strategyStore.get( - 'gradualRolloutUserId', - ); - expect(rolloutUserId.deprecated).toBe(true); -}); diff --git a/src/lib/services/state-service.ts b/src/lib/services/state-service.ts deleted file mode 100644 index c5884f83eb..0000000000 --- a/src/lib/services/state-service.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { stateSchema } from './state-schema'; -import { - DropEnvironmentsEvent, - DropFeaturesEvent, - DropFeatureTagsEvent, - DropProjectsEvent, - DropStrategiesEvent, - DropTagsEvent, - DropTagTypesEvent, - EnvironmentImport, - FeatureImport, - FeatureTagImport, - ProjectImport, - StrategyImport, - TagImport, - TagTypeImport, -} from '../types/events'; - -import { filterEqual, filterExisting, parseFile, readFile } from './state-util'; - -import type { IUnleashConfig } from '../types/option'; -import type { - FeatureToggle, - IEnvironment, - IFeatureEnvironment, - IFeatureStrategy, - IImportData, - IImportFile, - IProject, - ISegment, - IStrategyConfig, - ITag, -} from '../types/model'; -import type { Logger } from '../logger'; -import type { - IFeatureTag, - IFeatureTagStore, -} from '../types/stores/feature-tag-store'; -import type { IProjectStore } from '../features/project/project-store-type'; -import type { - ITagType, - ITagTypeStore, -} from '../features/tag-type/tag-type-store-type'; -import type { ITagStore } from '../types/stores/tag-store'; -import type { IStrategy, IStrategyStore } from '../types/stores/strategy-store'; -import type { IFeatureToggleStore } from '../features/feature-toggle/types/feature-toggle-store-type'; -import type { IFeatureStrategiesStore } from '../features/feature-toggle/types/feature-toggle-strategies-store-type'; -import type { IEnvironmentStore } from '../features/project-environments/environment-store-type'; -import type { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; -import type { IUnleashStores } from '../types/stores'; -import { DEFAULT_ENV } from '../util/constants'; -import { GLOBAL_ENV } from '../types/environment'; -import type { ISegmentStore } from '../features/segment/segment-store-type'; -import type { PartialSome } from '../types/partial'; -import type EventService from '../features/events/event-service'; -import type { IAuditUser } from '../server-impl'; - -export interface IBackupOption { - includeFeatureToggles: boolean; - includeStrategies: boolean; - includeProjects: boolean; - includeTags: boolean; -} - -interface IExportIncludeOptions { - includeFeatureToggles?: boolean; - includeStrategies?: boolean; - includeProjects?: boolean; - includeTags?: boolean; - includeEnvironments?: boolean; - includeSegments?: boolean; -} - -export default class StateService { - private logger: Logger; - - private toggleStore: IFeatureToggleStore; - - private featureStrategiesStore: IFeatureStrategiesStore; - - private strategyStore: IStrategyStore; - - private eventService: EventService; - - private tagStore: ITagStore; - - private tagTypeStore: ITagTypeStore; - - private projectStore: IProjectStore; - - private featureEnvironmentStore: IFeatureEnvironmentStore; - - private featureTagStore: IFeatureTagStore; - - private environmentStore: IEnvironmentStore; - - private segmentStore: ISegmentStore; - - constructor( - stores: IUnleashStores, - { getLogger }: Pick, - eventService: EventService, - ) { - this.eventService = eventService; - 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.logger = getLogger('services/state-service.js'); - } - - async importFile({ - file, - dropBeforeImport = false, - auditUser, - keepExisting = true, - }: IImportFile): Promise { - return readFile(file) - .then((data) => parseFile(file, data)) - .then((data) => - this.import({ - data, - auditUser, - dropBeforeImport, - keepExisting, - }), - ); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - replaceGlobalEnvWithDefaultEnv(data: any) { - data.environments?.forEach((e) => { - if (e.name === GLOBAL_ENV) { - e.name = DEFAULT_ENV; - } - }); - data.featureEnvironments?.forEach((fe) => { - if (fe.environment === GLOBAL_ENV) { - // eslint-disable-next-line no-param-reassign - fe.environment = DEFAULT_ENV; - } - }); - data.featureStrategies?.forEach((fs) => { - if (fs.environment === GLOBAL_ENV) { - // eslint-disable-next-line no-param-reassign - fs.environment = DEFAULT_ENV; - } - }); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - moveVariantsToFeatureEnvironments(data: any) { - data.featureEnvironments?.forEach((featureEnvironment) => { - const feature = data.features?.find( - (f) => f.name === featureEnvironment.featureName, - ); - if (feature) { - featureEnvironment.variants = feature.variants || []; - } - }); - } - - async import({ - data, - auditUser, - dropBeforeImport = false, - keepExisting = true, - }: IImportData): Promise { - if (data.version === 2) { - this.replaceGlobalEnvWithDefaultEnv(data); - } - if (!data.version || data.version < 4) { - this.moveVariantsToFeatureEnvironments(data); - } - const importData = await stateSchema.validateAsync(data); - - let importedEnvironments: IEnvironment[] = []; - if (importData.environments) { - importedEnvironments = await this.importEnvironments({ - environments: data.environments, - dropBeforeImport, - keepExisting, - auditUser, - }); - } - - if (importData.projects) { - await this.importProjects({ - projects: data.projects, - importedEnvironments, - dropBeforeImport, - keepExisting, - auditUser, - }); - } - - if (importData.features) { - // biome-ignore lint/suspicious/noImplicitAnyLet: too many formats to consider here. Allowing this to be any - let projectData; - if (!importData.version || importData.version === 1) { - projectData = await this.convertLegacyFeatures(importData); - } else { - projectData = importData; - } - const { features, featureStrategies, featureEnvironments } = - projectData; - - await this.importFeatures({ - features, - dropBeforeImport, - keepExisting, - featureEnvironments, - auditUser, - }); - - if (featureEnvironments) { - await this.importFeatureEnvironments({ - featureEnvironments, - }); - } - - await this.importFeatureStrategies({ - featureStrategies, - dropBeforeImport, - keepExisting, - }); - } - - if (importData.strategies) { - await this.importStrategies({ - strategies: data.strategies, - dropBeforeImport, - keepExisting, - auditUser, - }); - } - - if (importData.tagTypes && importData.tags) { - await this.importTagData({ - tagTypes: data.tagTypes, - tags: data.tags, - featureTags: - (data.featureTags || []) - .filter((t) => - (data.features || []).some( - (f) => f.name === t.featureName, - ), - ) - .map((t) => ({ - featureName: t.featureName, - tagValue: t.tagValue || t.value, - tagType: t.tagType || t.type, - })) || [], - dropBeforeImport, - keepExisting, - auditUser, - }); - } - - if (importData.segments) { - await this.importSegments( - data.segments, - auditUser, - dropBeforeImport, - ); - } - - if (importData.featureStrategySegments) { - await this.importFeatureStrategySegments( - data.featureStrategySegments, - ); - } - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - enabledIn(feature: string, env) { - const config = {}; - env.filter((e) => e.featureName === feature).forEach((e) => { - config[e.environment] = e.enabled || false; - }); - return config; - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importFeatureEnvironments({ featureEnvironments }): Promise { - await Promise.all( - featureEnvironments - .filter(async (env) => { - await this.environmentStore.exists(env.environment); - }) - .map(async (featureEnvironment) => - this.featureEnvironmentStore.addFeatureEnvironment( - featureEnvironment, - ), - ), - ); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importFeatureStrategies({ - featureStrategies, - dropBeforeImport, - keepExisting, - }): Promise { - const oldFeatureStrategies = dropBeforeImport - ? [] - : await this.featureStrategiesStore.getAll(); - if (dropBeforeImport) { - this.logger.info('Dropping existing strategies for feature flags'); - await this.featureStrategiesStore.deleteAll(); - } - const strategiesToImport = keepExisting - ? featureStrategies.filter( - (s) => !oldFeatureStrategies.some((o) => o.id === s.id), - ) - : featureStrategies; - await Promise.all( - strategiesToImport.map((featureStrategy) => - this.featureStrategiesStore.createStrategyFeatureEnv( - featureStrategy, - ), - ), - ); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async convertLegacyFeatures({ - features, - }): Promise<{ features; featureStrategies; featureEnvironments }> { - const strategies = features.flatMap((f) => - f.strategies.map((strategy: IStrategyConfig) => ({ - featureName: f.name, - projectId: f.project, - constraints: strategy.constraints || [], - parameters: strategy.parameters || {}, - environment: DEFAULT_ENV, - strategyName: strategy.name, - })), - ); - const newFeatures = features; - const featureEnvironments = features.map((feature) => ({ - featureName: feature.name, - environment: DEFAULT_ENV, - enabled: feature.enabled, - variants: feature.variants || [], - })); - return { - features: newFeatures, - featureStrategies: strategies, - featureEnvironments, - }; - } - - async importFeatures({ - features, - dropBeforeImport, - keepExisting, - featureEnvironments, - auditUser, - }): Promise { - this.logger.info(`Importing ${features.length} feature flags`); - const oldToggles = dropBeforeImport - ? [] - : await this.toggleStore.getAll(); - - if (dropBeforeImport) { - this.logger.info('Dropping existing feature flags'); - await this.toggleStore.deleteAll(); - await this.eventService.storeEvent( - new DropFeaturesEvent({ auditUser }), - ); - } - - await Promise.all( - features - .filter(filterExisting(keepExisting, oldToggles)) - .filter(filterEqual(oldToggles)) - .map(async (feature) => { - await this.toggleStore.create(feature.project, { - createdByUserId: auditUser.id, - ...feature, - }); - await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject( - feature.name, - feature.project, - this.enabledIn(feature.name, featureEnvironments), - ); - await this.eventService.storeEvent( - new FeatureImport({ - feature, - auditUser, - }), - ); - }), - ); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importStrategies({ - strategies, - dropBeforeImport, - keepExisting, - auditUser, - }): Promise { - this.logger.info(`Importing ${strategies.length} strategies`); - const oldStrategies = dropBeforeImport - ? [] - : await this.strategyStore.getAll(); - - if (dropBeforeImport) { - this.logger.info('Dropping existing strategies'); - await this.strategyStore.dropCustomStrategies(); - await this.eventService.storeEvent( - new DropStrategiesEvent({ auditUser }), - ); - } - - await Promise.all( - strategies - .filter(filterExisting(keepExisting, oldStrategies)) - .filter(filterEqual(oldStrategies)) - .map((strategy) => - this.strategyStore.importStrategy(strategy).then(() => { - this.eventService.storeEvent( - new StrategyImport({ strategy, auditUser }), - ); - }), - ), - ); - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importEnvironments({ - environments, - auditUser, - dropBeforeImport, - keepExisting, - }): Promise { - this.logger.info(`Import ${environments.length} projects`); - const oldEnvs = dropBeforeImport - ? [] - : await this.environmentStore.getAll(); - if (dropBeforeImport) { - this.logger.info('Dropping existing environments'); - await this.environmentStore.deleteAll(); - await this.eventService.storeEvent( - new DropEnvironmentsEvent({ auditUser }), - ); - } - const envsImport = environments.filter((env) => - keepExisting ? !oldEnvs.some((old) => old.name === env.name) : true, - ); - let importedEnvs: IEnvironment[] = []; - if (envsImport.length > 0) { - importedEnvs = - await this.environmentStore.importEnvironments(envsImport); - const importedEnvironmentEvents = importedEnvs.map( - (env) => new EnvironmentImport({ auditUser, env }), - ); - await this.eventService.storeEvents(importedEnvironmentEvents); - } - return importedEnvs; - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importProjects({ - projects, - importedEnvironments, - auditUser, - dropBeforeImport, - keepExisting, - }): Promise { - this.logger.info(`Import ${projects.length} projects`); - const oldProjects = dropBeforeImport - ? [] - : await this.projectStore.getAll(); - if (dropBeforeImport) { - this.logger.info('Dropping existing projects'); - await this.projectStore.deleteAll(); - await this.eventService.storeEvent( - new DropProjectsEvent({ auditUser }), - ); - } - const projectsToImport = projects.filter((project) => - keepExisting - ? !oldProjects.some((old) => old.id === project.id) - : true, - ); - if (projectsToImport.length > 0) { - const importedProjects = await this.projectStore.importProjects( - projectsToImport, - importedEnvironments, - ); - const importedProjectEvents = importedProjects.map( - (project) => new ProjectImport({ project, auditUser }), - ); - await this.eventService.storeEvents(importedProjectEvents); - } - } - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async importTagData({ - tagTypes, - tags, - featureTags, - auditUser, - dropBeforeImport, - keepExisting, - }): Promise { - this.logger.info( - `Importing ${tagTypes.length} tagtypes, ${tags.length} tags and ${featureTags.length} feature tags`, - ); - const oldTagTypes = dropBeforeImport - ? [] - : await this.tagTypeStore.getAll(); - const oldTags = dropBeforeImport ? [] : await this.tagStore.getAll(); - const oldFeatureTags = dropBeforeImport - ? [] - : await this.featureTagStore.getAll(); - if (dropBeforeImport) { - this.logger.info( - 'Dropping all existing featuretags, tags and tagtypes', - ); - await this.featureTagStore.deleteAll(); - await this.tagStore.deleteAll(); - await this.tagTypeStore.deleteAll(); - await this.eventService.storeEvents([ - new DropFeatureTagsEvent({ auditUser }), - new DropTagsEvent({ auditUser }), - new DropTagTypesEvent({ auditUser }), - ]); - } - await this.importTagTypes( - tagTypes, - keepExisting, - oldTagTypes, - auditUser, - ); - await this.importTags(tags, keepExisting, oldTags, auditUser); - await this.importFeatureTags( - featureTags, - keepExisting, - oldFeatureTags, - auditUser, - ); - } - - compareFeatureTags: (old: IFeatureTag, tag: IFeatureTag) => boolean = ( - old, - tag, - ) => - old.featureName === tag.featureName && - old.tagValue === tag.tagValue && - old.tagType === tag.tagType; - - async importFeatureTags( - featureTags: IFeatureTag[], - keepExisting: boolean, - oldFeatureTags: IFeatureTag[], - auditUser: IAuditUser, - ): Promise { - const featureTagsToInsert = featureTags - .filter((tag) => - keepExisting - ? !oldFeatureTags.some((old) => - this.compareFeatureTags(old, tag), - ) - : true, - ) - .map((tag) => ({ - createdByUserId: auditUser.id, - ...tag, - })); - if (featureTagsToInsert.length > 0) { - const importedFeatureTags = - await this.featureTagStore.tagFeatures(featureTagsToInsert); - const importedFeatureTagEvents = importedFeatureTags.map( - (featureTag) => new FeatureTagImport({ featureTag, auditUser }), - ); - await this.eventService.storeEvents(importedFeatureTagEvents); - } - } - - compareTags = (old: ITag, tag: ITag): boolean => - old.type === tag.type && old.value === tag.value; - - async importTags( - tags: ITag[], - keepExisting: boolean, - oldTags: ITag[], - auditUser: IAuditUser, - ): Promise { - const tagsToInsert = tags.filter((tag) => - keepExisting - ? !oldTags.some((old) => this.compareTags(old, tag)) - : true, - ); - if (tagsToInsert.length > 0) { - const importedTags = await this.tagStore.bulkImport(tagsToInsert); - const importedTagEvents = importedTags.map( - (tag) => new TagImport({ tag, auditUser }), - ); - await this.eventService.storeEvents(importedTagEvents); - } - } - - async importTagTypes( - tagTypes: ITagType[], - keepExisting: boolean, - oldTagTypes: ITagType[], - auditUser: IAuditUser, - ): Promise { - const tagTypesToInsert = tagTypes.filter((tagType) => - keepExisting - ? !oldTagTypes.some((t) => t.name === tagType.name) - : true, - ); - if (tagTypesToInsert.length > 0) { - const importedTagTypes = - await this.tagTypeStore.bulkImport(tagTypesToInsert); - const importedTagTypeEvents = importedTagTypes.map( - (tagType) => new TagTypeImport({ tagType, auditUser }), - ); - await this.eventService.storeEvents(importedTagTypeEvents); - } - } - - async importSegments( - segments: PartialSome[], - auditUser: IAuditUser, - dropBeforeImport: boolean, - ): Promise { - if (dropBeforeImport) { - await this.segmentStore.deleteAll(); - } - - await Promise.all( - segments.map((segment) => - this.segmentStore.create(segment, { - username: auditUser.username, - }), - ), - ); - } - - async importFeatureStrategySegments( - featureStrategySegments: { - featureStrategyId: string; - segmentId: number; - }[], - ): Promise { - await Promise.all( - featureStrategySegments.map(({ featureStrategyId, segmentId }) => - this.segmentStore.addToStrategy(segmentId, featureStrategyId), - ), - ); - } - - async export(opts: IExportIncludeOptions): Promise<{ - features: FeatureToggle[]; - strategies: IStrategy[]; - version: number; - projects: IProject[]; - tagTypes: ITagType[]; - tags: ITag[]; - featureTags: IFeatureTag[]; - featureStrategies: IFeatureStrategy[]; - environments: IEnvironment[]; - featureEnvironments: IFeatureEnvironment[]; - }> { - return this.exportV4(opts); - } - - async exportV4({ - includeFeatureToggles = true, - includeStrategies = true, - includeProjects = true, - includeTags = true, - includeEnvironments = true, - includeSegments = true, - }: IExportIncludeOptions): Promise<{ - features: FeatureToggle[]; - strategies: IStrategy[]; - version: number; - projects: IProject[]; - tagTypes: ITagType[]; - tags: ITag[]; - featureTags: IFeatureTag[]; - featureStrategies: IFeatureStrategy[]; - environments: IEnvironment[]; - featureEnvironments: IFeatureEnvironment[]; - }> { - return Promise.all([ - includeFeatureToggles - ? this.toggleStore.getAll({ archived: false }) - : Promise.resolve([]), - includeStrategies - ? this.strategyStore.getEditableStrategies() - : Promise.resolve([]), - this.projectStore && includeProjects - ? this.projectStore.getAll() - : Promise.resolve([]), - includeTags ? this.tagTypeStore.getAll() : Promise.resolve([]), - includeTags ? this.tagStore.getAll() : Promise.resolve([]), - includeTags && includeFeatureToggles - ? this.featureTagStore.getAll() - : Promise.resolve([]), - includeFeatureToggles - ? this.featureStrategiesStore.getAll() - : Promise.resolve([]), - includeEnvironments - ? this.environmentStore.getAll() - : Promise.resolve([]), - includeFeatureToggles - ? this.featureEnvironmentStore.getAll() - : Promise.resolve([]), - includeSegments ? this.segmentStore.getAll() : Promise.resolve([]), - includeSegments - ? this.segmentStore.getAllFeatureStrategySegments() - : Promise.resolve([]), - ]).then( - ([ - features, - strategies, - projects, - tagTypes, - tags, - featureTags, - featureStrategies, - environments, - featureEnvironments, - segments, - featureStrategySegments, - ]) => ({ - version: 4, - features, - strategies, - projects, - tagTypes, - tags, - featureTags, - featureStrategies: featureStrategies.filter((fS) => - features.some((f) => fS.featureName === f.name), - ), - environments, - featureEnvironments: featureEnvironments.filter((fE) => - features.some((f) => fE.featureName === f.name), - ), - segments, - featureStrategySegments, - }), - ); - } -} - -module.exports = StateService; diff --git a/src/lib/services/state-util.ts b/src/lib/services/state-util.ts deleted file mode 100644 index 730521c2a2..0000000000 --- a/src/lib/services/state-util.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as fs from 'fs'; -import * as mime from 'mime'; -import * as YAML from 'js-yaml'; - -export const readFile: (file: string) => Promise = (file) => - new Promise((resolve, reject) => - fs.readFile(file, (err, v) => - err ? reject(err) : resolve(v.toString('utf-8')), - ), - ); - -export const parseFile: (file: string, data: string) => any = ( - file: string, - data: string, -) => (mime.getType(file) === 'text/yaml' ? YAML.load(data) : JSON.parse(data)); - -export const filterExisting: ( - keepExisting: boolean, - existingArray: any[], -) => (item: any) => boolean = - (keepExisting, existingArray = []) => - (item) => { - if (keepExisting) { - const found = existingArray.find((t) => t.name === item.name); - return !found; - } - return true; - }; - -export const filterEqual: (existingArray: any[]) => (item: any) => boolean = - (existingArray = []) => - (item) => { - const toggle = existingArray.find((t) => t.name === item.name); - if (toggle) { - return JSON.stringify(toggle) !== JSON.stringify(item); - } - return true; - }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 1c3690dced..d5f199166d 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -1,7 +1,6 @@ import type { AccessService } from '../services/access-service'; import type AddonService from '../services/addon-service'; import type ProjectService from '../features/project/project-service'; -import type StateService from '../services/state-service'; import type StrategyService from '../services/strategy-service'; import type TagTypeService from '../features/tag-type/tag-type-service'; import type TagService from '../services/tag-service'; @@ -84,7 +83,6 @@ export interface IUnleashServices { resetTokenService: ResetTokenService; sessionService: SessionService; settingService: SettingService; - stateService: StateService; strategyService: StrategyService; tagService: TagService; tagTypeService: TagTypeService; diff --git a/src/test/e2e/api/admin/state.e2e.test.ts b/src/test/e2e/api/admin/state.e2e.test.ts deleted file mode 100644 index d84a8d90d2..0000000000 --- a/src/test/e2e/api/admin/state.e2e.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import dbInit, { type ITestDb } from '../../helpers/database-init'; -import { - type IUnleashTest, - setupAppWithCustomConfig, -} from '../../helpers/test-helper'; -import getLogger from '../../../fixtures/no-logger'; -import { DEFAULT_ENV } from '../../../../lib/util/constants'; -import { collectIds } from '../../../../lib/util/collect-ids'; -import { ApiTokenType } from '../../../../lib/types/models/api-token'; -import { - type IUser, - SYSTEM_USER_AUDIT, - TEST_AUDIT_USER, -} from '../../../../lib/types'; - -const importData = require('../../../examples/import.json'); - -let app: IUnleashTest; -let db: ITestDb; -const userId = -9999; - -beforeAll(async () => { - db = await dbInit('state_api_serial', getLogger); - app = await setupAppWithCustomConfig( - db.stores, - { - experimental: { - flags: { - strictSchemaValidation: true, - }, - }, - }, - db.rawDatabase, - ); -}); - -afterAll(async () => { - await app.destroy(); - await db.destroy(); -}); - -test('exports strategies and features as json by default', async () => { - expect.assertions(2); - - return app.request - .get('/api/admin/state/export') - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect('features' in res.body).toBe(true); - expect('strategies' in res.body).toBe(true); - }); -}); - -test('exports strategies and features as yaml', async () => { - return app.request - .get('/api/admin/state/export?format=yaml') - .expect('Content-Type', /yaml/) - .expect(200); -}); - -test('exports only features as yaml', async () => { - return app.request - .get('/api/admin/state/export?format=yaml&featureToggles=1') - .expect('Content-Type', /yaml/) - .expect(200); -}); - -test('exports strategies and features as attachment', async () => { - return app.request - .get('/api/admin/state/export?download=1') - .expect('Content-Type', /json/) - .expect('Content-Disposition', /attachment/) - .expect(200); -}); - -test('accepts "true" and "false" as parameter values', () => { - return app.request - .get('/api/admin/state/export?strategies=true&tags=false') - .expect(200); -}); - -test('imports strategies and features', async () => { - return app.request - .post('/api/admin/state/import') - .send(importData) - .expect(202); -}); - -test('imports features with variants', async () => { - await app.request - .post('/api/admin/state/import') - .send(importData) - .expect(202); - - const { body } = await app.request.get( - '/api/admin/projects/default/features/feature.with.variants', - ); - - expect(body.variants).toHaveLength(2); -}); - -test('does not not accept gibberish', async () => { - return app.request - .post('/api/admin/state/import') - .send({ features: 'nonsense' }) - .expect(400); -}); - -test('imports strategies and features from json file', async () => { - return app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/import.json') - .expect(202); -}); - -test('imports strategies and features from yaml file', async () => { - return app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/import.yml') - .expect(202); -}); - -test('import works for 3.17 json format', async () => { - await app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/exported3176.json') - .expect(202); -}); - -test('import works for 3.17 enterprise json format', async () => { - await app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/exported-3175-enterprise.json') - .expect(202); -}); -test('import works for 4.0 enterprise format', async () => { - await app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/exported405-enterprise.json') - .expect(202); -}); - -test('import for 4.1.2 enterprise format fails', async () => { - await expect(async () => - app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/exported412-enterprise.json') - .expect(202), - ).rejects; -}); - -test('import for 4.1.2 enterprise format fixed works', async () => { - await app.request - .post('/api/admin/state/import') - .attach( - 'file', - 'src/test/examples/exported412-enterprise-necessary-fixes.json', - ) - .expect(202); -}); - -test('Can roundtrip. I.e. export and then import', async () => { - const projectId = 'export-project'; - const environment = 'export-environment'; - const userName = 'export-user'; - const featureName = 'export.feature'; - await db.stores.environmentStore.create({ - name: environment, - type: 'test', - }); - await db.stores.projectStore.create({ - name: projectId, - id: projectId, - description: 'Project for export', - mode: 'open' as const, - }); - await app.services.environmentService.addEnvironmentToProject( - environment, - projectId, - SYSTEM_USER_AUDIT, - ); - await app.services.featureToggleServiceV2.createFeatureToggle( - projectId, - { - type: 'Release', - name: featureName, - description: 'Feature for export', - }, - TEST_AUDIT_USER, - ); - await app.services.featureToggleServiceV2.createStrategy( - { - name: 'default', - constraints: [ - { contextName: 'userId', operator: 'IN', values: ['123'] }, - ], - parameters: {}, - }, - { projectId, featureName, environment }, - TEST_AUDIT_USER, - { id: userId } as IUser, - ); - const data = await app.services.stateService.export({}); - await app.services.stateService.import({ - data, - dropBeforeImport: true, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - }); -}); - -test('Roundtrip with tags works', async () => { - const projectId = 'tags-project'; - const environment = 'tags-environment'; - const userName = 'tags-user'; - const featureName = 'tags.feature'; - await db.stores.environmentStore.create({ - name: environment, - type: 'test', - }); - await db.stores.projectStore.create({ - name: projectId, - id: projectId, - description: 'Project for export', - mode: 'open' as const, - }); - await app.services.environmentService.addEnvironmentToProject( - environment, - projectId, - SYSTEM_USER_AUDIT, - ); - await app.services.featureToggleServiceV2.createFeatureToggle( - projectId, - { - type: 'Release', - name: featureName, - description: 'Feature for export', - }, - TEST_AUDIT_USER, - ); - await app.services.featureToggleServiceV2.createStrategy( - { - name: 'default', - constraints: [ - { contextName: 'userId', operator: 'IN', values: ['123'] }, - ], - parameters: {}, - }, - { - projectId, - featureName, - environment, - }, - TEST_AUDIT_USER, - ); - await app.services.featureTagService.addTag( - featureName, - { type: 'simple', value: 'export-test' }, - TEST_AUDIT_USER, - ); - await app.services.featureTagService.addTag( - featureName, - { type: 'simple', value: 'export-test-2' }, - TEST_AUDIT_USER, - ); - const data = await app.services.stateService.export({}); - await app.services.stateService.import({ - data, - dropBeforeImport: true, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - }); - - const f = await app.services.featureTagService.listTags(featureName); - expect(f).toHaveLength(2); -}); - -test('Roundtrip with strategies in multiple environments works', async () => { - const projectId = 'multiple-environment-project'; - const environment = 'multiple-environment-environment'; - const userName = 'multiple-environment-user'; - const featureName = 'multiple-environment.feature'; - await db.stores.environmentStore.create({ - name: environment, - type: 'test', - }); - await db.stores.projectStore.create({ - name: projectId, - id: projectId, - description: 'Project for export', - mode: 'open' as const, - }); - - await app.services.featureToggleServiceV2.createFeatureToggle( - projectId, - { - type: 'Release', - name: featureName, - description: 'Feature for export', - }, - TEST_AUDIT_USER, - ); - await app.services.environmentService.addEnvironmentToProject( - environment, - projectId, - SYSTEM_USER_AUDIT, - ); - - await app.services.environmentService.addEnvironmentToProject( - DEFAULT_ENV, - projectId, - SYSTEM_USER_AUDIT, - ); - await app.services.featureToggleServiceV2.createStrategy( - { - name: 'default', - constraints: [ - { contextName: 'userId', operator: 'IN', values: ['123'] }, - ], - parameters: {}, - }, - { projectId, featureName, environment }, - TEST_AUDIT_USER, - ); - await app.services.featureToggleServiceV2.createStrategy( - { - name: 'default', - constraints: [ - { contextName: 'userId', operator: 'IN', values: ['123'] }, - ], - parameters: {}, - }, - { projectId, featureName, environment: DEFAULT_ENV }, - TEST_AUDIT_USER, - ); - const data = await app.services.stateService.export({}); - await app.services.stateService.import({ - data, - dropBeforeImport: true, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - }); - const f = await app.services.featureToggleServiceV2.getFeature({ - featureName, - }); - expect(f.environments).toHaveLength(4); // NOTE: this depends on other tests, otherwise it should be 2 -}); - -test(`Importing version 2 replaces :global: environment with 'default'`, async () => { - await app.request - .post('/api/admin/state/import?drop=true') - .attach('file', 'src/test/examples/exported412-version2.json') - .expect(202); - const env = await app.services.environmentService.get(DEFAULT_ENV); - expect(env).toBeTruthy(); - const feature = - await app.services.featureToggleServiceV2.getFeatureToggle( - 'this-is-fun', - ); - expect(feature.environments).toHaveLength(1); - expect(feature.environments[0].name).toBe(DEFAULT_ENV); -}); - -test(`should import segments and connect them to feature strategies`, async () => { - await app.request - .post('/api/admin/state/import') - .attach('file', 'src/test/examples/exported-segments.json') - .expect(202); - - const allSegments = await app.services.segmentService.getAll(); - const activeSegments = await db.stores.segmentReadModel.getActive(); - - expect(allSegments.length).toEqual(2); - expect(collectIds(allSegments)).toEqual([1, 2]); - expect(activeSegments.length).toEqual(1); - expect(collectIds(activeSegments)).toEqual([1]); -}); - -test(`should not delete api_tokens on import when drop-flag is set`, async () => { - const projectId = 'reimported-project'; - const environment = 'reimported-environment'; - const apiTokenName = 'not-dropped-token'; - const featureName = 'reimportedFeature'; - const userName = 'apiTokens-user'; - - await db.stores.environmentStore.create({ - name: environment, - type: 'test', - }); - await db.stores.projectStore.create({ - name: projectId, - id: projectId, - description: 'Project for export', - mode: 'open' as const, - }); - await app.services.environmentService.addEnvironmentToProject( - environment, - projectId, - SYSTEM_USER_AUDIT, - ); - await app.services.featureToggleServiceV2.createFeatureToggle( - projectId, - { - type: 'Release', - name: featureName, - description: 'Feature for export', - }, - TEST_AUDIT_USER, - ); - await app.services.apiTokenService.createApiTokenWithProjects({ - tokenName: apiTokenName, - type: ApiTokenType.CLIENT, - environment: environment, - projects: [projectId], - }); - - const data = await app.services.stateService.export({}); - await app.services.stateService.import({ - data, - dropBeforeImport: true, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - }); - - const apiTokens = await app.services.apiTokenService.getAllTokens(); - - expect(apiTokens.length).toEqual(1); - expect(apiTokens[0].username).toBe(apiTokenName); -}); - -test(`should not show environment on feature flag, when environment is disabled`, async () => { - await app.request - .post('/api/admin/state/import?drop=true') - .attach('file', 'src/test/examples/import-state.json') - .expect(202); - - const { body } = await app.request - .get('/api/admin/projects/default/features/my-feature') - .expect(200); - - const result = body.environments; - const dev = result.find((e) => e.name === 'development'); - expect(dev).toBeTruthy(); - expect(dev.enabled).toBe(true); - const prod = result.find((e) => e.name === 'production'); - expect(prod).toBeTruthy(); - expect(prod.enabled).toBe(false); -}); diff --git a/src/test/e2e/services/state-service.e2e.test.ts b/src/test/e2e/services/state-service.e2e.test.ts deleted file mode 100644 index c683e5e15a..0000000000 --- a/src/test/e2e/services/state-service.e2e.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createTestConfig } from '../../config/test-config'; -import dbInit, { type ITestDb } from '../helpers/database-init'; -import StateService from '../../../lib/services/state-service'; -import oldFormat from '../../examples/variantsexport_v3.json'; -import { WeightType } from '../../../lib/types/model'; -import { EventService } from '../../../lib/services'; -import { SYSTEM_USER_AUDIT, type IUnleashStores } from '../../../lib/types'; - -let stores: IUnleashStores; -let db: ITestDb; -let stateService: StateService; - -beforeAll(async () => { - const config = createTestConfig(); - db = await dbInit('state_service_serial', config.getLogger); - stores = db.stores; - const eventService = new EventService(stores, config); - stateService = new StateService(stores, config, eventService); -}); - -afterAll(async () => { - db.destroy(); -}); -test('Exporting featureEnvironmentVariants should work', async () => { - await stores.projectStore.create({ - id: 'fancy', - name: 'extra', - description: 'No surprises here', - }); - await stores.environmentStore.create({ - name: 'dev', - type: 'development', - }); - await stores.environmentStore.create({ - name: 'prod', - type: 'production', - }); - await stores.featureToggleStore.create('fancy', { - name: 'Some-feature', - createdByUserId: -1337, - }); - await stores.featureToggleStore.create('fancy', { - name: 'another-feature', - createdByUserId: -1337, - }); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'Some-feature', - 'dev', - true, - ); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'another-feature', - 'dev', - true, - ); - await stores.featureEnvironmentStore.addEnvironmentToFeature( - 'another-feature', - 'prod', - true, - ); - await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment( - 'Some-feature', - 'dev', - [ - { - name: 'blue', - weight: 333, - stickiness: 'default', - weightType: WeightType.VARIABLE, - }, - { - name: 'green', - weight: 333, - stickiness: 'default', - weightType: WeightType.VARIABLE, - }, - { - name: 'red', - weight: 333, - stickiness: 'default', - weightType: WeightType.VARIABLE, - }, - ], - ); - await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment( - 'another-feature', - 'dev', - [ - { - name: 'purple', - weight: 333, - stickiness: 'default', - weightType: 'variable', - }, - { - name: 'lilac', - weight: 333, - stickiness: 'default', - weightType: 'fix', - }, - { - name: 'azure', - weight: 333, - stickiness: 'default', - weightType: 'fix', - }, - ], - ); - await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment( - 'another-feature', - 'prod', - [ - { - name: 'purple', - weight: 333, - stickiness: 'default', - weightType: 'fix', - }, - { - name: 'lilac', - weight: 333, - stickiness: 'default', - weightType: 'fix', - }, - { - name: 'azure', - weight: 333, - stickiness: 'default', - weightType: 'variable', - }, - ], - ); - const exportedData = await stateService.export({}); - expect( - exportedData.featureEnvironments.find( - (fE) => fE.featureName === 'Some-feature', - )!.variants, - ).toHaveLength(3); -}); - -test('Should import variants from old format and convert to new format (per environment)', async () => { - await stateService.import({ - data: oldFormat, - keepExisting: false, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - const featureEnvironments = await stores.featureEnvironmentStore.getAll(); - expect(featureEnvironments).toHaveLength(6); // There are 3 environments enabled and 2 features - expect( - featureEnvironments - .filter((fE) => fE.featureName === 'variants-tester' && fE.enabled) - .every((e) => e.variants?.length === 4), - ).toBeTruthy(); -}); -test('Should import variants in new format (per environment)', async () => { - await stateService.import({ - data: oldFormat, - keepExisting: false, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - const exportedJson = await stateService.export({}); - await stateService.import({ - data: exportedJson, - keepExisting: false, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - const featureEnvironments = await stores.featureEnvironmentStore.getAll(); - expect(featureEnvironments).toHaveLength(6); // 3 environments, 2 features === 6 rows -}); - -test('Importing states with deprecated strategies should keep their deprecated state', async () => { - const deprecatedStrategyExample = { - version: 4, - features: [], - strategies: [ - { - name: 'deprecatedstrat', - description: 'This should be deprecated when imported', - deprecated: true, - parameters: [], - builtIn: false, - sortOrder: 9999, - displayName: 'Deprecated strategy', - }, - ], - featureStrategies: [], - }; - await stateService.import({ - data: deprecatedStrategyExample, - dropBeforeImport: true, - keepExisting: false, - auditUser: SYSTEM_USER_AUDIT, - }); - const deprecatedStrategy = - await stores.strategyStore.get('deprecatedstrat'); - expect(deprecatedStrategy.deprecated).toBe(true); -}); - -test('Exporting a deprecated strategy and then importing it should keep correct state', async () => { - await stateService.import({ - data: oldFormat, - keepExisting: false, - dropBeforeImport: true, - auditUser: SYSTEM_USER_AUDIT, - }); - const rolloutRandom = await stores.strategyStore.get( - 'gradualRolloutRandom', - ); - expect(rolloutRandom.deprecated).toBe(true); - const rolloutSessionId = await stores.strategyStore.get( - 'gradualRolloutSessionId', - ); - expect(rolloutSessionId.deprecated).toBe(true); - const rolloutUserId = await stores.strategyStore.get( - 'gradualRolloutUserId', - ); - expect(rolloutUserId.deprecated).toBe(true); -}); diff --git a/src/test/e2e/stores/feature-toggle-client-store.e2e.test.ts b/src/test/e2e/stores/feature-toggle-client-store.e2e.test.ts deleted file mode 100644 index d9cc7ce847..0000000000 --- a/src/test/e2e/stores/feature-toggle-client-store.e2e.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import dbInit, { type ITestDb } from '../helpers/database-init'; -import getLogger from '../../fixtures/no-logger'; -import { type IUnleashTest, setupApp } from '../helpers/test-helper'; -import type { - IFeatureToggleClientStore, - IUnleashStores, -} from '../../../lib/types'; - -let stores: IUnleashStores; -let app: IUnleashTest; -let db: ITestDb; -let clientFeatureToggleStore: IFeatureToggleClientStore; - -beforeAll(async () => { - getLogger.setMuteError(true); - db = await dbInit('feature_toggle_client_store_serial', getLogger); - app = await setupApp(db.stores); - stores = db.stores; - clientFeatureToggleStore = stores.clientFeatureToggleStore; -}); - -afterAll(async () => { - await app.destroy(); - await db.destroy(); -}); - -test('should be able to fetch client toggles', async () => { - const response = await app.request - .post('/api/admin/state/import?drop=true') - .attach('file', 'src/test/examples/exported-segments.json'); - - expect(response.status).toBe(202); - - const clientToggles = await clientFeatureToggleStore.getClient({}); - expect(clientToggles).toHaveLength(1); -}); diff --git a/website/docs/how-to/how-to-import-export.md b/website/docs/how-to/how-to-import-export.md index 7c18763bb6..45de93809b 100644 --- a/website/docs/how-to/how-to-import-export.md +++ b/website/docs/how-to/how-to-import-export.md @@ -31,6 +31,12 @@ Be careful when using the `drop` parameter in production environments: cleaning ### State Service {#state-service} +:::caution Removal notice + +State Service has been removed as of Unleash 6.0 + +::: + Unleash returns a StateService when started, you can use this to import and export data at any time. ```javascript