diff --git a/package.json b/package.json index b1ad57ba08..eea4e6b9cd 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "serve-favicon": "^2.5.0", "stoppable": "^1.1.0", "type-is": "^1.6.18", + "unleash-client": "^3.15.0", "unleash-frontend": "4.14.0-beta.0", "uuid": "^8.3.2" }, @@ -155,6 +156,7 @@ "eslint-plugin-import": "2.26.0", "eslint-plugin-prettier": "4.2.1", "faker": "5.5.3", + "fast-check": "^3.0.1", "fetch-mock": "9.11.0", "husky": "8.0.1", "jest": "27.5.1", diff --git a/src/lib/openapi/endpoint-descriptions.ts b/src/lib/openapi/endpoint-descriptions.ts index 96e1c9a92e..8e2dcfb1fd 100644 --- a/src/lib/openapi/endpoint-descriptions.ts +++ b/src/lib/openapi/endpoint-descriptions.ts @@ -11,5 +11,11 @@ export const endpointDescriptions = { 'Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.', summary: 'Get all events related to a specific feature toggle.', }, + playground: { + description: + 'Use the provided `context`, `environment`, and `projects` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.', + summary: + 'Evaluate an Unleash context against a set of environments and projects.', + }, }, } as const; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index d7c5a79c3d..c6020945a2 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -1,4 +1,5 @@ import { OpenAPIV3 } from 'openapi-types'; + import { addonParameterSchema } from './spec/addon-parameter-schema'; import { addonSchema } from './spec/addon-schema'; import { addonsSchema } from './spec/addons-schema'; @@ -60,11 +61,15 @@ import { passwordSchema } from './spec/password-schema'; import { patchesSchema } from './spec/patches-schema'; import { patchSchema } from './spec/patch-schema'; import { permissionSchema } from './spec/permission-schema'; +import { playgroundFeatureSchema } from './spec/playground-feature-schema'; +import { playgroundResponseSchema } from './spec/playground-response-schema'; +import { playgroundRequestSchema } from './spec/playground-request-schema'; import { projectEnvironmentSchema } from './spec/project-environment-schema'; import { projectSchema } from './spec/project-schema'; import { projectsSchema } from './spec/projects-schema'; import { resetPasswordSchema } from './spec/reset-password-schema'; import { roleSchema } from './spec/role-schema'; +import { sdkContextSchema } from './spec/sdk-context-schema'; import { segmentSchema } from './spec/segment-schema'; import { sortOrderSchema } from './spec/sort-order-schema'; import { splashSchema } from './spec/splash-schema'; @@ -93,6 +98,7 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema'; import { variantSchema } from './spec/variant-schema'; import { variantsSchema } from './spec/variants-schema'; import { versionSchema } from './spec/version-schema'; + import { IServerOption } from '../types'; import { URL } from 'url'; @@ -157,11 +163,15 @@ export const schemas = { patchesSchema, patchSchema, permissionSchema, + playgroundFeatureSchema, + playgroundResponseSchema, + playgroundRequestSchema, projectEnvironmentSchema, projectSchema, projectsSchema, resetPasswordSchema, roleSchema, + sdkContextSchema, segmentSchema, sortOrderSchema, splashSchema, diff --git a/src/lib/openapi/spec/playground-feature-schema.test.ts b/src/lib/openapi/spec/playground-feature-schema.test.ts new file mode 100644 index 0000000000..1db02069cf --- /dev/null +++ b/src/lib/openapi/spec/playground-feature-schema.test.ts @@ -0,0 +1,48 @@ +import fc, { Arbitrary } from 'fast-check'; +import { urlFriendlyString } from '../../../test/arbitraries.test'; +import { validateSchema } from '../validate'; +import { + playgroundFeatureSchema, + PlaygroundFeatureSchema, +} from './playground-feature-schema'; + +export const generate = (): Arbitrary => + fc.boolean().chain((isEnabled) => + fc.record({ + isEnabled: fc.constant(isEnabled), + projectId: urlFriendlyString(), + name: urlFriendlyString(), + variant: fc.record( + { + name: urlFriendlyString(), + enabled: fc.constant(isEnabled), + payload: fc.oneof( + fc.record({ + type: fc.constant('json' as 'json'), + value: fc.json(), + }), + fc.record({ + type: fc.constant('csv' as 'csv'), + value: fc + .array(fc.lorem()) + .map((words) => words.join(',')), + }), + fc.record({ + type: fc.constant('string' as 'string'), + value: fc.string(), + }), + ), + }, + { requiredKeys: ['name', 'enabled'] }, + ), + }), + ); + +test('playgroundFeatureSchema', () => + fc.assert( + fc.property( + generate(), + (data: PlaygroundFeatureSchema) => + validateSchema(playgroundFeatureSchema.$id, data) === undefined, + ), + )); diff --git a/src/lib/openapi/spec/playground-feature-schema.ts b/src/lib/openapi/spec/playground-feature-schema.ts new file mode 100644 index 0000000000..5eee0afc20 --- /dev/null +++ b/src/lib/openapi/spec/playground-feature-schema.ts @@ -0,0 +1,43 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const playgroundFeatureSchema = { + $id: '#/components/schemas/playgroundFeatureSchema', + description: + 'A simplified feature toggle model intended for the Unleash playground.', + type: 'object', + additionalProperties: false, + required: ['name', 'projectId', 'isEnabled', 'variant'], + properties: { + name: { type: 'string', examples: ['my-feature'] }, + projectId: { type: 'string', examples: ['my-project'] }, + isEnabled: { type: 'boolean', examples: [true] }, + variant: { + type: 'object', + additionalProperties: false, + required: ['name', 'enabled'], + properties: { + name: { type: 'string' }, + enabled: { type: 'boolean' }, + payload: { + type: 'object', + additionalProperties: false, + required: ['type', 'value'], + properties: { + type: { + type: 'string', + enum: ['json', 'csv', 'string'], + }, + value: { type: 'string' }, + }, + }, + }, + nullable: true, + examples: ['green'], + }, + }, + components: { schemas: {} }, +} as const; + +export type PlaygroundFeatureSchema = FromSchema< + typeof playgroundFeatureSchema +>; diff --git a/src/lib/openapi/spec/playground-request-schema.test.ts b/src/lib/openapi/spec/playground-request-schema.test.ts new file mode 100644 index 0000000000..0583720901 --- /dev/null +++ b/src/lib/openapi/spec/playground-request-schema.test.ts @@ -0,0 +1,32 @@ +import fc, { Arbitrary } from 'fast-check'; +import { urlFriendlyString } from '../../../test/arbitraries.test'; +import { + playgroundRequestSchema, + PlaygroundRequestSchema, +} from '../../../lib/openapi/spec/playground-request-schema'; +import { validateSchema } from '../validate'; +import { generate as generateContext } from './sdk-context-schema.test'; + +export const generate = (): Arbitrary => + fc.record({ + environment: fc.oneof( + fc.constantFrom('development', 'production', 'default'), + fc.lorem({ maxCount: 1 }), + ), + projects: fc.oneof( + fc.uniqueArray( + fc.oneof(fc.lorem({ maxCount: 1 }), urlFriendlyString()), + ), + fc.constant('*' as '*'), + ), + context: generateContext(), + }); + +test('playgroundRequestSchema', () => + fc.assert( + fc.property( + generate(), + (data: PlaygroundRequestSchema) => + validateSchema(playgroundRequestSchema.$id, data) === undefined, + ), + )); diff --git a/src/lib/openapi/spec/playground-request-schema.ts b/src/lib/openapi/spec/playground-request-schema.ts new file mode 100644 index 0000000000..def85ca996 --- /dev/null +++ b/src/lib/openapi/spec/playground-request-schema.ts @@ -0,0 +1,40 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { ALL } from '../../types/models/api-token'; +import { sdkContextSchema } from './sdk-context-schema'; + +export const playgroundRequestSchema = { + $id: '#/components/schemas/playgroundRequestSchema', + description: 'Data for the playground API to evaluate toggles', + type: 'object', + required: ['environment', 'context'], + properties: { + environment: { type: 'string', examples: ['development'] }, + projects: { + oneOf: [ + { + type: 'array', + items: { type: 'string' }, + examples: ['my-project', 'my-other-project'], + description: 'A list of projects to check for toggles in.', + }, + { + type: 'string', + enum: [ALL], + description: 'Check toggles in all projects.', + }, + ], + }, + context: { + $ref: sdkContextSchema.$id, + }, + }, + components: { + schemas: { + sdkContextSchema, + }, + }, +} as const; + +export type PlaygroundRequestSchema = FromSchema< + typeof playgroundRequestSchema +>; diff --git a/src/lib/openapi/spec/playground-response-schema.test.ts b/src/lib/openapi/spec/playground-response-schema.test.ts new file mode 100644 index 0000000000..e70f8ff168 --- /dev/null +++ b/src/lib/openapi/spec/playground-response-schema.test.ts @@ -0,0 +1,24 @@ +import fc, { Arbitrary } from 'fast-check'; +import { + playgroundResponseSchema, + PlaygroundResponseSchema, +} from '../../../lib/openapi/spec/playground-response-schema'; +import { validateSchema } from '../validate'; +import { generate as generateInput } from './playground-request-schema.test'; +import { generate as generateToggles } from './playground-feature-schema.test'; + +const generate = (): Arbitrary => + fc.record({ + input: generateInput(), + features: fc.array(generateToggles()), + }); + +test('playgroundResponseSchema', () => + fc.assert( + fc.property( + generate(), + (data: PlaygroundResponseSchema) => + validateSchema(playgroundResponseSchema.$id, data) === + undefined, + ), + )); diff --git a/src/lib/openapi/spec/playground-response-schema.ts b/src/lib/openapi/spec/playground-response-schema.ts new file mode 100644 index 0000000000..8cb1dd54ad --- /dev/null +++ b/src/lib/openapi/spec/playground-response-schema.ts @@ -0,0 +1,34 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { sdkContextSchema } from './sdk-context-schema'; +import { playgroundRequestSchema } from './playground-request-schema'; +import { playgroundFeatureSchema } from './playground-feature-schema'; + +export const playgroundResponseSchema = { + $id: '#/components/schemas/playgroundResponseSchema', + description: 'The state of all features given the provided input.', + type: 'object', + additionalProperties: false, + required: ['features', 'input'], + properties: { + input: { + $ref: playgroundRequestSchema.$id, + }, + features: { + type: 'array', + items: { + $ref: playgroundFeatureSchema.$id, + }, + }, + }, + components: { + schemas: { + sdkContextSchema, + playgroundRequestSchema, + playgroundFeatureSchema, + }, + }, +} as const; + +export type PlaygroundResponseSchema = FromSchema< + typeof playgroundResponseSchema +>; diff --git a/src/lib/openapi/spec/sdk-context-schema.test.ts b/src/lib/openapi/spec/sdk-context-schema.test.ts new file mode 100644 index 0000000000..e32af00afe --- /dev/null +++ b/src/lib/openapi/spec/sdk-context-schema.test.ts @@ -0,0 +1,27 @@ +import fc, { Arbitrary } from 'fast-check'; +import { validateSchema } from '../validate'; +import { SdkContextSchema, sdkContextSchema } from './sdk-context-schema'; +import { commonISOTimestamp } from '../../../test/arbitraries.test'; + +export const generate = (): Arbitrary => + fc.record( + { + appName: fc.string({ minLength: 1 }), + currentTime: commonISOTimestamp(), + environment: fc.string(), + properties: fc.dictionary(fc.string(), fc.string()), + remoteAddress: fc.ipV4(), + sessionId: fc.uuid(), + userId: fc.emailAddress(), + }, + { requiredKeys: ['appName'] }, + ); + +test('sdkContextSchema', () => + fc.assert( + fc.property( + generate(), + (data: SdkContextSchema) => + validateSchema(sdkContextSchema.$id, data) === undefined, + ), + )); diff --git a/src/lib/openapi/spec/sdk-context-schema.ts b/src/lib/openapi/spec/sdk-context-schema.ts new file mode 100644 index 0000000000..de390df859 --- /dev/null +++ b/src/lib/openapi/spec/sdk-context-schema.ts @@ -0,0 +1,47 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const sdkContextSchema = { + $id: '#/components/schemas/sdkContextSchema', + description: 'The Unleash context as modeled in client SDKs', + type: 'object', + additionalProperties: { + type: 'string', + examples: ['top-level custom context value'], + }, + required: ['appName'], + properties: { + appName: { + type: 'string', + minLength: 1, + examples: ['My cool application.'], + }, + currentTime: { + type: 'string', + format: 'date-time', + examples: ['2022-07-05T12:56:41+02:00'], + }, + environment: { type: 'string', deprecated: true }, + properties: { + type: 'object', + additionalProperties: { type: 'string' }, + examples: [ + { + customContextField: 'this is one!', + otherCustomField: 3, + }, + ], + }, + remoteAddress: { + type: 'string', + examples: ['192.168.1.1'], + }, + sessionId: { + type: 'string', + examples: ['b65e7b23-fec0-4814-a129-0e9861ef18fc'], + }, + userId: { type: 'string', examples: ['username@provider.com'] }, + }, + components: {}, +} as const; + +export type SdkContextSchema = FromSchema; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index dc0f4c72a8..37d5419f18 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -7,6 +7,7 @@ import { FeatureTypeController } from './feature-type'; import ArchiveController from './archive'; import StrategyController from './strategy'; import EventController from './event'; +import PlaygroundController from './playground'; import MetricsController from './metrics'; import UserController from './user'; import ConfigController from './config'; @@ -50,6 +51,10 @@ class AdminApi extends Controller { new StrategyController(config, services).router, ); this.app.use('/events', new EventController(config, services).router); + this.app.use( + '/playground', + new PlaygroundController(config, services).router, + ); this.app.use( '/metrics', new MetricsController(config, services).router, diff --git a/src/lib/routes/admin-api/playground.test.ts b/src/lib/routes/admin-api/playground.test.ts new file mode 100644 index 0000000000..da594a5065 --- /dev/null +++ b/src/lib/routes/admin-api/playground.test.ts @@ -0,0 +1,88 @@ +import fc from 'fast-check'; + +import supertest from 'supertest'; +import { createServices } from '../../services'; +import { createTestConfig } from '../../../test/config/test-config'; + +import createStores from '../../../test/fixtures/store'; + +import getApp from '../../app'; +import { + playgroundRequestSchema, + PlaygroundRequestSchema, +} from '../../../lib/openapi/spec/playground-request-schema'; + +import { generate as generateRequest } from '../../../lib/openapi/spec/playground-request-schema.test'; +import { clientFeatures } from '../../../test/arbitraries.test'; + +async function getSetup() { + const base = `/random${Math.round(Math.random() * 1000)}`; + const stores = createStores(); + const config = createTestConfig({ + server: { baseUriPath: base }, + }); + const services = createServices(stores, config); + const app = await getApp(config, stores, services); + return { base, request: supertest(app) }; +} +describe('toggle generator', () => { + it('generates toggles with unique names', () => { + fc.assert( + fc.property( + clientFeatures({ minLength: 2 }), + (toggles) => + toggles.length === + [...new Set(toggles.map((feature) => feature.name))].length, + ), + ); + }); +}); + +const testParams = { + interruptAfterTimeLimit: 4000, // Default timeout in Jest is 5000ms + markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure +}; +describe('the playground API', () => { + test('should return the provided input arguments as part of the response', async () => { + await fc.assert( + fc.asyncProperty( + generateRequest(), + async (payload: PlaygroundRequestSchema) => { + const { request, base } = await getSetup(); + const { body } = await request + .post(`${base}/api/admin/playground`) + .send(payload) + .expect('Content-Type', /json/) + .expect(200); + + expect(body.input).toStrictEqual(payload); + + return true; + }, + ), + testParams, + ); + }); + + test('should return 400 if any of the required query properties are missing', async () => { + await fc.assert( + fc.asyncProperty( + generateRequest(), + fc.constantFrom(...playgroundRequestSchema.required), + async (payload, requiredKey) => { + const { request, base } = await getSetup(); + + delete payload[requiredKey]; + + const { status } = await request + .post(`${base}/api/admin/playground`) + .send(payload) + .expect('Content-Type', /json/); + + return status === 400; + }, + ), + testParams, + ); + }); +}); diff --git a/src/lib/routes/admin-api/playground.ts b/src/lib/routes/admin-api/playground.ts new file mode 100644 index 0000000000..e614a08d4b --- /dev/null +++ b/src/lib/routes/admin-api/playground.ts @@ -0,0 +1,74 @@ +import { Request, Response } from 'express'; +import { IUnleashConfig } from '../../types/option'; +import { IUnleashServices } from '../../types/services'; +import { NONE } from '../../types/permissions'; +import Controller from '../controller'; +import { OpenApiService } from '../../services/openapi-service'; +import { createResponseSchema } from '../../openapi/util/create-response-schema'; +import { endpointDescriptions } from '../../openapi/endpoint-descriptions'; +import { getStandardResponses } from '../../../lib/openapi/util/standard-responses'; +import { createRequestSchema } from '../../../lib/openapi/util/create-request-schema'; +import { + PlaygroundResponseSchema, + playgroundResponseSchema, +} from '../../../lib/openapi/spec/playground-response-schema'; +import { PlaygroundRequestSchema } from '../../../lib/openapi/spec/playground-request-schema'; +import { PlaygroundService } from '../../../lib/services/playground-service'; + +export default class PlaygroundController extends Controller { + private openApiService: OpenApiService; + + private playgroundService: PlaygroundService; + + constructor( + config: IUnleashConfig, + { + openApiService, + playgroundService, + }: Pick, + ) { + super(config); + this.openApiService = openApiService; + this.playgroundService = playgroundService; + + this.route({ + method: 'post', + path: '', + handler: this.evaluateContext, + permission: NONE, + middleware: [ + openApiService.validPath({ + operationId: 'getPlayground', + tags: ['admin'], + responses: { + ...getStandardResponses(400, 401), + 200: createResponseSchema('playgroundResponseSchema'), + }, + requestBody: createRequestSchema('playgroundRequestSchema'), + ...endpointDescriptions.admin.playground, + }), + ], + }); + } + + async evaluateContext( + req: Request, + res: Response, + ): Promise { + const response: PlaygroundResponseSchema = { + input: req.body, + features: await this.playgroundService.evaluateQuery( + req.body.projects, + req.body.environment, + req.body.context, + ), + }; + + this.openApiService.respondWithValidation( + 200, + res, + playgroundResponseSchema.$id, + response, + ); + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 8c8491b18f..1c0502be68 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -31,6 +31,7 @@ import UserSplashService from './user-splash-service'; import { SegmentService } from './segment-service'; import { OpenApiService } from './openapi-service'; import { ClientSpecService } from './client-spec-service'; +import { PlaygroundService } from './playground-service'; export const createServices = ( stores: IUnleashStores, @@ -84,6 +85,9 @@ export const createServices = ( const userSplashService = new UserSplashService(stores, config); const openApiService = new OpenApiService(config); const clientSpecService = new ClientSpecService(config); + const playgroundService = new PlaygroundService(config, { + featureToggleServiceV2, + }); return { accessService, @@ -116,6 +120,7 @@ export const createServices = ( segmentService, openApiService, clientSpecService, + playgroundService, }; }; diff --git a/src/lib/services/playground-service.ts b/src/lib/services/playground-service.ts new file mode 100644 index 0000000000..a9f0bdb5fb --- /dev/null +++ b/src/lib/services/playground-service.ts @@ -0,0 +1,70 @@ +import FeatureToggleService from './feature-toggle-service'; +import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema'; +import { IUnleashServices } from 'lib/types/services'; +import { ALL } from '../../lib/types/models/api-token'; +import { PlaygroundFeatureSchema } from 'lib/openapi/spec/playground-feature-schema'; +import { Logger } from '../logger'; +import { IUnleashConfig } from 'lib/types'; +import { offlineUnleashClient } from '..//util/offline-unleash-client'; + +export class PlaygroundService { + private readonly logger: Logger; + + private readonly featureToggleService: FeatureToggleService; + + constructor( + config: IUnleashConfig, + { + featureToggleServiceV2, + }: Pick, + ) { + this.logger = config.getLogger('services/playground-service.ts'); + this.featureToggleService = featureToggleServiceV2; + } + + async evaluateQuery( + projects: typeof ALL | string[], + environment: string, + context: SdkContextSchema, + ): Promise { + const toggles = await this.featureToggleService.getClientFeatures({ + project: projects === ALL ? undefined : projects, + environment, + }); + + const [head, ...rest] = toggles; + if (!head) { + return []; + } else { + const client = await offlineUnleashClient( + [head, ...rest], + context, + this.logger.error, + ); + + const clientContext = { + ...context, + currentTime: context.currentTime + ? new Date(context.currentTime) + : undefined, + }; + const output: PlaygroundFeatureSchema[] = await Promise.all( + client.getFeatureToggleDefinitions().map(async (feature) => { + return { + isEnabled: client.isEnabled( + feature.name, + clientContext, + ), + projectId: await this.featureToggleService.getProjectId( + feature.name, + ), + variant: client.getVariant(feature.name), + name: feature.name, + }; + }), + ); + + return output; + } + } +} diff --git a/src/lib/types/allowed-strings.ts b/src/lib/types/allowed-strings.ts deleted file mode 100644 index d633b645c3..0000000000 --- a/src/lib/types/allowed-strings.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Create a string with allowed values from a values array. ['A', 'B'] => 'A' | 'B' -export type AllowedStrings> = - // eslint-disable-next-line @typescript-eslint/no-shadow - T extends ReadonlyArray ? AllowedStrings : never; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index c03547dca4..875419d7aa 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -3,9 +3,8 @@ import { LogProvider } from '../logger'; import { IRole } from './stores/access-store'; import { IUser } from './user'; import { ALL_OPERATORS } from '../util/constants'; -import { AllowedStrings } from './allowed-strings'; -export type Operator = AllowedStrings; +export type Operator = typeof ALL_OPERATORS[number]; export interface IConstraint { contextName: string; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 18facc88d5..9b588cfc69 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -27,6 +27,7 @@ import UserSplashService from '../services/user-splash-service'; import { SegmentService } from '../services/segment-service'; import { OpenApiService } from '../services/openapi-service'; import { ClientSpecService } from '../services/client-spec-service'; +import { PlaygroundService } from 'lib/services/playground-service'; export interface IUnleashServices { accessService: AccessService; @@ -59,4 +60,5 @@ export interface IUnleashServices { segmentService: SegmentService; openApiService: OpenApiService; clientSpecService: ClientSpecService; + playgroundService: PlaygroundService; } diff --git a/src/lib/util/offline-unleash-client.test.ts b/src/lib/util/offline-unleash-client.test.ts new file mode 100644 index 0000000000..03526c3ad1 --- /dev/null +++ b/src/lib/util/offline-unleash-client.test.ts @@ -0,0 +1,205 @@ +import { offlineUnleashClient } from './offline-unleash-client'; + +describe('offline client', () => { + it('considers enabled variants with a default strategy to be on', async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient( + [ + { + name, + enabled: true, + strategies: [{ name: 'default' }], + variants: [], + type: '', + stale: false, + }, + ], + { appName: 'other-app', environment: 'default' }, + console.log, + ); + + expect(client.isEnabled(name)).toBeTruthy(); + }); + + it('constrains on appName', async () => { + const enabledFeature = 'toggle-name'; + const disabledFeature = 'other-toggle'; + const appName = 'app-name'; + const client = await offlineUnleashClient( + [ + { + name: enabledFeature, + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: 'appName', + operator: 'IN', + values: [appName], + }, + ], + }, + ], + variants: [], + type: '', + stale: false, + }, + { + name: disabledFeature, + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: 'appName', + operator: 'IN', + values: ['otherApp'], + }, + ], + }, + ], + variants: [], + type: '', + stale: false, + }, + ], + { appName, environment: 'default' }, + console.log, + ); + + expect(client.isEnabled(enabledFeature)).toBeTruthy(); + expect(client.isEnabled(disabledFeature)).toBeFalsy(); + }); + + it('considers disabled variants with a default strategy to be off', async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient( + [ + { + strategies: [ + { + name: 'default', + }, + ], + stale: false, + enabled: false, + name, + type: 'experiment', + variants: [], + }, + ], + { appName: 'client-test' }, + console.log, + ); + + expect(client.isEnabled(name)).toBeFalsy(); + }); + + it('considers disabled variants with a default strategy and variants to be off', async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient( + [ + { + strategies: [ + { + name: 'default', + }, + ], + stale: false, + enabled: false, + name, + type: 'experiment', + variants: [ + { + name: 'a', + weight: 500, + weightType: 'variable', + stickiness: 'default', + overrides: [], + }, + { + name: 'b', + weight: 500, + weightType: 'variable', + stickiness: 'default', + overrides: [], + }, + ], + }, + ], + { appName: 'client-test' }, + console.log, + ); + + expect(client.isEnabled(name)).toBeFalsy(); + }); + + it("returns variant {name: 'disabled', enabled: false } if the toggle isn't enabled", async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient( + [ + { + strategies: [], + stale: false, + enabled: false, + name, + type: 'experiment', + variants: [ + { + name: 'a', + weight: 500, + weightType: 'variable', + stickiness: 'default', + overrides: [], + }, + { + name: 'b', + weight: 500, + weightType: 'variable', + stickiness: 'default', + overrides: [], + }, + ], + }, + ], + { appName: 'client-test' }, + + console.log, + ); + + expect(client.isEnabled(name)).toBeFalsy(); + expect(client.getVariant(name).name).toEqual('disabled'); + expect(client.getVariant(name).enabled).toBeFalsy(); + }); + + it('returns the disabled variant if there are no variants', async () => { + const name = 'toggle-name'; + const client = await offlineUnleashClient( + [ + { + strategies: [ + { + name: 'default', + constraints: [], + }, + ], + stale: false, + enabled: true, + name, + type: 'experiment', + variants: [], + }, + ], + { appName: 'client-test' }, + + console.log, + ); + + expect(client.getVariant(name, {}).name).toEqual('disabled'); + expect(client.getVariant(name, {}).enabled).toBeFalsy(); + expect(client.isEnabled(name, {})).toBeTruthy(); + }); +}); diff --git a/src/lib/util/offline-unleash-client.ts b/src/lib/util/offline-unleash-client.ts new file mode 100644 index 0000000000..2d13bccd24 --- /dev/null +++ b/src/lib/util/offline-unleash-client.ts @@ -0,0 +1,62 @@ +import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema'; +import { InMemStorageProvider, Unleash as UnleashClient } from 'unleash-client'; +import { FeatureConfigurationClient } from 'lib/types/stores/feature-strategies-store'; +import { Operator } from 'unleash-client/lib/strategy/strategy'; +import { once } from 'events'; + +enum PayloadType { + STRING = 'string', +} + +type NonEmptyList = [T, ...T[]]; + +const mapFeaturesForBootstrap = (features: FeatureConfigurationClient[]) => + features.map((feature) => ({ + impressionData: false, + ...feature, + variants: feature.variants.map((variant) => ({ + overrides: [], + ...variant, + payload: variant.payload && { + ...variant.payload, + type: variant.payload.type as unknown as PayloadType, + }, + })), + strategies: feature.strategies.map((strategy) => ({ + parameters: {}, + ...strategy, + constraints: + strategy.constraints && + strategy.constraints.map((constraint) => ({ + inverted: false, + values: [], + ...constraint, + operator: constraint.operator as unknown as Operator, + })), + })), + })); + +export const offlineUnleashClient = async ( + features: NonEmptyList, + context: SdkContextSchema, + logError: (message: any, ...args: any[]) => void, +): Promise => { + const client = new UnleashClient({ + ...context, + appName: context.appName, + disableMetrics: true, + refreshInterval: 0, + url: 'not-needed', + storageProvider: new InMemStorageProvider(), + bootstrap: { + data: mapFeaturesForBootstrap(features), + }, + }); + + client.on('error', logError); + client.start(); + + await once(client, 'ready'); + + return client; +}; diff --git a/src/test/arbitraries.test.ts b/src/test/arbitraries.test.ts new file mode 100644 index 0000000000..e6def58648 --- /dev/null +++ b/src/test/arbitraries.test.ts @@ -0,0 +1,159 @@ +import fc, { Arbitrary } from 'fast-check'; + +import { ALL_OPERATORS } from '../lib/util/constants'; +import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema'; +import { WeightType } from '../lib/types/model'; +import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema'; +import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema'; + +export const urlFriendlyString = (): Arbitrary => + fc + .array( + fc.oneof( + fc.integer({ min: 0x30, max: 0x39 }).map(String.fromCharCode), // numbers + fc.integer({ min: 0x41, max: 0x5a }).map(String.fromCharCode), // UPPERCASE LETTERS + fc.integer({ min: 0x61, max: 0x7a }).map(String.fromCharCode), // lowercase letters + fc.constantFrom('-', '_', '~', '.'), // rest + fc.lorem({ maxCount: 1 }), // random words for more 'realistic' names + ), + { minLength: 1 }, + ) + .map((arr) => arr.join('')); + +export const commonISOTimestamp = (): Arbitrary => + fc + .date({ + min: new Date('1900-01-01T00:00:00.000Z'), + max: new Date('9999-12-31T23:59:59.999Z'), + }) + .map((timestamp) => timestamp.toISOString()); + +const strategyConstraints = (): Arbitrary => + fc.array( + fc.record({ + contextName: urlFriendlyString(), + operator: fc.constantFrom(...ALL_OPERATORS), + caseInsensitive: fc.boolean(), + inverted: fc.boolean(), + values: fc.array(fc.string()), + value: fc.string(), + }), + ); + +export const strategy = ( + name: string, + parameters: Arbitrary>, +): Arbitrary => + fc.record({ + name: fc.constant(name), + parameters, + constraints: strategyConstraints(), + }); + +export const strategies = (): Arbitrary => + fc.array( + fc.oneof( + strategy('default', fc.constant({})), + strategy( + 'flexibleRollout', + fc.record({ + groupId: fc.lorem({ maxCount: 1 }), + rollout: fc.nat({ max: 100 }).map(String), + stickiness: fc.constantFrom( + 'default', + 'userId', + 'sessionId', + ), + }), + ), + strategy( + 'applicationHostname', + fc.record({ + hostNames: fc + .uniqueArray(fc.domain()) + .map((domains) => domains.join(',')), + }), + ), + + strategy( + 'userWithId', + fc.record({ + userIds: fc + .uniqueArray(fc.emailAddress()) + .map((ids) => ids.join(',')), + }), + ), + strategy( + 'remoteAddress', + fc.record({ + IPs: fc.uniqueArray(fc.ipV4()).map((ips) => ips.join(',')), + }), + ), + ), + ); + +export const clientFeature = (name?: string): Arbitrary => + fc.record( + { + name: name ? fc.constant(name) : urlFriendlyString(), + type: fc.constantFrom( + 'release', + 'kill-switch', + 'experiment', + 'operational', + 'permission', + ), + description: fc.lorem(), + project: urlFriendlyString(), + enabled: fc.boolean(), + createdAt: commonISOTimestamp(), + lastSeenAt: commonISOTimestamp(), + stale: fc.boolean(), + impressionData: fc.option(fc.boolean()), + strategies: strategies(), + variants: fc.array( + fc.record({ + name: urlFriendlyString(), + weight: fc.nat({ max: 1000 }), + weightType: fc.constant(WeightType.VARIABLE), + stickiness: fc.constant('default'), + payload: fc.option( + fc.oneof( + fc.record({ + type: fc.constant('json'), + value: fc.json(), + }), + fc.record({ + type: fc.constant('csv'), + value: fc + .array(fc.lorem()) + .map((words) => words.join(',')), + }), + fc.record({ + type: fc.constant('string'), + value: fc.string(), + }), + ), + ), + }), + ), + }, + { requiredKeys: ['name', 'enabled', 'project', 'strategies'] }, + ); + +export const clientFeatures = (constraints?: { + minLength?: number; +}): Arbitrary => + fc.uniqueArray(clientFeature(), { + ...constraints, + selector: (v) => v.name, + }); + +// TEST ARBITRARIES + +test('url-friendly strings are URL-friendly', () => + fc.assert( + fc.property(urlFriendlyString(), (input: string) => + /^[\w~.-]+$/.test(input), + ), + )); diff --git a/src/test/e2e/api/admin/playground.e2e.test.ts b/src/test/e2e/api/admin/playground.e2e.test.ts new file mode 100644 index 0000000000..c9a24a3b9f --- /dev/null +++ b/src/test/e2e/api/admin/playground.e2e.test.ts @@ -0,0 +1,580 @@ +import fc, { Arbitrary } from 'fast-check'; +import { clientFeature, clientFeatures } from '../../../arbitraries.test'; +import { generate as generateRequest } from '../../../../lib/openapi/spec/playground-request-schema.test'; +import dbInit, { ITestDb } from '../../helpers/database-init'; +import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; +import { FeatureToggle, WeightType } from '../../../../lib/types/model'; +import getLogger from '../../../fixtures/no-logger'; +import { + ApiTokenType, + IApiToken, +} from '../../../../lib/types/models/api-token'; +import { PlaygroundFeatureSchema } from 'lib/openapi/spec/playground-feature-schema'; +import { ClientFeatureSchema } from 'lib/openapi/spec/client-feature-schema'; +import { PlaygroundResponseSchema } from 'lib/openapi/spec/playground-response-schema'; +import { PlaygroundRequestSchema } from 'lib/openapi/spec/playground-request-schema'; + +let app: IUnleashTest; +let db: ITestDb; +let token: IApiToken; + +beforeAll(async () => { + db = await dbInit('playground_api_serial', getLogger); + app = await setupAppWithAuth(db.stores); + const { apiTokenService } = app.services; + token = await apiTokenService.createApiTokenWithProjects({ + type: ApiTokenType.ADMIN, + username: 'tester', + environment: '*', + projects: ['*'], + }); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +const reset = (database: ITestDb) => async () => { + await database.stores.featureToggleStore.deleteAll(); + await database.stores.environmentStore.deleteAll(); +}; + +const toArray = (x: T): [T] => [x]; + +const testParams = { + interruptAfterTimeLimit: 4000, // Default timeout in Jest 5000ms + markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure +}; + +const playgroundRequest = async ( + testApp: IUnleashTest, + secret: string, + request: PlaygroundRequestSchema, +): Promise => { + const { + body, + }: { + body: PlaygroundResponseSchema; + } = await testApp.request + .post('/api/admin/playground') + .set('Authorization', secret) + .send(request) + .expect(200); + + return body; +}; + +describe('Playground API E2E', () => { + // utility function for seeding the database before runs + const seedDatabase = ( + database: ITestDb, + features: ClientFeatureSchema[], + environment: string, + ): Promise => + Promise.all( + features.map(async (feature) => { + // create feature + const toggle = await database.stores.featureToggleStore.create( + feature.project, + { + ...feature, + createdAt: undefined, + variants: [ + ...(feature.variants ?? []).map((variant) => ({ + ...variant, + weightType: WeightType.VARIABLE, + stickiness: 'default', + })), + ], + }, + ); + + // create environment if necessary + await database.stores.environmentStore + .create({ + name: environment, + type: 'development', + enabled: true, + }) + .catch(() => { + // purposefully left empty: env creation may fail if the + // env already exists, and because of the async nature + // of things, this is the easiest way to make it work. + }); + + // assign strategies + await Promise.all( + (feature.strategies || []).map((strategy) => + database.stores.featureStrategiesStore.createStrategyFeatureEnv( + { + parameters: {}, + constraints: [], + ...strategy, + featureName: feature.name, + environment, + strategyName: strategy.name, + projectId: feature.project, + }, + ), + ), + ); + + // enable/disable the feature in environment + await database.stores.featureEnvironmentStore.addEnvironmentToFeature( + feature.name, + environment, + feature.enabled, + ); + + return toggle; + }), + ); + + test('Returned features should be a subset of the provided toggles', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeatures({ minLength: 1 }), + generateRequest(), + async (features, request) => { + // seed the database + await seedDatabase(db, features, request.environment); + + const body = await playgroundRequest( + app, + token.secret, + request, + ); + + // the returned list should always be a subset of the provided list + expect(features.map((feature) => feature.name)).toEqual( + expect.arrayContaining( + body.features.map((feature) => feature.name), + ), + ); + }, + ) + .afterEach(reset(db)), + testParams, + ); + }); + + test('should filter the list according to the input parameters', async () => { + await fc.assert( + fc + .asyncProperty( + generateRequest(), + clientFeatures({ minLength: 1 }), + async (request, features) => { + await seedDatabase(db, features, request.environment); + + // get a subset of projects that exist among the features + const [projects] = fc.sample( + fc.oneof( + fc.constant('*' as '*'), + fc.uniqueArray( + fc.constantFrom( + ...features.map( + (feature) => feature.project, + ), + ), + ), + ), + ); + + request.projects = projects; + + // create a list of features that can be filtered + // pass in args that should filter the list + const body = await playgroundRequest( + app, + token.secret, + request, + ); + + switch (projects) { + case '*': + // no features have been filtered out + return body.features.length === features.length; + case []: + // no feature should be without a project + return body.features.length === 0; + default: + // every feature should be in one of the prescribed projects + return body.features.every((feature) => + projects.includes(feature.projectId), + ); + } + }, + ) + .afterEach(reset(db)), + testParams, + ); + }); + + test('should map project and name correctly', async () => { + // note: we're not testing `isEnabled` and `variant` here, because + // that's the SDK's responsibility and it's tested elsewhere. + await fc.assert( + fc + .asyncProperty( + clientFeatures(), + fc.context(), + async (features, ctx) => { + await seedDatabase(db, features, 'default'); + + const body = await playgroundRequest( + app, + token.secret, + { + projects: '*', + environment: 'default', + context: { + appName: 'playground-test', + }, + }, + ); + + const createDict = (xs: { name: string }[]) => + xs.reduce( + (acc, next) => ({ ...acc, [next.name]: next }), + {}, + ); + + const mappedToggles = createDict(body.features); + + if (features.length !== body.features.length) { + ctx.log( + `I expected the number of mapped toggles (${body.features.length}) to be the same as the number of created toggles (${features.length}), but that was not the case.`, + ); + return false; + } + + return features.every((feature) => { + const mapped: PlaygroundFeatureSchema = + mappedToggles[feature.name]; + + expect(mapped).toBeTruthy(); + + return ( + feature.name === mapped.name && + feature.project === mapped.projectId + ); + }); + }, + ) + .afterEach(reset(db)), + testParams, + ); + }); + + describe('context application', () => { + it('applies appName constraints correctly', async () => { + const appNames = ['A', 'B', 'C']; + + // Choose one of the app names at random + const appName = () => fc.constantFrom(...appNames); + + // generate a list of features that are active only for a specific + // app name (constraints). Each feature will be constrained to a + // random appName from the list above. + const constrainedFeatures = (): Arbitrary => + fc.uniqueArray( + fc + .tuple( + clientFeature(), + fc.record({ + name: fc.constant('default'), + constraints: fc + .record({ + values: appName().map(toArray), + inverted: fc.constant(false), + operator: fc.constant('IN' as 'IN'), + contextName: fc.constant('appName'), + caseInsensitive: fc.boolean(), + }) + .map(toArray), + }), + ) + .map(([feature, strategy]) => ({ + ...feature, + enabled: true, + strategies: [strategy], + })), + { selector: (feature) => feature.name }, + ); + + await fc.assert( + fc + .asyncProperty( + fc + .tuple(appName(), generateRequest()) + .map(([generatedAppName, req]) => ({ + ...req, + // generate a context that has appName set to + // one of the above values + context: { + appName: generatedAppName, + environment: 'default', + }, + })), + constrainedFeatures(), + async (req, features) => { + await seedDatabase(db, features, req.environment); + const body = await playgroundRequest( + app, + token.secret, + req, + ); + + const shouldBeEnabled = features.reduce( + (acc, next) => ({ + ...acc, + [next.name]: + next.strategies[0].constraints[0] + .values[0] === req.context.appName, + }), + {}, + ); + + return body.features.every( + (feature) => + feature.isEnabled === + shouldBeEnabled[feature.name], + ); + }, + ) + .afterEach(reset(db)), + { + ...testParams, + examples: [], + }, + ); + }); + + it('applies dynamic context fields correctly', async () => { + const contextValue = () => + fc.oneof( + fc.record({ + name: fc.constant('remoteAddress'), + value: fc.ipV4(), + operator: fc.constant('IN' as 'IN'), + }), + fc.record({ + name: fc.constant('sessionId'), + value: fc.uuid(), + operator: fc.constant('IN' as 'IN'), + }), + fc.record({ + name: fc.constant('userId'), + value: fc.emailAddress(), + operator: fc.constant('IN' as 'IN'), + }), + ); + + const constrainedFeatures = (): Arbitrary => + fc.uniqueArray( + fc + .tuple( + clientFeature(), + contextValue().map((context) => ({ + name: 'default', + constraints: [ + { + values: [context.value], + inverted: false, + operator: context.operator, + contextName: context.name, + caseInsensitive: false, + }, + ], + })), + ) + .map(([feature, strategy]) => ({ + ...feature, + enabled: true, + strategies: [strategy], + })), + { selector: (feature) => feature.name }, + ); + await fc.assert( + fc + .asyncProperty( + fc + .tuple(contextValue(), generateRequest()) + .map(([generatedContextValue, req]) => ({ + ...req, + // generate a context that has a dynamic context field set to + // one of the above values + context: { + ...req.context, + [generatedContextValue.name]: + generatedContextValue.value, + }, + })), + constrainedFeatures(), + async (req, features) => { + await seedDatabase(db, features, 'default'); + + const body = await playgroundRequest( + app, + token.secret, + req, + ); + + const contextField = Object.values(req.context)[0]; + + const shouldBeEnabled = features.reduce( + (acc, next) => ({ + ...acc, + [next.name]: + next.strategies[0].constraints[0] + .values[0] === contextField, + }), + {}, + ); + return body.features.every( + (feature) => + feature.isEnabled === + shouldBeEnabled[feature.name], + ); + }, + ) + .afterEach(reset(db)), + testParams, + ); + }); + + it('applies custom context fields correctly', async () => { + const environment = 'default'; + const contextValue = () => + fc.record({ + name: fc.constantFrom('Context field A', 'Context field B'), + value: fc.constantFrom( + 'Context value 1', + 'Context value 2', + ), + }); + const constrainedFeatures = (): Arbitrary => + fc.uniqueArray( + fc + .tuple( + clientFeature(), + contextValue().map((context) => ({ + name: 'default', + constraints: [ + { + values: [context.value], + inverted: false, + operator: 'IN' as 'IN', + contextName: context.name, + caseInsensitive: false, + }, + ], + })), + ) + .map(([feature, strategy]) => ({ + ...feature, + enabled: true, + strategies: [strategy], + })), + { selector: (feature) => feature.name }, + ); + + // generate a constraint to be used for the context and a request + // that contains that constraint value. + const constraintAndRequest = () => + fc + .tuple( + contextValue(), + fc.constantFrom('top', 'nested'), + generateRequest(), + ) + .map(([generatedContextValue, placement, req]) => { + const request = + placement === 'top' + ? { + ...req, + environment, + context: { + ...req.context, + [generatedContextValue.name]: + generatedContextValue.value, + }, + } + : { + ...req, + environment, + context: { + ...req.context, + properties: { + [generatedContextValue.name]: + generatedContextValue.value, + }, + }, + }; + + return { + generatedContextValue, + request, + }; + }); + + await fc.assert( + fc + .asyncProperty( + constraintAndRequest(), + constrainedFeatures(), + fc.context(), + async ( + { generatedContextValue, request }, + features, + ctx, + ) => { + await seedDatabase(db, features, environment); + + const body = await playgroundRequest( + app, + token.secret, + request, + ); + + const shouldBeEnabled = features.reduce( + (acc, next) => { + const constraint = + next.strategies[0].constraints[0]; + + return { + ...acc, + [next.name]: + constraint.contextName === + generatedContextValue.name && + constraint.values[0] === + generatedContextValue.value, + }; + }, + {}, + ); + + ctx.log( + `Got these ${JSON.stringify( + body.features, + )} and I expect them to be enabled/disabled: ${JSON.stringify( + shouldBeEnabled, + )}`, + ); + + return body.features.every( + (feature) => + feature.isEnabled === + shouldBeEnabled[feature.name], + ); + }, + ) + .afterEach(reset(db)), + testParams, + ); + }); + }); +}); 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 02ad23ade4..a6fdf4fde6 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 @@ -1746,6 +1746,139 @@ Object { ], "type": "object", }, + "playgroundFeatureSchema": Object { + "additionalProperties": false, + "description": "A simplified feature toggle model intended for the Unleash playground.", + "properties": Object { + "isEnabled": Object { + "examples": Array [ + true, + ], + "type": "boolean", + }, + "name": Object { + "examples": Array [ + "my-feature", + ], + "type": "string", + }, + "projectId": Object { + "examples": Array [ + "my-project", + ], + "type": "string", + }, + "variant": Object { + "additionalProperties": false, + "examples": Array [ + "green", + ], + "nullable": true, + "properties": Object { + "enabled": Object { + "type": "boolean", + }, + "name": Object { + "type": "string", + }, + "payload": Object { + "additionalProperties": false, + "properties": Object { + "type": Object { + "enum": Array [ + "json", + "csv", + "string", + ], + "type": "string", + }, + "value": Object { + "type": "string", + }, + }, + "required": Array [ + "type", + "value", + ], + "type": "object", + }, + }, + "required": Array [ + "name", + "enabled", + ], + "type": "object", + }, + }, + "required": Array [ + "name", + "projectId", + "isEnabled", + "variant", + ], + "type": "object", + }, + "playgroundRequestSchema": Object { + "description": "Data for the playground API to evaluate toggles", + "properties": Object { + "context": Object { + "$ref": "#/components/schemas/sdkContextSchema", + }, + "environment": Object { + "examples": Array [ + "development", + ], + "type": "string", + }, + "projects": Object { + "oneOf": Array [ + Object { + "description": "A list of projects to check for toggles in.", + "examples": Array [ + "my-project", + "my-other-project", + ], + "items": Object { + "type": "string", + }, + "type": "array", + }, + Object { + "description": "Check toggles in all projects.", + "enum": Array [ + "*", + ], + "type": "string", + }, + ], + }, + }, + "required": Array [ + "environment", + "context", + ], + "type": "object", + }, + "playgroundResponseSchema": Object { + "additionalProperties": false, + "description": "The state of all features given the provided input.", + "properties": Object { + "features": Object { + "items": Object { + "$ref": "#/components/schemas/playgroundFeatureSchema", + }, + "type": "array", + }, + "input": Object { + "$ref": "#/components/schemas/playgroundRequestSchema", + }, + }, + "required": Array [ + "features", + "input", + ], + "type": "object", + }, "projectEnvironmentSchema": Object { "additionalProperties": false, "properties": Object { @@ -1849,6 +1982,69 @@ Object { ], "type": "object", }, + "sdkContextSchema": Object { + "additionalProperties": Object { + "examples": Array [ + "top-level custom context value", + ], + "type": "string", + }, + "description": "The Unleash context as modeled in client SDKs", + "properties": Object { + "appName": Object { + "examples": Array [ + "My cool application.", + ], + "minLength": 1, + "type": "string", + }, + "currentTime": Object { + "examples": Array [ + "2022-07-05T12:56:41+02:00", + ], + "format": "date-time", + "type": "string", + }, + "environment": Object { + "deprecated": true, + "type": "string", + }, + "properties": Object { + "additionalProperties": Object { + "type": "string", + }, + "examples": Array [ + Object { + "customContextField": "this is one!", + "otherCustomField": 3, + }, + ], + "type": "object", + }, + "remoteAddress": Object { + "examples": Array [ + "192.168.1.1", + ], + "type": "string", + }, + "sessionId": Object { + "examples": Array [ + "b65e7b23-fec0-4814-a129-0e9861ef18fc", + ], + "type": "string", + }, + "userId": Object { + "examples": Array [ + "username@provider.com", + ], + "type": "string", + }, + }, + "required": Array [ + "appName", + ], + "type": "object", + }, "segmentSchema": Object { "additionalProperties": false, "properties": Object { @@ -3701,6 +3897,45 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/playground": Object { + "post": Object { + "description": "Use the provided \`context\`, \`environment\`, and \`projects\` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.", + "operationId": "getPlayground", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/playgroundRequestSchema", + }, + }, + }, + "description": "playgroundRequestSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/playgroundResponseSchema", + }, + }, + }, + "description": "playgroundResponseSchema", + }, + "400": Object { + "description": "The request data does not match what we expect.", + }, + "401": Object { + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + }, + "summary": "Evaluate an Unleash context against a set of environments and projects.", + "tags": Array [ + "admin", + ], + }, + }, "/api/admin/projects": Object { "get": Object { "operationId": "getProjects", diff --git a/src/test/e2e/services/playground-service.test.ts b/src/test/e2e/services/playground-service.test.ts new file mode 100644 index 0000000000..9dc54085c8 --- /dev/null +++ b/src/test/e2e/services/playground-service.test.ts @@ -0,0 +1,157 @@ +import { PlaygroundService } from '../../../lib/services/playground-service'; +import { clientFeatures } from '../../arbitraries.test'; +import { generate as generateContext } from '../../../lib/openapi/spec/sdk-context-schema.test'; +import fc from 'fast-check'; +import { createTestConfig } from '../../config/test-config'; +import dbInit, { ITestDb } from '../helpers/database-init'; +import { IUnleashStores } from '../../../lib/types/stores'; +import FeatureToggleService from '../../../lib/services/feature-toggle-service'; +import { SegmentService } from '../../../lib/services/segment-service'; +import { WeightType } from '../../../lib/types/model'; +import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema'; +import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client'; + +let stores: IUnleashStores; +let db: ITestDb; +let service: PlaygroundService; +let featureToggleService: FeatureToggleService; + +beforeAll(async () => { + const config = createTestConfig(); + db = await dbInit('playground_service_serial', config.getLogger); + stores = db.stores; + featureToggleService = new FeatureToggleService( + stores, + config, + new SegmentService(stores, config), + ); + service = new PlaygroundService(config, { + featureToggleServiceV2: featureToggleService, + }); +}); + +afterAll(async () => { + await db.destroy(); +}); + +const testParams = { + interruptAfterTimeLimit: 4000, // Default timeout in Jest 5000ms + markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure +}; + +describe('the playground service (e2e)', () => { + const isDisabledVariant = ({ + name, + enabled, + }: { + name: string; + enabled: boolean; + }) => name === 'disabled' && !enabled; + + test('should return the same enabled toggles as the raw SDK correctly mapped', async () => { + await fc.assert( + fc + .asyncProperty( + clientFeatures({ minLength: 1 }), + generateContext(), + async (toggles, context) => { + await Promise.all( + toggles.map((feature) => + stores.featureToggleStore.create( + feature.project, + { + ...feature, + createdAt: undefined, + variants: [ + ...(feature.variants ?? []).map( + (variant) => ({ + ...variant, + weightType: + WeightType.VARIABLE, + stickiness: 'default', + }), + ), + ], + }, + ), + ), + ); + + const projects = '*'; + const env = 'default'; + + const serviceToggles: PlaygroundFeatureSchema[] = + await service.evaluateQuery(projects, env, context); + + const [head, ...rest] = + await featureToggleService.getClientFeatures(); + if (!head) { + return serviceToggles.length === 0; + } + + const client = await offlineUnleashClient( + [head, ...rest], + context, + console.log, + ); + + const clientContext = { + ...context, + + currentTime: context.currentTime + ? new Date(context.currentTime) + : undefined, + }; + + return serviceToggles.every((feature) => { + const enabledStateMatches = + feature.isEnabled === + client.isEnabled(feature.name, clientContext); + + // if x.isEnabled then variant should === variant.name. Otherwise it should be null + + // if x is disabled, then the variant will be the + // disabled variant. + if (!feature.isEnabled) { + return ( + enabledStateMatches && + isDisabledVariant(feature.variant) + ); + } + + const clientVariant = client.getVariant( + feature.name, + clientContext, + ); + + // if x is enabled, but its variant is the disabled + // variant, then the source does not have any + // variants + if ( + feature.isEnabled && + isDisabledVariant(feature.variant) + ) { + return ( + enabledStateMatches && + isDisabledVariant(clientVariant) + ); + } + + return ( + enabledStateMatches && + clientVariant.name === feature.variant.name && + clientVariant.enabled === + feature.variant.enabled && + clientVariant.payload === + feature.variant.payload + ); + }); + }, + ) + .afterEach(async () => { + await stores.featureToggleStore.deleteAll(); + }), + testParams, + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 99028366dd..94ed93a9f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1860,6 +1860,30 @@ cacache@^16.0.2: tar "^6.1.11" unique-filename "^1.1.1" +cacache@^16.1.0: + version "16.1.1" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.1.tgz#4e79fb91d3efffe0630d5ad32db55cc1b870669c" + integrity sha512-VDKN+LHyCQXaaYZ7rA/qtkURU+/yYhviUdvqEv2LT6QPZU8jpyzEkEVAcKlKLt5dJ5BRp11ym8lo3NKLluEPLg== + dependencies: + "@npmcli/fs" "^2.1.0" + "@npmcli/move-file" "^2.0.0" + chownr "^2.0.0" + fs-minipass "^2.1.0" + glob "^8.0.1" + infer-owner "^1.0.4" + lru-cache "^7.7.1" + minipass "^3.1.6" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + mkdirp "^1.0.4" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^9.0.0" + tar "^6.1.11" + unique-filename "^1.1.1" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz" @@ -3188,6 +3212,13 @@ faker@5.5.3: resolved "https://registry.npmjs.org/faker/-/faker-5.5.3.tgz" integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g== +fast-check@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.0.1.tgz#b9e7b57c4643a4e62893aca85e21c270591d0eac" + integrity sha512-AriFDYpYVOBynpPZq/quxSLumFOo2hPB2H5Nz2vc1QlNfjOaA62zX8USNXcOY5nwKHEq7lZ84dG9M1W+LAND1g== + dependencies: + pure-rand "^5.0.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -5239,6 +5270,28 @@ make-error@1.x, make-error@^1.1.1: resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +make-fetch-happen@^10.0.0: + version "10.1.8" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.8.tgz#3b6e93dd8d8fdb76c0d7bf32e617f37c3108435a" + integrity sha512-0ASJbG12Au6+N5I84W+8FhGS6iM8MyzvZady+zaQAu+6IOaESFzCLLD0AR1sAFF3Jufi8bxm586ABN6hWd3k7g== + dependencies: + agentkeepalive "^4.2.1" + cacache "^16.1.0" + http-cache-semantics "^4.1.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^7.7.1" + minipass "^3.1.6" + minipass-collect "^1.0.2" + minipass-fetch "^2.0.3" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.3" + promise-retry "^2.0.1" + socks-proxy-agent "^7.0.0" + ssri "^9.0.0" + make-fetch-happen@^10.1.2: version "10.1.2" resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.1.2.tgz" @@ -5612,6 +5665,11 @@ multer@^1.4.5-lts.1: type-is "^1.6.4" xtend "^4.0.0" +murmurhash3js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/murmurhash3js/-/murmurhash3js-3.0.1.tgz#3e983e5b47c2a06f43a713174e7e435ca044b998" + integrity sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow== + mustache@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz" @@ -6360,6 +6418,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pure-rand@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-5.0.1.tgz#97a287b4b4960b2a3448c0932bf28f2405cac51d" + integrity sha512-ksWccjmXOHU2gJBnH0cK1lSYdvSZ0zLoCMSz/nTGh6hDvCSgcRxDyIcOBD6KNxFz3xhMPm/T267Tbe2JRymKEQ== + qs@6.7.0: version "6.7.0" resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz" @@ -6948,6 +7011,15 @@ socks-proxy-agent@^6.1.1: debug "^4.3.3" socks "^2.6.2" +socks-proxy-agent@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6" + integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww== + dependencies: + agent-base "^6.0.2" + debug "^4.3.3" + socks "^2.6.2" + socks@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz" @@ -7770,6 +7842,16 @@ universalify@^2.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +unleash-client@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.15.0.tgz#6ba4d917a0d8d628e73267ae8114d261d210a1a9" + integrity sha512-pNfzJa7QWhtSMTGNhmanpgqjg3xIJK4gJgQiZdkJlUY6GPDXit8p4fGs94jC8zM/xzpa1ji9+sSx6GC9YDeCiQ== + dependencies: + ip "^1.1.5" + make-fetch-happen "^10.0.0" + murmurhash3js "^3.0.1" + semver "^7.3.5" + unleash-frontend@4.14.0-beta.0: version "4.14.0-beta.0" resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.0-beta.0.tgz#c68335f92f92494bdd25eb3aeb5f2dd9ce7950de"