From 468b923bdad3af30563235e40a0d6ad0375730a0 Mon Sep 17 00:00:00 2001 From: olav Date: Mon, 15 Aug 2022 16:04:13 +0200 Subject: [PATCH] feat: embed proxy endpoints --- package.json | 4 +- src/lib/openapi/index.ts | 8 + src/lib/openapi/spec/proxy-client-schema.ts | 50 ++ src/lib/openapi/spec/proxy-feature-schema.ts | 44 ++ src/lib/openapi/spec/proxy-features-schema.ts | 24 + src/lib/openapi/spec/proxy-metrics-schema.ts | 55 ++ src/lib/openapi/util/openapi-tags.ts | 5 + src/lib/proxy/create-context.test.ts | 78 +++ src/lib/proxy/create-context.ts | 34 + src/lib/proxy/proxy-repository.ts | 121 ++++ src/lib/routes/client-api/metrics.ts | 17 +- src/lib/routes/index.ts | 2 + src/lib/routes/proxy-api/index.ts | 177 ++++++ .../client-metrics/metrics-service-v2.ts | 14 + src/lib/services/index.ts | 7 + src/lib/services/project-service.ts | 8 +- src/lib/services/proxy-service.ts | 120 ++++ src/lib/types/services.ts | 2 + src/lib/util/offline-unleash-client.test.ts | 8 +- src/lib/util/offline-unleash-client.ts | 8 +- .../__snapshots__/openapi.e2e.test.ts.snap | 267 ++++++++ src/test/e2e/api/proxy/proxy.e2e.test.ts | 601 ++++++++++++++++++ src/test/e2e/helpers/database.json | 2 +- 23 files changed, 1625 insertions(+), 31 deletions(-) create mode 100644 src/lib/openapi/spec/proxy-client-schema.ts create mode 100644 src/lib/openapi/spec/proxy-feature-schema.ts create mode 100644 src/lib/openapi/spec/proxy-features-schema.ts create mode 100644 src/lib/openapi/spec/proxy-metrics-schema.ts create mode 100644 src/lib/proxy/create-context.test.ts create mode 100644 src/lib/proxy/create-context.ts create mode 100644 src/lib/proxy/proxy-repository.ts create mode 100644 src/lib/routes/proxy-api/index.ts create mode 100644 src/lib/services/proxy-service.ts create mode 100644 src/test/e2e/api/proxy/proxy.e2e.test.ts diff --git a/package.json b/package.json index fbea3c9548..199a8c6ca9 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", + "unleash-client": "3.15.0", "unleash-frontend": "4.15.0-beta.0", "uuid": "^8.3.2" }, @@ -173,8 +174,7 @@ "ts-jest": "27.1.5", "ts-node": "10.9.1", "tsc-watch": "5.0.3", - "typescript": "4.7.4", - "unleash-client": "3.15.0" + "typescript": "4.7.4" }, "resolutions": { "async": "^3.2.3", diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index edbdc4339f..22a7d2eb59 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -106,6 +106,10 @@ import { groupUserModelSchema } from './spec/group-user-model-schema'; import { usersGroupsBaseSchema } from './spec/users-groups-base-schema'; import { openApiTags } from './util/openapi-tags'; import { searchEventsSchema } from './spec/search-events-schema'; +import { proxyFeaturesSchema } from './spec/proxy-features-schema'; +import { proxyFeatureSchema } from './spec/proxy-feature-schema'; +import { proxyClientSchema } from './spec/proxy-client-schema'; +import { proxyMetricsSchema } from './spec/proxy-metrics-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -211,6 +215,10 @@ export const schemas = { variantSchema, variantsSchema, versionSchema, + proxyClientSchema, + proxyFeaturesSchema, + proxyFeatureSchema, + proxyMetricsSchema, }; // Schemas must have an $id property on the form "#/components/schemas/mySchema". diff --git a/src/lib/openapi/spec/proxy-client-schema.ts b/src/lib/openapi/spec/proxy-client-schema.ts new file mode 100644 index 0000000000..62360ee2c8 --- /dev/null +++ b/src/lib/openapi/spec/proxy-client-schema.ts @@ -0,0 +1,50 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const proxyClientSchema = { + $id: '#/components/schemas/proxyClientSchema', + type: 'object', + required: ['appName', 'interval', 'started', 'strategies'], + properties: { + appName: { + type: 'string', + description: 'Name of the application using Unleash', + }, + instanceId: { + type: 'string', + description: + 'Instance id for this application (typically hostname, podId or similar)', + }, + sdkVersion: { + type: 'string', + description: + 'Optional field that describes the sdk version (name:version)', + }, + environment: { + type: 'string', + deprecated: true, + }, + interval: { + type: 'number', + description: + 'At which interval, in milliseconds, will this client be expected to send metrics', + }, + started: { + oneOf: [ + { type: 'string', format: 'date-time' }, + { type: 'number' }, + ], + description: + 'When this client started. Should be reported as ISO8601 time.', + }, + strategies: { + type: 'array', + items: { + type: 'string', + }, + description: 'List of strategies implemented by this application', + }, + }, + components: {}, +} as const; + +export type ProxyClientSchema = FromSchema; diff --git a/src/lib/openapi/spec/proxy-feature-schema.ts b/src/lib/openapi/spec/proxy-feature-schema.ts new file mode 100644 index 0000000000..8aaa7d5abd --- /dev/null +++ b/src/lib/openapi/spec/proxy-feature-schema.ts @@ -0,0 +1,44 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const proxyFeatureSchema = { + $id: '#/components/schemas/proxyFeatureSchema', + type: 'object', + required: ['name', 'enabled', 'impressionData'], + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + enabled: { + type: 'boolean', + }, + impressionData: { + type: 'boolean', + }, + variant: { + type: 'object', + required: ['name', 'enabled'], + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + enabled: { + type: 'boolean', + }, + payload: { + type: 'object', + additionalProperties: false, + required: ['type', 'value'], + properties: { + type: { type: 'string', enum: ['string'] }, + value: { type: 'string' }, + }, + }, + }, + }, + }, + components: {}, +} as const; + +export type ProxyFeatureSchema = FromSchema; diff --git a/src/lib/openapi/spec/proxy-features-schema.ts b/src/lib/openapi/spec/proxy-features-schema.ts new file mode 100644 index 0000000000..1db700286c --- /dev/null +++ b/src/lib/openapi/spec/proxy-features-schema.ts @@ -0,0 +1,24 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { proxyFeatureSchema } from './proxy-feature-schema'; + +export const proxyFeaturesSchema = { + $id: '#/components/schemas/proxyFeaturesSchema', + type: 'object', + required: ['toggles'], + additionalProperties: false, + properties: { + toggles: { + type: 'array', + items: { + $ref: proxyFeatureSchema.$id, + }, + }, + }, + components: { + schemas: { + proxyFeatureSchema, + }, + }, +} as const; + +export type ProxyFeaturesSchema = FromSchema; diff --git a/src/lib/openapi/spec/proxy-metrics-schema.ts b/src/lib/openapi/spec/proxy-metrics-schema.ts new file mode 100644 index 0000000000..3fd8007985 --- /dev/null +++ b/src/lib/openapi/spec/proxy-metrics-schema.ts @@ -0,0 +1,55 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const proxyMetricsSchema = { + $id: '#/components/schemas/proxyMetricsSchema', + type: 'object', + required: ['appName', 'instanceId', 'bucket'], + properties: { + appName: { type: 'string' }, + instanceId: { type: 'string' }, + environment: { type: 'string' }, + bucket: { + type: 'object', + required: ['start', 'stop', 'toggles'], + properties: { + start: { type: 'string', format: 'date-time' }, + stop: { type: 'string', format: 'date-time' }, + toggles: { + type: 'object', + example: { + myCoolToggle: { + yes: 25, + no: 42, + variants: { + blue: 6, + green: 15, + red: 46, + }, + }, + myOtherToggle: { + yes: 0, + no: 100, + }, + }, + additionalProperties: { + type: 'object', + properties: { + yes: { type: 'integer', minimum: 0 }, + no: { type: 'integer', minimum: 0 }, + variants: { + type: 'object', + additionalProperties: { + type: 'integer', + minimum: 0, + }, + }, + }, + }, + }, + }, + }, + }, + components: {}, +} as const; + +export type ProxyMetricsSchema = FromSchema; diff --git a/src/lib/openapi/util/openapi-tags.ts b/src/lib/openapi/util/openapi-tags.ts index e73422ca16..238c7d85d9 100644 --- a/src/lib/openapi/util/openapi-tags.ts +++ b/src/lib/openapi/util/openapi-tags.ts @@ -72,6 +72,11 @@ const OPENAPI_TAGS = [ 'Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).', }, { name: 'Users', description: 'Manage users and passwords.' }, + { + name: 'Unstable', + description: + 'Experimental endpoints that may change or disappear at any time.', + }, ] as const; // make the export mutable, so it can be used in a schema diff --git a/src/lib/proxy/create-context.test.ts b/src/lib/proxy/create-context.test.ts new file mode 100644 index 0000000000..6e6b4ff4aa --- /dev/null +++ b/src/lib/proxy/create-context.test.ts @@ -0,0 +1,78 @@ +// Copy of https://github.com/Unleash/unleash-proxy/blob/main/src/test/create-context.test.ts. + +import { createContext } from './create-context'; + +test('should remove undefined properties', () => { + const context = createContext({ + appName: undefined, + userId: '123', + }); + + expect(context).not.toHaveProperty('appName'); + expect(context).toHaveProperty('userId'); + expect(context.userId).toBe('123'); +}); + +test('should move rest props to properties', () => { + const context = createContext({ + userId: '123', + tenantId: 'some-tenant', + region: 'eu', + }); + + expect(context.userId).toBe('123'); + expect(context).not.toHaveProperty('tenantId'); + expect(context).not.toHaveProperty('region'); + expect(context.properties?.region).toBe('eu'); + expect(context.properties?.tenantId).toBe('some-tenant'); +}); + +test('should keep properties', () => { + const context = createContext({ + userId: '123', + tenantId: 'some-tenant', + region: 'eu', + properties: { + a: 'b', + b: 'test', + }, + }); + + expect(context.userId).toBe('123'); + expect(context).not.toHaveProperty('tenantId'); + expect(context).not.toHaveProperty('region'); + expect(context.properties?.region).toBe('eu'); + expect(context.properties?.tenantId).toBe('some-tenant'); + expect(context.properties?.a).toBe('b'); + expect(context.properties?.b).toBe('test'); +}); + +test('will not blow up if properties is an array', () => { + const context = createContext({ + userId: '123', + tenantId: 'some-tenant', + region: 'eu', + properties: ['some'], + }); + + // console.log(context); + + expect(context.userId).toBe('123'); + expect(context).not.toHaveProperty('tenantId'); + expect(context).not.toHaveProperty('region'); +}); + +test.skip('will not blow up if userId is an array', () => { + const context = createContext({ + userId: ['123'], + tenantId: 'some-tenant', + region: 'eu', + properties: ['some'], + }); + + // console.log(context); + + expect(context.userId).toBe('123'); + expect(context).not.toHaveProperty('tenantId'); + expect(context).not.toHaveProperty('region'); +}); diff --git a/src/lib/proxy/create-context.ts b/src/lib/proxy/create-context.ts new file mode 100644 index 0000000000..41430a8dd1 --- /dev/null +++ b/src/lib/proxy/create-context.ts @@ -0,0 +1,34 @@ +// Copy of https://github.com/Unleash/unleash-proxy/blob/main/src/create-context.ts. + +/* eslint-disable prefer-object-spread */ +import { Context } from 'unleash-client'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function createContext(value: any): Context { + const { + appName, + environment, + userId, + sessionId, + remoteAddress, + properties, + ...rest + } = value; + + // move non root context fields to properties + const context: Context = { + appName, + environment, + userId, + sessionId, + remoteAddress, + properties: Object.assign({}, rest, properties), + }; + + // Clean undefined properties on the context + const cleanContext = Object.keys(context) + .filter((k) => context[k]) + .reduce((a, k) => ({ ...a, [k]: context[k] }), {}); + + return cleanContext; +} diff --git a/src/lib/proxy/proxy-repository.ts b/src/lib/proxy/proxy-repository.ts new file mode 100644 index 0000000000..55463c7279 --- /dev/null +++ b/src/lib/proxy/proxy-repository.ts @@ -0,0 +1,121 @@ +import EventEmitter from 'events'; +import { RepositoryInterface } from 'unleash-client/lib/repository'; +import { Segment } from 'unleash-client/lib/strategy/strategy'; +import { FeatureInterface } from 'unleash-client/lib/feature'; +import ApiUser from '../types/api-user'; +import { IUnleashConfig, IUnleashServices, IUnleashStores } from '../types'; +import { + mapFeaturesForClient, + mapSegmentsForClient, +} from '../util/offline-unleash-client'; +import { ALL_PROJECTS } from '../util/constants'; +import { UnleashEvents } from 'unleash-client'; +import { ANY_EVENT } from '../util/anyEventEmitter'; +import { Logger } from '../logger'; + +type Config = Pick; + +type Stores = Pick; + +type Services = Pick< + IUnleashServices, + 'featureToggleServiceV2' | 'segmentService' +>; + +export class ProxyRepository + extends EventEmitter + implements RepositoryInterface +{ + private readonly config: Config; + + private readonly logger: Logger; + + private readonly stores: Stores; + + private readonly services: Services; + + private readonly token: ApiUser; + + private features: FeatureInterface[]; + + private segments: Segment[]; + + constructor( + config: Config, + stores: Stores, + services: Services, + token: ApiUser, + ) { + super(); + this.config = config; + this.logger = config.getLogger('proxy-repository.ts'); + this.stores = stores; + this.services = services; + this.token = token; + this.onAnyEvent = this.onAnyEvent.bind(this); + } + + getSegment(id: number): Segment | undefined { + return this.segments.find((segment) => segment.id === id); + } + + getToggle(name: string): FeatureInterface { + return this.features.find((feature) => feature.name === name); + } + + getToggles(): FeatureInterface[] { + return this.features; + } + + async start(): Promise { + await this.loadDataForToken(); + + // Reload cached token data whenever something relevant has changed. + // For now, simply reload all the data on any EventStore event. + this.stores.eventStore.on(ANY_EVENT, this.onAnyEvent); + + this.emit(UnleashEvents.Ready); + this.emit(UnleashEvents.Changed); + } + + stop(): void { + this.stores.eventStore.off(ANY_EVENT, this.onAnyEvent); + } + + private async loadDataForToken() { + this.features = await this.featuresForToken(); + this.segments = await this.segmentsForToken(); + } + + private async onAnyEvent() { + try { + await this.loadDataForToken(); + } catch (error) { + this.logger.error(error); + } + } + + private async featuresForToken(): Promise { + return mapFeaturesForClient( + await this.services.featureToggleServiceV2.getClientFeatures({ + project: await this.projectNamesForToken(), + environment: this.token.environment, + }), + ); + } + + private async segmentsForToken(): Promise { + return mapSegmentsForClient( + await this.services.segmentService.getAll(), + ); + } + + private async projectNamesForToken(): Promise { + if (this.token.projects.includes(ALL_PROJECTS)) { + const allProjects = await this.stores.projectStore.getAll(); + return allProjects.map((project) => project.name); + } + + return this.token.projects; + } +} diff --git a/src/lib/routes/client-api/metrics.ts b/src/lib/routes/client-api/metrics.ts index 75f85879bc..4998588510 100644 --- a/src/lib/routes/client-api/metrics.ts +++ b/src/lib/routes/client-api/metrics.ts @@ -4,11 +4,7 @@ import { IUnleashConfig, IUnleashServices } from '../../types'; import ClientInstanceService from '../../services/client-metrics/instance-service'; import { Logger } from '../../logger'; import { IAuthRequest } from '../unleash-types'; -import ApiUser from '../../types/api-user'; -import { ALL } from '../../types/models/api-token'; import ClientMetricsServiceV2 from '../../services/client-metrics/metrics-service-v2'; -import { User } from '../../server-impl'; -import { IClientApp } from '../../types/model'; import { NONE } from '../../types/permissions'; import { OpenApiService } from '../../services/openapi-service'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; @@ -66,20 +62,9 @@ export default class ClientMetricsController extends Controller { }); } - private resolveEnvironment(user: User, data: IClientApp) { - if (user instanceof ApiUser) { - if (user.environment !== ALL) { - return user.environment; - } else if (user.environment === ALL && data.environment) { - return data.environment; - } - } - return 'default'; - } - async registerMetrics(req: IAuthRequest, res: Response): Promise { const { body: data, ip: clientIp, user } = req; - data.environment = this.resolveEnvironment(user, data); + data.environment = this.metricsV2.resolveMetricsEnvironment(user, data); await this.clientInstanceService.registerInstance(data, clientIp); try { diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 0449e57b58..02dae65fdb 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -9,6 +9,7 @@ const AdminApi = require('./admin-api'); const ClientApi = require('./client-api'); const Controller = require('./controller'); import { HealthCheckController } from './health-check'; +import ProxyController from './proxy-api'; class IndexRouter extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { @@ -26,6 +27,7 @@ class IndexRouter extends Controller { ); this.use('/api/admin', new AdminApi(config, services).router); this.use('/api/client', new ClientApi(config, services).router); + this.use('/api/frontend', new ProxyController(config, services).router); } } diff --git a/src/lib/routes/proxy-api/index.ts b/src/lib/routes/proxy-api/index.ts new file mode 100644 index 0000000000..de46c7cea1 --- /dev/null +++ b/src/lib/routes/proxy-api/index.ts @@ -0,0 +1,177 @@ +import { Response, Request } from 'express'; +import Controller from '../controller'; +import { IUnleashConfig, IUnleashServices } from '../../types'; +import { Logger } from '../../logger'; +import { OpenApiService } from '../../services/openapi-service'; +import { NONE } from '../../types/permissions'; +import { ProxyService } from '../../services/proxy-service'; +import ApiUser from '../../types/api-user'; +import { + proxyFeaturesSchema, + ProxyFeaturesSchema, +} from '../../openapi/spec/proxy-features-schema'; +import { Context } from 'unleash-client'; +import { createContext } from '../../proxy/create-context'; +import { ProxyMetricsSchema } from '../../openapi/spec/proxy-metrics-schema'; +import { ProxyClientSchema } from '../../openapi/spec/proxy-client-schema'; +import { createResponseSchema } from '../../openapi/util/create-response-schema'; +import { createRequestSchema } from '../../openapi/util/create-request-schema'; +import { emptyResponse } from '../../openapi/util/standard-responses'; + +interface ApiUserRequest< + PARAM = any, + ResBody = any, + ReqBody = any, + ReqQuery = any, +> extends Request { + user: ApiUser; +} + +export default class ProxyController extends Controller { + private readonly logger: Logger; + + private proxyService: ProxyService; + + private openApiService: OpenApiService; + + // TODO(olav): Add CORS config to all proxy endpoints. + constructor( + config: IUnleashConfig, + { + proxyService, + openApiService, + }: Pick, + ) { + super(config); + this.logger = config.getLogger('client-api/feature.js'); + this.proxyService = proxyService; + this.openApiService = openApiService; + + this.route({ + method: 'get', + path: '', + handler: this.getProxyFeatures, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getFrontendFeatures', + responses: { + 200: createResponseSchema('proxyFeaturesSchema'), + }, + }), + ], + }); + + this.route({ + method: 'post', + path: '', + handler: ProxyController.endpointNotImplemented, + permission: NONE, + }); + + this.route({ + method: 'get', + path: '/client/features', + handler: ProxyController.endpointNotImplemented, + permission: NONE, + }); + + this.route({ + method: 'post', + path: '/client/metrics', + handler: this.registerProxyMetrics, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'registerFrontendMetrics', + requestBody: createRequestSchema('proxyMetricsSchema'), + responses: { 200: emptyResponse }, + }), + ], + }); + + this.route({ + method: 'post', + path: '/client/register', + handler: ProxyController.registerProxyClient, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'registerFrontendClient', + requestBody: createRequestSchema('proxyClientSchema'), + responses: { 200: emptyResponse }, + }), + ], + }); + + this.route({ + method: 'get', + path: '/health', + handler: ProxyController.endpointNotImplemented, + permission: NONE, + }); + + this.route({ + method: 'get', + path: '/internal-backstage/prometheus', + handler: ProxyController.endpointNotImplemented, + permission: NONE, + }); + } + + private static async endpointNotImplemented( + req: ApiUserRequest, + res: Response, + ) { + res.status(405).json({ + message: 'The frontend API does not support this endpoint.', + }); + } + + private async getProxyFeatures( + req: ApiUserRequest, + res: Response, + ) { + const toggles = await this.proxyService.getProxyFeatures( + req.user, + ProxyController.createContext(req), + ); + this.openApiService.respondWithValidation( + 200, + res, + proxyFeaturesSchema.$id, + { toggles }, + ); + } + + private async registerProxyMetrics( + req: ApiUserRequest, + res: Response, + ) { + await this.proxyService.registerProxyMetrics( + req.user, + req.body, + req.ip, + ); + res.sendStatus(200); + } + + private static async registerProxyClient( + req: ApiUserRequest, + res: Response, + ) { + // Client registration is not yet supported by @unleash/proxy, + // but proxy clients may still expect a 200 from this endpoint. + res.sendStatus(200); + } + + private static createContext(req: ApiUserRequest): Context { + const { query } = req; + query.remoteAddress = query.remoteAddress || req.ip; + query.environment = req.user.environment; + return createContext(query); + } +} diff --git a/src/lib/services/client-metrics/metrics-service-v2.ts b/src/lib/services/client-metrics/metrics-service-v2.ts index a5aa4a2d6e..58bd43e41b 100644 --- a/src/lib/services/client-metrics/metrics-service-v2.ts +++ b/src/lib/services/client-metrics/metrics-service-v2.ts @@ -12,6 +12,9 @@ import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns'; import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store'; import EventEmitter from 'events'; import { CLIENT_METRICS } from '../../types/events'; +import ApiUser from '../../types/api-user'; +import { ALL } from '../../types/models/api-token'; +import User from '../../types/user'; export default class ClientMetricsServiceV2 { private timer: NodeJS.Timeout; @@ -122,6 +125,17 @@ export default class ClientMetricsServiceV2 { ); } + resolveMetricsEnvironment(user: User | ApiUser, data: IClientApp): string { + if (user instanceof ApiUser) { + if (user.environment !== ALL) { + return user.environment; + } else if (user.environment === ALL && data.environment) { + return data.environment; + } + } + return 'default'; + } + destroy(): void { clearInterval(this.timer); this.timer = null; diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index b6394e49b9..a43fd85c50 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -33,6 +33,7 @@ import { OpenApiService } from './openapi-service'; import { ClientSpecService } from './client-spec-service'; import { PlaygroundService } from './playground-service'; import { GroupService } from './group-service'; +import { ProxyService } from './proxy-service'; export const createServices = ( stores: IUnleashStores, config: IUnleashConfig, @@ -91,6 +92,11 @@ export const createServices = ( featureToggleServiceV2, segmentService, }); + const proxyService = new ProxyService(config, stores, { + featureToggleServiceV2, + clientMetricsServiceV2, + segmentService, + }); return { accessService, @@ -125,6 +131,7 @@ export const createServices = ( clientSpecService, playgroundService, groupService, + proxyService, }; }; diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 8da1bd49ac..14ea9e8487 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -1,4 +1,4 @@ -import User from '../types/user'; +import User, { IUser } from '../types/user'; import { AccessService } from './access-service'; import NameExistsError from '../error/name-exists-error'; import InvalidOperationError from '../error/invalid-operation-error'; @@ -48,7 +48,7 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems'; import { GroupService } from './group-service'; import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group'; -const getCreatedBy = (user: User) => user.email || user.username; +const getCreatedBy = (user: IUser) => user.email || user.username; export interface AccessWithRoles { users: IUserWithRole[]; @@ -130,8 +130,8 @@ export default class ProjectService { } async createProject( - newProject: Pick, - user: User, + newProject: Pick, + user: IUser, ): Promise { const data = await projectSchema.validateAsync(newProject); await this.validateUniqueId(data.id); diff --git a/src/lib/services/proxy-service.ts b/src/lib/services/proxy-service.ts new file mode 100644 index 0000000000..f28ebc7436 --- /dev/null +++ b/src/lib/services/proxy-service.ts @@ -0,0 +1,120 @@ +import { IUnleashConfig } from '../types/option'; +import { Logger } from '../logger'; +import { IUnleashServices, IUnleashStores } from '../types'; +import { ProxyFeatureSchema } from '../openapi/spec/proxy-feature-schema'; +import ApiUser from '../types/api-user'; +import { + Context, + InMemStorageProvider, + startUnleash, + Unleash, + UnleashEvents, +} from 'unleash-client'; +import { ProxyRepository } from '../proxy/proxy-repository'; +import assert from 'assert'; +import { ApiTokenType } from '../types/models/api-token'; +import { ProxyMetricsSchema } from '../openapi/spec/proxy-metrics-schema'; + +type Config = Pick; + +type Stores = Pick; + +type Services = Pick< + IUnleashServices, + 'featureToggleServiceV2' | 'segmentService' | 'clientMetricsServiceV2' +>; + +export class ProxyService { + private readonly config: Config; + + private readonly logger: Logger; + + private readonly stores: Stores; + + private readonly services: Services; + + private readonly clients: Map = new Map(); + + constructor(config: Config, stores: Stores, services: Services) { + this.config = config; + this.logger = config.getLogger('services/proxy-service.ts'); + this.stores = stores; + this.services = services; + } + + async getProxyFeatures( + token: ApiUser, + context: Context, + ): Promise { + const client = await this.clientForProxyToken(token); + const definitions = client.getFeatureToggleDefinitions() || []; + + return definitions + .filter((feature) => client.isEnabled(feature.name, context)) + .map((feature) => ({ + name: feature.name, + enabled: Boolean(feature.enabled), + variant: client.forceGetVariant(feature.name, context), + impressionData: Boolean(feature.impressionData), + })); + } + + async registerProxyMetrics( + token: ApiUser, + metrics: ProxyMetricsSchema, + ip: string, + ): Promise { + ProxyService.assertExpectedTokenType(token); + + const environment = + this.services.clientMetricsServiceV2.resolveMetricsEnvironment( + token, + metrics, + ); + + await this.services.clientMetricsServiceV2.registerClientMetrics( + { ...metrics, environment }, + ip, + ); + } + + private async clientForProxyToken(token: ApiUser): Promise { + ProxyService.assertExpectedTokenType(token); + + if (!this.clients.has(token.secret)) { + this.clients.set( + token.secret, + await this.createClientForProxyToken(token), + ); + } + + return this.clients.get(token.secret); + } + + private async createClientForProxyToken(token: ApiUser): Promise { + const repository = new ProxyRepository( + this.config, + this.stores, + this.services, + token, + ); + + const client = await startUnleash({ + appName: 'proxy', + url: 'unused', + storageProvider: new InMemStorageProvider(), + disableMetrics: true, + repository, + }); + + client.on(UnleashEvents.Error, (error) => { + this.logger.error(error); + }); + + return client; + } + + private static assertExpectedTokenType({ type }: ApiUser) { + assert(type === ApiTokenType.PROXY || type === ApiTokenType.ADMIN); + } +} diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 776fac81be..c5b45d61ff 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -29,6 +29,7 @@ import { OpenApiService } from '../services/openapi-service'; import { ClientSpecService } from '../services/client-spec-service'; import { PlaygroundService } from 'lib/services/playground-service'; import { GroupService } from '../services/group-service'; +import { ProxyService } from '../services/proxy-service'; export interface IUnleashServices { accessService: AccessService; @@ -63,4 +64,5 @@ export interface IUnleashServices { openApiService: OpenApiService; clientSpecService: ClientSpecService; playgroundService: PlaygroundService; + proxyService: ProxyService; } diff --git a/src/lib/util/offline-unleash-client.test.ts b/src/lib/util/offline-unleash-client.test.ts index 0181d69378..4973538cc9 100644 --- a/src/lib/util/offline-unleash-client.test.ts +++ b/src/lib/util/offline-unleash-client.test.ts @@ -1,7 +1,7 @@ import { ClientInitOptions, - mapFeaturesForBootstrap, - mapSegmentsForBootstrap, + mapFeaturesForClient, + mapSegmentsForClient, offlineUnleashClient, } from './offline-unleash-client'; import { @@ -25,8 +25,8 @@ export const offlineUnleashClientNode = async ({ url: 'not-needed', storageProvider: new InMemStorageProviderNode(), bootstrap: { - data: mapFeaturesForBootstrap(features), - segments: mapSegmentsForBootstrap(segments), + data: mapFeaturesForClient(features), + segments: mapSegmentsForClient(segments), }, }); diff --git a/src/lib/util/offline-unleash-client.ts b/src/lib/util/offline-unleash-client.ts index ae5197bcca..e3e0617b0d 100644 --- a/src/lib/util/offline-unleash-client.ts +++ b/src/lib/util/offline-unleash-client.ts @@ -13,7 +13,7 @@ enum PayloadType { type NonEmptyList = [T, ...T[]]; -export const mapFeaturesForBootstrap = ( +export const mapFeaturesForClient = ( features: FeatureConfigurationClient[], ): FeatureInterface[] => features.map((feature) => ({ @@ -41,7 +41,7 @@ export const mapFeaturesForBootstrap = ( })), })); -export const mapSegmentsForBootstrap = (segments: ISegment[]): Segment[] => +export const mapSegmentsForClient = (segments: ISegment[]): Segment[] => serializeDates(segments) as Segment[]; export type ClientInitOptions = { @@ -61,8 +61,8 @@ export const offlineUnleashClient = async ({ appName: context.appName, storageProvider: new InMemStorageProvider(), bootstrap: { - data: mapFeaturesForBootstrap(features), - segments: mapSegmentsForBootstrap(segments), + data: mapFeaturesForClient(features), + segments: mapSegmentsForClient(segments), }, }); 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 e1ee33c1a6..de549f36df 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 @@ -2129,6 +2129,201 @@ Object { ], "type": "object", }, + "proxyClientSchema": Object { + "properties": Object { + "appName": Object { + "description": "Name of the application using Unleash", + "type": "string", + }, + "environment": Object { + "deprecated": true, + "type": "string", + }, + "instanceId": Object { + "description": "Instance id for this application (typically hostname, podId or similar)", + "type": "string", + }, + "interval": Object { + "description": "At which interval, in milliseconds, will this client be expected to send metrics", + "type": "number", + }, + "sdkVersion": Object { + "description": "Optional field that describes the sdk version (name:version)", + "type": "string", + }, + "started": Object { + "description": "When this client started. Should be reported as ISO8601 time.", + "oneOf": Array [ + Object { + "format": "date-time", + "type": "string", + }, + Object { + "type": "number", + }, + ], + }, + "strategies": Object { + "description": "List of strategies implemented by this application", + "items": Object { + "type": "string", + }, + "type": "array", + }, + }, + "required": Array [ + "appName", + "interval", + "started", + "strategies", + ], + "type": "object", + }, + "proxyFeatureSchema": Object { + "additionalProperties": false, + "properties": Object { + "enabled": Object { + "type": "boolean", + }, + "impressionData": Object { + "type": "boolean", + }, + "name": Object { + "type": "string", + }, + "variant": Object { + "additionalProperties": false, + "properties": Object { + "enabled": Object { + "type": "boolean", + }, + "name": Object { + "type": "string", + }, + "payload": Object { + "additionalProperties": false, + "properties": Object { + "type": Object { + "enum": Array [ + "string", + ], + "type": "string", + }, + "value": Object { + "type": "string", + }, + }, + "required": Array [ + "type", + "value", + ], + "type": "object", + }, + }, + "required": Array [ + "name", + "enabled", + ], + "type": "object", + }, + }, + "required": Array [ + "name", + "enabled", + "impressionData", + ], + "type": "object", + }, + "proxyFeaturesSchema": Object { + "additionalProperties": false, + "properties": Object { + "toggles": Object { + "items": Object { + "$ref": "#/components/schemas/proxyFeatureSchema", + }, + "type": "array", + }, + }, + "required": Array [ + "toggles", + ], + "type": "object", + }, + "proxyMetricsSchema": Object { + "properties": Object { + "appName": Object { + "type": "string", + }, + "bucket": Object { + "properties": Object { + "start": Object { + "format": "date-time", + "type": "string", + }, + "stop": Object { + "format": "date-time", + "type": "string", + }, + "toggles": Object { + "additionalProperties": Object { + "properties": Object { + "no": Object { + "minimum": 0, + "type": "integer", + }, + "variants": Object { + "additionalProperties": Object { + "minimum": 0, + "type": "integer", + }, + "type": "object", + }, + "yes": Object { + "minimum": 0, + "type": "integer", + }, + }, + "type": "object", + }, + "example": Object { + "myCoolToggle": Object { + "no": 42, + "variants": Object { + "blue": 6, + "green": 15, + "red": 46, + }, + "yes": 25, + }, + "myOtherToggle": Object { + "no": 100, + "yes": 0, + }, + }, + "type": "object", + }, + }, + "required": Array [ + "start", + "stop", + "toggles", + ], + "type": "object", + }, + "environment": Object { + "type": "string", + }, + "instanceId": Object { + "type": "string", + }, + }, + "required": Array [ + "appName", + "instanceId", + "bucket", + ], + "type": "object", + }, "resetPasswordSchema": Object { "additionalProperties": false, "properties": Object { @@ -6404,6 +6599,74 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/frontend": Object { + "get": Object { + "operationId": "getFrontendFeatures", + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/proxyFeaturesSchema", + }, + }, + }, + "description": "proxyFeaturesSchema", + }, + }, + "tags": Array [ + "Unstable", + ], + }, + }, + "/api/frontend/client/metrics": Object { + "post": Object { + "operationId": "registerFrontendMetrics", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/proxyMetricsSchema", + }, + }, + }, + "description": "proxyMetricsSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "description": "This response has no body.", + }, + }, + "tags": Array [ + "Unstable", + ], + }, + }, + "/api/frontend/client/register": Object { + "post": Object { + "operationId": "registerFrontendClient", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/proxyClientSchema", + }, + }, + }, + "description": "proxyClientSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "description": "This response has no body.", + }, + }, + "tags": Array [ + "Unstable", + ], + }, + }, "/auth/reset/password": Object { "post": Object { "operationId": "changePassword", @@ -6637,6 +6900,10 @@ If the provided project does not exist, the list of events will be empty.", "description": "Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).", "name": "Tags", }, + Object { + "description": "Experimental endpoints that may change or disappear at any time.", + "name": "Unstable", + }, Object { "description": "Manage users and passwords.", "name": "Users", diff --git a/src/test/e2e/api/proxy/proxy.e2e.test.ts b/src/test/e2e/api/proxy/proxy.e2e.test.ts new file mode 100644 index 0000000000..9a53b34a2d --- /dev/null +++ b/src/test/e2e/api/proxy/proxy.e2e.test.ts @@ -0,0 +1,601 @@ +import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { randomId } from '../../../../lib/util/random-id'; +import { + ApiTokenType, + IApiToken, + IApiTokenCreate, +} from '../../../../lib/types/models/api-token'; +import { startOfHour } from 'date-fns'; +import { IStrategyConfig } from '../../../../lib/types/model'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('proxy', getLogger); + app = await setupAppWithAuth(db.stores); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => { + await db.stores.segmentStore.deleteAll(); + await db.stores.featureToggleStore.deleteAll(); + await db.stores.clientMetricsStoreV2.deleteAll(); + await db.stores.apiTokenStore.deleteAll(); +}); + +export const createApiToken = ( + type: ApiTokenType, + overrides: Partial> = {}, +): Promise => { + return app.services.apiTokenService.createApiTokenWithProjects({ + type, + projects: ['default'], + environment: 'default', + username: `${type}-token-${randomId()}`, + ...overrides, + }); +}; + +const createFeatureToggle = async ({ + name, + project = 'default', + environment = 'default', + strategies, + enabled, +}: { + name: string; + project?: string; + environment?: string; + strategies: IStrategyConfig[]; + enabled: boolean; +}): Promise => { + await app.services.featureToggleService.createFeatureToggle( + project, + { name }, + 'userName', + true, + ); + await Promise.all( + (strategies ?? []).map(async (s) => + app.services.featureToggleService.createStrategy( + s, + { projectId: project, featureName: name, environment }, + 'userName', + ), + ), + ); + await app.services.featureToggleService.updateEnabled( + project, + name, + environment, + enabled, + 'userName', + ); +}; + +const createProject = async (id: string): Promise => { + const user = await db.stores.userStore.insert({ + name: randomId(), + email: `${randomId()}@example.com`, + }); + await app.services.projectService.createProject({ id, name: id }, user); +}; + +test('should require a proxy token or an admin token', async () => { + await app.request + .get('/api/frontend') + .expect('Content-Type', /json/) + .expect(401); +}); + +test('should not allow requests with a client token', async () => { + const clientToken = await createApiToken(ApiTokenType.CLIENT); + await app.request + .get('/api/frontend') + .set('Authorization', clientToken.secret) + .expect('Content-Type', /json/) + .expect(403); +}); + +test('should allow requests with an admin token', async () => { + const adminToken = await createApiToken(ApiTokenType.ADMIN, { + projects: ['*'], + environment: '*', + }); + await app.request + .get('/api/frontend') + .set('Authorization', adminToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body).toEqual({ toggles: [] })); +}); + +test('should not allow admin requests with a proxy token', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .get('/api/admin/features') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(403); +}); + +test('should not allow client requests with a proxy token', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .get('/api/client/features') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(403); +}); + +test('should not allow requests with an invalid proxy token', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .get('/api/frontend') + .set('Authorization', proxyToken.secret.slice(0, -1)) + .expect('Content-Type', /json/) + .expect(401); +}); + +test('should allow requests with a proxy token', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .get('/api/frontend') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body).toEqual({ toggles: [] })); +}); + +test('should return 405 from unimplemented endpoints', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .post('/api/frontend') + .send({}) + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(405); + await app.request + .get('/api/frontend/client/features') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(405); + await app.request + .get('/api/frontend/health') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(405); + await app.request + .get('/api/frontend/internal-backstage/prometheus') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(405); +}); + +// TODO(olav): Test CORS config for all proxy endpoints. +test.todo('should enforce token CORS settings'); + +test('should accept client registration requests', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await app.request + .post('/api/frontend/client/register') + .set('Authorization', proxyToken.secret) + .send({}) + .expect('Content-Type', /json/) + .expect(400); + await app.request + .post('/api/frontend/client/register') + .set('Authorization', proxyToken.secret) + .send({ + appName: randomId(), + instanceId: randomId(), + sdkVersion: randomId(), + environment: 'default', + interval: 10000, + started: new Date(), + strategies: ['default'], + }) + .expect(200) + .expect((res) => expect(res.text).toEqual('OK')); +}); + +test('should store proxy client metrics', async () => { + const now = new Date(); + const appName = randomId(); + const instanceId = randomId(); + const featureName = randomId(); + const proxyToken = await createApiToken(ApiTokenType.PROXY); + const adminToken = await createApiToken(ApiTokenType.ADMIN, { + projects: ['*'], + environment: '*', + }); + await app.request + .get(`/api/admin/client-metrics/features/${featureName}`) + .set('Authorization', adminToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + featureName, + lastHourUsage: [], + maturity: 'stable', + seenApplications: [], + version: 1, + }); + }); + await app.request + .post('/api/frontend/client/metrics') + .set('Authorization', proxyToken.secret) + .send({ + appName, + instanceId, + bucket: { + start: now, + stop: now, + toggles: { [featureName]: { yes: 1, no: 10 } }, + }, + }) + .expect(200) + .expect((res) => expect(res.text).toEqual('OK')); + await app.request + .post('/api/frontend/client/metrics') + .set('Authorization', proxyToken.secret) + .send({ + appName, + instanceId, + bucket: { + start: now, + stop: now, + toggles: { [featureName]: { yes: 2, no: 20 } }, + }, + }) + .expect(200) + .expect((res) => expect(res.text).toEqual('OK')); + await app.request + .get(`/api/admin/client-metrics/features/${featureName}`) + .set('Authorization', adminToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + expect(res.body).toEqual({ + featureName, + lastHourUsage: [ + { + environment: 'default', + timestamp: startOfHour(now).toISOString(), + yes: 3, + no: 30, + }, + ], + maturity: 'stable', + seenApplications: [appName], + version: 1, + }); + }); +}); + +test('should filter features by enabled/disabled', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await createFeatureToggle({ + name: 'enabledFeature1', + enabled: true, + strategies: [{ name: 'default', constraints: [], parameters: {} }], + }); + await createFeatureToggle({ + name: 'enabledFeature2', + enabled: true, + strategies: [{ name: 'default', constraints: [], parameters: {} }], + }); + await createFeatureToggle({ + name: 'disabledFeature', + enabled: false, + strategies: [{ name: 'default', constraints: [], parameters: {} }], + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'enabledFeature1', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + { + name: 'enabledFeature2', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); +}); + +test('should filter features by strategies', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await createFeatureToggle({ + name: 'featureWithoutStrategies', + enabled: false, + strategies: [], + }); + await createFeatureToggle({ + name: 'featureWithUnknownStrategy', + enabled: true, + strategies: [{ name: 'unknown', constraints: [], parameters: {} }], + }); + await createFeatureToggle({ + name: 'featureWithMultipleStrategies', + enabled: true, + strategies: [ + { name: 'default', constraints: [], parameters: {} }, + { name: 'unknown', constraints: [], parameters: {} }, + ], + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureWithMultipleStrategies', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); +}); + +test('should filter features by constraints', async () => { + const proxyToken = await createApiToken(ApiTokenType.PROXY); + await createFeatureToggle({ + name: 'featureWithAppNameA', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { contextName: 'appName', operator: 'IN', values: ['a'] }, + ], + parameters: {}, + }, + ], + }); + await createFeatureToggle({ + name: 'featureWithAppNameAorB', + enabled: true, + strategies: [ + { + name: 'default', + constraints: [ + { + contextName: 'appName', + operator: 'IN', + values: ['a', 'b'], + }, + ], + parameters: {}, + }, + ], + }); + await app.request + .get('/api/frontend?appName=a') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.toggles).toHaveLength(2)); + await app.request + .get('/api/frontend?appName=b') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.toggles).toHaveLength(1)); + await app.request + .get('/api/frontend?appName=c') + .set('Authorization', proxyToken.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.toggles).toHaveLength(0)); +}); + +test('should filter features by project', async () => { + const projectA = 'projectA'; + const projectB = 'projectB'; + await createProject(projectA); + await createProject(projectB); + const proxyTokenDefault = await createApiToken(ApiTokenType.PROXY); + const proxyTokenProjectA = await createApiToken(ApiTokenType.PROXY, { + projects: [projectA], + }); + const proxyTokenProjectAB = await createApiToken(ApiTokenType.PROXY, { + projects: [projectA, projectB], + }); + await createFeatureToggle({ + name: 'featureInProjectDefault', + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await createFeatureToggle({ + name: 'featureInProjectA', + project: projectA, + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await createFeatureToggle({ + name: 'featureInProjectB', + project: projectB, + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenDefault.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInProjectDefault', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenProjectA.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInProjectA', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenProjectAB.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInProjectA', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + { + name: 'featureInProjectB', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); +}); + +test('should filter features by environment', async () => { + const environmentA = 'environmentA'; + const environmentB = 'environmentB'; + await db.stores.environmentStore.create({ + name: environmentA, + type: 'production', + }); + await db.stores.environmentStore.create({ + name: environmentB, + type: 'production', + }); + await app.services.environmentService.addEnvironmentToProject( + environmentA, + 'default', + ); + await app.services.environmentService.addEnvironmentToProject( + environmentB, + 'default', + ); + const proxyTokenEnvironmentDefault = await createApiToken( + ApiTokenType.PROXY, + ); + const proxyTokenEnvironmentA = await createApiToken(ApiTokenType.PROXY, { + environment: environmentA, + }); + const proxyTokenEnvironmentB = await createApiToken(ApiTokenType.PROXY, { + environment: environmentB, + }); + await createFeatureToggle({ + name: 'featureInEnvironmentDefault', + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await createFeatureToggle({ + name: 'featureInEnvironmentA', + environment: environmentA, + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await createFeatureToggle({ + name: 'featureInEnvironmentB', + environment: environmentB, + enabled: true, + strategies: [{ name: 'default', parameters: {} }], + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenEnvironmentDefault.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInEnvironmentDefault', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenEnvironmentA.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInEnvironmentA', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); + await app.request + .get('/api/frontend') + .set('Authorization', proxyTokenEnvironmentB.secret) + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ + toggles: [ + { + name: 'featureInEnvironmentB', + enabled: true, + impressionData: false, + variant: { enabled: false, name: 'disabled' }, + }, + ], + }); + }); +}); diff --git a/src/test/e2e/helpers/database.json b/src/test/e2e/helpers/database.json index 8430a289d4..d90668d3d0 100644 --- a/src/test/e2e/helpers/database.json +++ b/src/test/e2e/helpers/database.json @@ -19,7 +19,7 @@ "contextFields": [ { "name": "environment" }, { "name": "userId" }, - { "name": "appNam" } + { "name": "appName" } ], "projects": [ {