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:
parent
c82318d4c5
commit
e8d542af0f
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -3,6 +3,7 @@ export interface IExperimentalOptions {
|
||||
clientFeatureMemoize?: IExperimentalToggle;
|
||||
userGroups?: boolean;
|
||||
anonymiseEventLog?: boolean;
|
||||
embedProxy?: boolean;
|
||||
}
|
||||
|
||||
export interface IExperimentalToggle {
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -47,6 +47,7 @@ function demoAuthentication(
|
||||
environment: 'default',
|
||||
type: ApiTokenType.CLIENT,
|
||||
project: '*',
|
||||
secret: 'a',
|
||||
});
|
||||
}
|
||||
next();
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -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".
|
||||
|
50
src/lib/openapi/spec/proxy-client-schema.ts
Normal file
50
src/lib/openapi/spec/proxy-client-schema.ts
Normal 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>;
|
44
src/lib/openapi/spec/proxy-feature-schema.ts
Normal file
44
src/lib/openapi/spec/proxy-feature-schema.ts
Normal 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>;
|
24
src/lib/openapi/spec/proxy-features-schema.ts
Normal file
24
src/lib/openapi/spec/proxy-features-schema.ts
Normal 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>;
|
55
src/lib/openapi/spec/proxy-metrics-schema.ts
Normal file
55
src/lib/openapi/spec/proxy-metrics-schema.ts
Normal 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>;
|
@ -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
|
||||
|
78
src/lib/proxy/create-context.test.ts
Normal file
78
src/lib/proxy/create-context.test.ts
Normal 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');
|
||||
});
|
34
src/lib/proxy/create-context.ts
Normal file
34
src/lib/proxy/create-context.ts
Normal 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;
|
||||
}
|
121
src/lib/proxy/proxy-repository.ts
Normal file
121
src/lib/proxy/proxy-repository.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
@ -1,14 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"links": {
|
||||
"feature-toggles": {
|
||||
"uri": "/api/client/features"
|
||||
},
|
||||
"register": {
|
||||
"uri": "/api/client/register"
|
||||
},
|
||||
"metrics": {
|
||||
"uri": "/api/client/metrics"
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
177
src/lib/routes/proxy-api/index.ts
Normal file
177
src/lib/routes/proxy-api/index.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
120
src/lib/services/proxy-service.ts
Normal file
120
src/lib/services/proxy-service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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 = (
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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[]>;
|
||||
|
22
src/lib/util/anyEventEmitter.test.ts
Normal file
22
src/lib/util/anyEventEmitter.test.ts
Normal 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]);
|
||||
});
|
12
src/lib/util/anyEventEmitter.ts
Normal file
12
src/lib/util/anyEventEmitter.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -34,6 +34,7 @@ process.nextTick(async () => {
|
||||
metricsV2: { enabled: true },
|
||||
anonymiseEventLog: false,
|
||||
userGroups: true,
|
||||
embedProxy: true,
|
||||
},
|
||||
authentication: {
|
||||
initApiTokens: [
|
||||
|
@ -24,6 +24,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
||||
},
|
||||
experimental: {
|
||||
userGroups: true,
|
||||
embedProxy: true,
|
||||
},
|
||||
};
|
||||
const options = mergeAll<IUnleashOptions>([testConfig, config]);
|
||||
|
@ -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(
|
||||
|
@ -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",
|
||||
|
601
src/test/e2e/api/proxy/proxy.e2e.test.ts
Normal file
601
src/test/e2e/api/proxy/proxy.e2e.test.ts
Normal 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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
@ -19,7 +19,7 @@
|
||||
"contextFields": [
|
||||
{ "name": "environment" },
|
||||
{ "name": "userId" },
|
||||
{ "name": "appNam" }
|
||||
{ "name": "appName" }
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
|
4
src/test/fixtures/fake-event-store.ts
vendored
4
src/test/fixtures/fake-event-store.ts
vendored
@ -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() {
|
||||
|
Loading…
Reference in New Issue
Block a user