diff --git a/package.json b/package.json index e6869e84d2..21e19940ab 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/db/event-store.ts b/src/lib/db/event-store.ts index 013aacd34e..e0790473b1 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -1,10 +1,10 @@ -import { EventEmitter } from 'events'; import { Knex } from 'knex'; import { IEvent, IBaseEvent } from '../types/events'; import { LogProvider, Logger } from '../logger'; import { IEventStore } from '../types/stores/event-store'; import { ITag } from '../types/model'; import { SearchEventsSchema } from '../openapi/spec/search-events-schema'; +import { AnyEventEmitter } from '../util/anyEventEmitter'; const EVENT_COLUMNS = [ 'id', @@ -34,7 +34,7 @@ export interface IEventTable { const TABLE = 'events'; -class EventStore extends EventEmitter implements IEventStore { +class EventStore extends AnyEventEmitter implements IEventStore { private db: Knex; private logger: Logger; diff --git a/src/lib/experimental.ts b/src/lib/experimental.ts index 31de42a260..1ef05fa259 100644 --- a/src/lib/experimental.ts +++ b/src/lib/experimental.ts @@ -3,6 +3,7 @@ export interface IExperimentalOptions { clientFeatureMemoize?: IExperimentalToggle; userGroups?: boolean; anonymiseEventLog?: boolean; + embedProxy?: boolean; } export interface IExperimentalToggle { diff --git a/src/lib/middleware/api-token-middleware.test.ts b/src/lib/middleware/api-token-middleware.test.ts index d5ecb12d2a..1be03a31e6 100644 --- a/src/lib/middleware/api-token-middleware.test.ts +++ b/src/lib/middleware/api-token-middleware.test.ts @@ -65,6 +65,7 @@ test('should add user if known token', async () => { project: ALL, environment: ALL, type: ApiTokenType.CLIENT, + secret: 'a', }); const apiTokenService = { getUserForToken: jest.fn().mockReturnValue(apiUser), @@ -96,6 +97,7 @@ test('should not add user if not /api/client', async () => { project: ALL, environment: ALL, type: ApiTokenType.CLIENT, + secret: 'a', }); const apiTokenService = { @@ -134,6 +136,7 @@ test('should not add user if disabled', async () => { project: ALL, environment: ALL, type: ApiTokenType.CLIENT, + secret: 'a', }); const apiTokenService = { getUserForToken: jest.fn().mockReturnValue(apiUser), diff --git a/src/lib/middleware/api-token-middleware.ts b/src/lib/middleware/api-token-middleware.ts index 8c2aab9ab2..10740c149f 100644 --- a/src/lib/middleware/api-token-middleware.ts +++ b/src/lib/middleware/api-token-middleware.ts @@ -6,14 +6,19 @@ const isClientApi = ({ path }) => { return path && path.startsWith('/api/client'); }; +const isProxyApi = ({ path }) => { + return path && path.startsWith('/api/frontend'); +}; + export const TOKEN_TYPE_ERROR_MESSAGE = - 'invalid token: expected an admin token but got a client token instead'; + 'invalid token: expected a different token type for this endpoint'; const apiAccessMiddleware = ( { getLogger, authentication, - }: Pick, + experimental, + }: Pick, { apiTokenService }: any, ): any => { const logger = getLogger('/middleware/api-token.ts'); @@ -31,9 +36,14 @@ const apiAccessMiddleware = ( try { const apiToken = req.header('authorization'); const apiUser = apiTokenService.getUserForToken(apiToken); + const { CLIENT, PROXY } = ApiTokenType; if (apiUser) { - if (apiUser.type === ApiTokenType.CLIENT && !isClientApi(req)) { + if ( + (apiUser.type === CLIENT && !isClientApi(req)) || + (apiUser.type === PROXY && !isProxyApi(req)) || + (apiUser.type === PROXY && !experimental.embedProxy) + ) { res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE }); return; } diff --git a/src/lib/middleware/demo-authentication.ts b/src/lib/middleware/demo-authentication.ts index 2151fcd549..c988705764 100644 --- a/src/lib/middleware/demo-authentication.ts +++ b/src/lib/middleware/demo-authentication.ts @@ -47,6 +47,7 @@ function demoAuthentication( environment: 'default', type: ApiTokenType.CLIENT, project: '*', + secret: 'a', }); } next(); diff --git a/src/lib/middleware/rbac-middleware.test.ts b/src/lib/middleware/rbac-middleware.test.ts index 66c5916384..b07b9ef229 100644 --- a/src/lib/middleware/rbac-middleware.test.ts +++ b/src/lib/middleware/rbac-middleware.test.ts @@ -50,6 +50,7 @@ test('should give api-user ADMIN permission', async () => { project: '*', environment: '*', type: ApiTokenType.ADMIN, + secret: 'a', }), }; @@ -75,6 +76,7 @@ test('should not give api-user ADMIN permission', async () => { project: '*', environment: '*', type: ApiTokenType.CLIENT, + secret: 'a', }), }; 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/admin-api/api-def.json b/src/lib/routes/admin-api/api-def.json deleted file mode 100644 index 39cf712281..0000000000 --- a/src/lib/routes/admin-api/api-def.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "version": 3, - "links": { - "feature-toggles": { - "uri": "/api/admin/features" - }, - "feature-archive": { - "uri": "/api/admin/archive" - }, - "strategies": { - "uri": "/api/admin/strategies" - }, - "events": { - "uri": "/api/admin/events" - }, - "metrics": { - "uri": "/api/admin/metrics" - }, - "state": { - "uri": "/api/admin/state" - }, - "context": { - "uri": "/api/admin/context" - }, - "tags": { - "uri": "/api/admin/tags" - }, - "tag-types": { - "uri": "/api/admin/tag-types" - } - } -} diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 2a140a8032..2bfcc85693 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -1,4 +1,3 @@ -import apiDef from './api-def.json'; import Controller from '../controller'; import { IUnleashServices } from '../../types/services'; import { IUnleashConfig } from '../../types/option'; @@ -30,8 +29,6 @@ class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { super(config); - this.app.get('/', this.index); - this.app.use( '/features', new FeatureController(config, services).router, @@ -105,10 +102,6 @@ class AdminApi extends Controller { new ConstraintsController(config, services).router, ); } - - index(req, res) { - res.json(apiDef); - } } module.exports = AdminApi; diff --git a/src/lib/routes/api-def.ts b/src/lib/routes/api-def.ts deleted file mode 100644 index a558b6d779..0000000000 --- a/src/lib/routes/api-def.ts +++ /dev/null @@ -1,19 +0,0 @@ -import clientApiDef from './client-api/api-def.json'; -import adminApiDef from './admin-api/api-def.json'; -import version from '../util/version'; - -export const api = { - name: 'unleash-server', - version, - uri: '/api', - links: { - admin: { - uri: '/api/admin', - links: adminApiDef.links, - }, - client: { - uri: '/api/client', - links: clientApiDef.links, - }, - }, -}; diff --git a/src/lib/routes/client-api/api-def.json b/src/lib/routes/client-api/api-def.json deleted file mode 100644 index d280a114cb..0000000000 --- a/src/lib/routes/client-api/api-def.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 3, - "links": { - "feature-toggles": { - "uri": "/api/client/features" - }, - "register": { - "uri": "/api/client/register" - }, - "metrics": { - "uri": "/api/client/metrics" - } - } -} diff --git a/src/lib/routes/client-api/index.ts b/src/lib/routes/client-api/index.ts index 7265ce289f..4eff426ae5 100644 --- a/src/lib/routes/client-api/index.ts +++ b/src/lib/routes/client-api/index.ts @@ -1,4 +1,3 @@ -import { Request, Response } from 'express'; import Controller from '../controller'; import FeatureController from './feature'; import MetricsController from './metrics'; @@ -6,21 +5,14 @@ import RegisterController from './register'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types'; -const apiDef = require('./api-def.json'); - export default class ClientApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { super(config); - this.get('/', this.index); this.use('/features', new FeatureController(services, config).router); this.use('/metrics', new MetricsController(services, config).router); this.use('/register', new RegisterController(services, config).router); } - - index(req: Request, res: Response): void { - res.json(apiDef); - } } module.exports = ClientApi; 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.test.ts b/src/lib/routes/index.test.ts deleted file mode 100644 index dd68c1a014..0000000000 --- a/src/lib/routes/index.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import supertest from 'supertest'; -import { createTestConfig } from '../../test/config/test-config'; -import createStores from '../../test/fixtures/store'; -import getApp from '../app'; -import { createServices } from '../services'; - -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), - destroy: () => { - services.versionService.destroy(); - services.clientInstanceService.destroy(); - services.apiTokenService.destroy(); - }, - }; -} - -let base; -let request; -let destroy; -beforeEach(async () => { - const setup = await getSetup(); - base = setup.base; - request = setup.request; - destroy = setup.destroy; -}); - -afterEach(() => { - destroy(); -}); - -test('api definition', () => { - expect.assertions(5); - return request - .get(`${base}/api/`) - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect(res.body).toBeTruthy(); - const { admin, client } = res.body.links; - expect(admin.uri === '/api/admin').toBe(true); - expect(client.uri === '/api/client').toBe(true); - expect( - admin.links['feature-toggles'].uri === '/api/admin/features', - ).toBe(true); - expect(client.links.metrics.uri === '/api/client/metrics').toBe( - true, - ); - }); -}); - -test('admin api defintion', () => { - expect.assertions(2); - return request - .get(`${base}/api/admin`) - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect(res.body).toBeTruthy(); - expect( - res.body.links['feature-toggles'].uri === '/api/admin/features', - ).toBe(true); - }); -}); - -test('client api defintion', () => { - expect.assertions(2); - return request - .get(`${base}/api/client`) - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect(res.body).toBeTruthy(); - expect(res.body.links.metrics.uri === '/api/client/metrics').toBe( - true, - ); - }); -}); diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index cc1ecea13c..410590c49a 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -1,16 +1,16 @@ -import { Request, Response } from 'express'; import { BackstageController } from './backstage'; import ResetPasswordController from './auth/reset-password-controller'; import { SimplePasswordProvider } from './auth/simple-password-provider'; import { IUnleashConfig } from '../types/option'; import { IUnleashServices } from '../types/services'; -import { api } from './api-def'; import LogoutController from './logout'; 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) { super(config); @@ -25,13 +25,15 @@ class IndexRouter extends Controller { '/auth/reset', new ResetPasswordController(config, services).router, ); - this.get(api.uri, this.index); - this.use(api.links.admin.uri, new AdminApi(config, services).router); - this.use(api.links.client.uri, new ClientApi(config, services).router); - } + this.use('/api/admin', new AdminApi(config, services).router); + this.use('/api/client', new ClientApi(config, services).router); - async index(req: Request, res: Response): Promise { - res.json(api); + if (config.experimental.embedProxy) { + 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/api-token-service.ts b/src/lib/services/api-token-service.ts index 3916d5201e..42680b2806 100644 --- a/src/lib/services/api-token-service.ts +++ b/src/lib/services/api-token-service.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { Logger } from '../logger'; -import { ADMIN, CLIENT } from '../types/permissions'; +import { ADMIN, CLIENT, PROXY } from '../types/permissions'; import { IUnleashStores } from '../types/stores'; import { IUnleashConfig } from '../types/option'; import ApiUser from '../types/api-user'; @@ -20,6 +20,22 @@ import BadDataError from '../error/bad-data-error'; import { minutesToMilliseconds } from 'date-fns'; import { IEnvironmentStore } from 'lib/types/stores/environment-store'; +const resolveTokenPermissions = (tokenType: string) => { + if (tokenType === ApiTokenType.ADMIN) { + return [ADMIN]; + } + + if (tokenType === ApiTokenType.CLIENT) { + return [CLIENT]; + } + + if (tokenType === ApiTokenType.PROXY) { + return [PROXY]; + } + + return []; +}; + export class ApiTokenService { private store: IApiTokenStore; @@ -88,15 +104,13 @@ export class ApiTokenService { public getUserForToken(secret: string): ApiUser | undefined { const token = this.activeTokens.find((t) => t.secret === secret); if (token) { - const permissions = - token.type === ApiTokenType.ADMIN ? [ADMIN] : [CLIENT]; - return new ApiUser({ username: token.username, - permissions, + permissions: resolveTokenPermissions(token.type), projects: token.projects, environment: token.environment, type: token.type, + secret: token.secret, }); } return undefined; 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/api-user.ts b/src/lib/types/api-user.ts index 8514edd6d0..dfd325feb7 100644 --- a/src/lib/types/api-user.ts +++ b/src/lib/types/api-user.ts @@ -8,6 +8,7 @@ interface IApiUserData { project?: string; environment: string; type: ApiTokenType; + secret: string; } export default class ApiUser { @@ -23,6 +24,8 @@ export default class ApiUser { readonly type: ApiTokenType; + readonly secret: string; + constructor({ username, permissions = [CLIENT], @@ -30,6 +33,7 @@ export default class ApiUser { project, environment, type, + secret, }: IApiUserData) { if (!username) { throw new TypeError('username is required'); @@ -38,6 +42,7 @@ export default class ApiUser { this.permissions = permissions; this.environment = environment; this.type = type; + this.secret = secret; if (projects && projects.length > 0) { this.projects = projects; } else { diff --git a/src/lib/types/models/api-token.ts b/src/lib/types/models/api-token.ts index b31948248e..88132e3331 100644 --- a/src/lib/types/models/api-token.ts +++ b/src/lib/types/models/api-token.ts @@ -6,6 +6,7 @@ export const ALL = '*'; export enum ApiTokenType { CLIENT = 'client', ADMIN = 'admin', + PROXY = 'proxy', } export interface ILegacyApiTokenCreate { @@ -102,6 +103,12 @@ export const validateApiToken = ({ 'Client token cannot be scoped to all environments', ); } + + if (type === ApiTokenType.PROXY && environment === ALL) { + throw new BadDataError( + 'Proxy token cannot be scoped to all environments', + ); + } }; export const validateApiTokenEnvironment = ( diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 10486656a2..2bbea67da1 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -1,6 +1,7 @@ //Special export const ADMIN = 'ADMIN'; export const CLIENT = 'CLIENT'; +export const PROXY = 'PROXY'; export const NONE = 'NONE'; export const CREATE_FEATURE = 'CREATE_FEATURE'; 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/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 67b2e33aab..2a234c2c0a 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -1,9 +1,9 @@ -import EventEmitter from 'events'; import { IBaseEvent, IEvent } from '../events'; import { Store } from './store'; import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; +import { AnyEventEmitter } from 'lib/util/anyEventEmitter'; -export interface IEventStore extends Store, EventEmitter { +export interface IEventStore extends Store, AnyEventEmitter { store(event: IBaseEvent): Promise; batchStore(events: IBaseEvent[]): Promise; getEvents(): Promise; diff --git a/src/lib/util/anyEventEmitter.test.ts b/src/lib/util/anyEventEmitter.test.ts new file mode 100644 index 0000000000..268434a78d --- /dev/null +++ b/src/lib/util/anyEventEmitter.test.ts @@ -0,0 +1,22 @@ +import { AnyEventEmitter } from './anyEventEmitter'; + +test('AnyEventEmitter', () => { + const events = []; + const results = []; + + class MyEventEmitter extends AnyEventEmitter {} + const myEventEmitter = new MyEventEmitter(); + + myEventEmitter.on('a', () => events.push('a')); + myEventEmitter.on('b', () => events.push('b')); + myEventEmitter.on('c', () => events.push('c')); + myEventEmitter.on('*', () => events.push('*')); + + results.push(myEventEmitter.emit('a')); + results.push(myEventEmitter.emit('b')); + results.push(myEventEmitter.emit('c')); + results.push(myEventEmitter.emit('d')); + + expect(events).toEqual(['*', 'a', '*', 'b', '*', 'c', '*']); + expect(results).toEqual([true, true, true, false]); +}); diff --git a/src/lib/util/anyEventEmitter.ts b/src/lib/util/anyEventEmitter.ts new file mode 100644 index 0000000000..76ccaa710f --- /dev/null +++ b/src/lib/util/anyEventEmitter.ts @@ -0,0 +1,12 @@ +import EventEmitter from 'events'; + +export const ANY_EVENT = '*'; + +// Extends the built-in EventEmitter with support for listening for any event. +// See https://stackoverflow.com/a/54431931. +export class AnyEventEmitter extends EventEmitter { + emit(type: string, ...args: any[]): boolean { + super.emit(ANY_EVENT, ...args); + return super.emit(type, ...args) || super.emit('', ...args); + } +} 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 70390b2e8a..53bb61c261 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/server-dev.ts b/src/server-dev.ts index bb95b58a9c..547dd9a40a 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -34,6 +34,7 @@ process.nextTick(async () => { metricsV2: { enabled: true }, anonymiseEventLog: false, userGroups: true, + embedProxy: true, }, authentication: { initApiTokens: [ diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index 16ebdd703a..6c6cbb2699 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -24,6 +24,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { }, experimental: { userGroups: true, + embedProxy: true, }, }; const options = mergeAll([testConfig, config]); diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index e0d1f5ea0b..02b9960396 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -1862,6 +1862,7 @@ test('Should not allow changing project to target project without the same enabl project: '*', type: ApiTokenType.ADMIN, environment: '*', + secret: 'a', }); await expect(async () => app.services.projectService.changeProject( @@ -1945,6 +1946,7 @@ test('Should allow changing project to target project with the same enabled envi project: '*', type: ApiTokenType.ADMIN, environment: '*', + secret: 'a', }); await expect(async () => app.services.projectService.changeProject( 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 f787fea168..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 @@ -230,7 +230,7 @@ Object { "type": "string", }, "type": Object { - "description": "client, admin.", + "description": "client, admin, proxy.", "type": "string", }, "username": Object { @@ -667,7 +667,7 @@ Object { "type": "string", }, "type": Object { - "description": "client, admin.", + "description": "client, admin, proxy.", "type": "string", }, "username": Object { @@ -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": [ { diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 1155bc2056..dbd2505a00 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -1,8 +1,8 @@ -import EventEmitter from 'events'; import { IEventStore } from '../../lib/types/stores/event-store'; import { IEvent } from '../../lib/types/events'; +import { AnyEventEmitter } from '../../lib/util/anyEventEmitter'; -class FakeEventStore extends EventEmitter implements IEventStore { +class FakeEventStore extends AnyEventEmitter implements IEventStore { events: IEvent[]; constructor() {