1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: embed proxy endpoints (#1926)

* refactor: remove unused API definition routes

* feat: add support for proxy keys

* feat: support listening for any event

* feat: embed proxy endpoints

* refactor: add an experimental flag for the embedded proxy
This commit is contained in:
olav 2022-08-16 15:33:33 +02:00 committed by GitHub
parent c82318d4c5
commit e8d542af0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1731 additions and 222 deletions

View File

@ -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",

View File

@ -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;

View File

@ -3,6 +3,7 @@ export interface IExperimentalOptions {
clientFeatureMemoize?: IExperimentalToggle;
userGroups?: boolean;
anonymiseEventLog?: boolean;
embedProxy?: boolean;
}
export interface IExperimentalToggle {

View File

@ -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),

View File

@ -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<IUnleashConfig, 'getLogger' | 'authentication'>,
experimental,
}: Pick<IUnleashConfig, 'getLogger' | 'authentication' | 'experimental'>,
{ 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;
}

View File

@ -47,6 +47,7 @@ function demoAuthentication(
environment: 'default',
type: ApiTokenType.CLIENT,
project: '*',
secret: 'a',
});
}
next();

View File

@ -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',
}),
};

View File

@ -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".

View File

@ -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<typeof proxyClientSchema>;

View File

@ -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<typeof proxyFeatureSchema>;

View File

@ -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<typeof proxyFeaturesSchema>;

View File

@ -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<typeof proxyMetricsSchema>;

View File

@ -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

View File

@ -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');
});

View File

@ -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;
}

View File

@ -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<IUnleashConfig, 'getLogger'>;
type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
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<void> {
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<FeatureInterface[]> {
return mapFeaturesForClient(
await this.services.featureToggleServiceV2.getClientFeatures({
project: await this.projectNamesForToken(),
environment: this.token.environment,
}),
);
}
private async segmentsForToken(): Promise<Segment[]> {
return mapSegmentsForClient(
await this.services.segmentService.getAll(),
);
}
private async projectNamesForToken(): Promise<string[]> {
if (this.token.projects.includes(ALL_PROJECTS)) {
const allProjects = await this.stores.projectStore.getAll();
return allProjects.map((project) => project.name);
}
return this.token.projects;
}
}

View File

@ -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"
}
}
}

View File

@ -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;

View File

@ -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,
},
},
};

View File

@ -1,14 +0,0 @@
{
"version": 3,
"links": {
"feature-toggles": {
"uri": "/api/client/features"
},
"register": {
"uri": "/api/client/register"
},
"metrics": {
"uri": "/api/client/metrics"
}
}
}

View File

@ -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;

View File

@ -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<void> {
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 {

View File

@ -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,
);
});
});

View File

@ -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<void> {
res.json(api);
if (config.experimental.embedProxy) {
this.use(
'/api/frontend',
new ProxyController(config, services).router,
);
}
}
}

View File

@ -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<PARAM, ResBody, ReqBody, ReqQuery> {
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<IUnleashServices, 'proxyService' | 'openApiService'>,
) {
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<ProxyFeaturesSchema>,
) {
const toggles = await this.proxyService.getProxyFeatures(
req.user,
ProxyController.createContext(req),
);
this.openApiService.respondWithValidation(
200,
res,
proxyFeaturesSchema.$id,
{ toggles },
);
}
private async registerProxyMetrics(
req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>,
res: Response,
) {
await this.proxyService.registerProxyMetrics(
req.user,
req.body,
req.ip,
);
res.sendStatus(200);
}
private static async registerProxyClient(
req: ApiUserRequest<unknown, unknown, ProxyClientSchema>,
res: Response<string>,
) {
// 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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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<IProject, 'id'>,
user: User,
newProject: Pick<IProject, 'id' | 'name'>,
user: IUser,
): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject);
await this.validateUniqueId(data.id);

View File

@ -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<IUnleashConfig, 'getLogger'>;
type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
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<ApiUser['secret'], Unleash> = 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<ProxyFeatureSchema[]> {
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<void> {
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<Unleash> {
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<Unleash> {
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);
}
}

View File

@ -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 {

View File

@ -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 = (

View File

@ -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';

View File

@ -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;
}

View File

@ -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<IEvent, number>, EventEmitter {
export interface IEventStore extends Store<IEvent, number>, AnyEventEmitter {
store(event: IBaseEvent): Promise<void>;
batchStore(events: IBaseEvent[]): Promise<void>;
getEvents(): Promise<IEvent[]>;

View File

@ -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]);
});

View File

@ -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);
}
}

View File

@ -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),
},
});

View File

@ -13,7 +13,7 @@ enum PayloadType {
type NonEmptyList<T> = [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),
},
});

View File

@ -34,6 +34,7 @@ process.nextTick(async () => {
metricsV2: { enabled: true },
anonymiseEventLog: false,
userGroups: true,
embedProxy: true,
},
authentication: {
initApiTokens: [

View File

@ -24,6 +24,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
},
experimental: {
userGroups: true,
embedProxy: true,
},
};
const options = mergeAll<IUnleashOptions>([testConfig, config]);

View File

@ -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(

View File

@ -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",

View File

@ -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<Omit<IApiTokenCreate, 'type' | 'secret'>> = {},
): Promise<IApiToken> => {
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<void> => {
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<void> => {
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' },
},
],
});
});
});

View File

@ -19,7 +19,7 @@
"contextFields": [
{ "name": "environment" },
{ "name": "userId" },
{ "name": "appNam" }
{ "name": "appName" }
],
"projects": [
{

View File

@ -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() {