1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-12 01:17:04 +02:00

fix: validate the type and length of parameter values (#1559)

* refactor: coerce primitive types in OpenAPI requests

* refactor: avoid broken array args to serializeDates

* refactor: avoid some spec refs to improve generated types

* refactor: remove debug logging

* refactor: fix IExpressOpenApi interface name prefix

* refactor: ensure that parameter values are strings

* refactor: test that parameter values are coerced to strings
This commit is contained in:
olav 2022-05-04 15:16:18 +02:00 committed by GitHub
parent 61e9588bb0
commit 56615e91f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 742 additions and 108 deletions

View File

@ -53,6 +53,15 @@ interface IFeatureStrategiesTable {
created_at?: Date; created_at?: Date;
} }
function ensureStringValues(data: object): { [key: string]: string } {
const stringEntries = Object.entries(data).map(([key, value]) => [
key,
String(value),
]);
return Object.fromEntries(stringEntries);
}
function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy { function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
return { return {
id: row.id, id: row.id,
@ -60,7 +69,7 @@ function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
projectId: row.project_name, projectId: row.project_name,
environment: row.environment, environment: row.environment,
strategyName: row.strategy_name, strategyName: row.strategy_name,
parameters: row.parameters, parameters: ensureStringValues(row.parameters),
constraints: (row.constraints as unknown as IConstraint[]) || [], constraints: (row.constraints as unknown as IConstraint[]) || [],
createdAt: row.created_at, createdAt: row.created_at,
sortOrder: row.sort_order, sortOrder: row.sort_order,

View File

@ -1,13 +1,14 @@
import { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
import { featuresSchema } from './spec/features-schema'; import { constraintSchema } from './spec/constraint-schema';
import { createFeatureSchema } from './spec/create-feature-schema';
import { createStrategySchema } from './spec/create-strategy-schema';
import { featureSchema } from './spec/feature-schema'; import { featureSchema } from './spec/feature-schema';
import { featuresSchema } from './spec/features-schema';
import { overrideSchema } from './spec/override-schema';
import { parametersSchema } from './spec/parameters-schema';
import { strategySchema } from './spec/strategy-schema'; import { strategySchema } from './spec/strategy-schema';
import { variantSchema } from './spec/variant-schema'; import { variantSchema } from './spec/variant-schema';
import { overrideSchema } from './spec/override-schema';
import { createFeatureSchema } from './spec/create-feature-schema';
import { constraintSchema } from './spec/constraint-schema';
// Create the base OpenAPI schema, with everything except paths.
export const createOpenApiSchema = ( export const createOpenApiSchema = (
serverUrl?: string, serverUrl?: string,
): Omit<OpenAPIV3.Document, 'paths'> => { ): Omit<OpenAPIV3.Document, 'paths'> => {
@ -32,13 +33,15 @@ export const createOpenApiSchema = (
}, },
}, },
schemas: { schemas: {
constraintSchema,
createFeatureSchema, createFeatureSchema,
featuresSchema, createStrategySchema,
featureSchema, featureSchema,
featuresSchema,
overrideSchema,
parametersSchema,
strategySchema, strategySchema,
variantSchema, variantSchema,
overrideSchema,
constraintSchema,
}, },
}, },
}; };

View File

@ -1,6 +1,6 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
export const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['contextName', 'operator'], required: ['contextName', 'operator'],
@ -11,12 +11,21 @@ export const schema = {
operator: { operator: {
type: 'string', type: 'string',
}, },
caseInsensitive: {
type: 'boolean',
},
inverted: {
type: 'boolean',
},
values: { values: {
type: 'array', type: 'array',
items: { items: {
type: 'string', type: 'string',
}, },
}, },
value: {
type: 'string',
},
}, },
} as const; } as const;

View File

@ -0,0 +1,12 @@
import { OpenAPIV3 } from 'openapi-types';
export const createStrategyRequest: OpenAPIV3.RequestBodyObject = {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/createStrategySchema',
},
},
},
};

View File

@ -0,0 +1,25 @@
import { createSchemaObject, CreateSchemaType } from '../types';
import { parametersSchema } from './parameters-schema';
import { constraintSchema } from './constraint-schema';
const schema = {
type: 'object',
additionalProperties: false,
properties: {
name: {
type: 'string',
},
sortOrder: {
type: 'number',
},
constraints: {
type: 'array',
items: constraintSchema,
},
parameters: parametersSchema,
},
} as const;
export type CreateStrategySchema = CreateSchemaType<typeof schema>;
export const createStrategySchema = createSchemaObject(schema);

View File

@ -1,4 +1,6 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
import { strategySchema } from './strategy-schema';
import { variantSchema } from './variant-schema';
const schema = { const schema = {
type: 'object', type: 'object',
@ -38,14 +40,10 @@ const schema = {
}, },
strategies: { strategies: {
type: 'array', type: 'array',
items: { items: strategySchema,
$ref: '#/components/schemas/strategySchema',
},
}, },
variants: { variants: {
items: { items: variantSchema,
$ref: '#/components/schemas/variantSchema',
},
type: 'array', type: 'array',
}, },
}, },

View File

@ -1,6 +1,7 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
import { featureSchema } from './feature-schema';
export const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['version', 'features'], required: ['version', 'features'],
@ -10,9 +11,7 @@ export const schema = {
}, },
features: { features: {
type: 'array', type: 'array',
items: { items: featureSchema,
$ref: '#/components/schemas/featureSchema',
},
}, },
}, },
} as const; } as const;

View File

@ -1,6 +1,6 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
export const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['contextName', 'values'], required: ['contextName', 'values'],

View File

@ -0,0 +1,13 @@
import { createSchemaObject, CreateSchemaType } from '../types';
const schema = {
type: 'object',
additionalProperties: {
type: 'string',
maxLength: 100,
},
} as const;
export type ParametersSchema = CreateSchemaType<typeof schema>;
export const parametersSchema = createSchemaObject(schema);

View File

@ -0,0 +1,12 @@
import { OpenAPIV3 } from 'openapi-types';
export const strategyResponse: OpenAPIV3.ResponseObject = {
description: 'strategyResponse',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/strategySchema',
},
},
},
};

View File

@ -1,6 +1,8 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
import { constraintSchema } from './constraint-schema';
import { parametersSchema } from './parameters-schema';
export const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['id', 'name', 'constraints', 'parameters'], required: ['id', 'name', 'constraints', 'parameters'],
@ -13,13 +15,9 @@ export const schema = {
}, },
constraints: { constraints: {
type: 'array', type: 'array',
items: { items: constraintSchema,
$ref: '#/components/schemas/constraintSchema',
},
},
parameters: {
type: 'object',
}, },
parameters: parametersSchema,
}, },
} as const; } as const;

View File

@ -1,9 +1,10 @@
import { createSchemaObject, CreateSchemaType } from '../types'; import { createSchemaObject, CreateSchemaType } from '../types';
import { overrideSchema } from './override-schema';
export const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['name', 'weight', 'weightType', 'stickiness', 'overrides'], required: ['name', 'weight', 'weightType', 'stickiness'],
properties: { properties: {
name: { name: {
type: 'string', type: 'string',
@ -22,9 +23,7 @@ export const schema = {
}, },
overrides: { overrides: {
type: 'array', type: 'array',
items: { items: overrideSchema,
$ref: '#/components/schemas/overrideSchema',
},
}, },
}, },
} as const; } as const;

View File

@ -11,6 +11,7 @@ import FeatureToggleService from '../../services/feature-toggle-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import { featuresResponse } from '../../openapi/spec/features-response'; import { featuresResponse } from '../../openapi/spec/features-response';
import { FeaturesSchema } from '../../openapi/spec/features-schema'; import { FeaturesSchema } from '../../openapi/spec/features-schema';
import { serializeDates } from '../../util/serialize-dates';
export default class ArchiveController extends Controller { export default class ArchiveController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
@ -71,7 +72,11 @@ export default class ArchiveController extends Controller {
const features = await this.featureService.getMetadataForAllFeatures( const features = await this.featureService.getMetadataForAllFeatures(
true, true,
); );
res.json({ version: 2, features });
res.json({
version: 2,
features: features.map(serializeDates),
});
} }
async getArchivedFeaturesByProjectId( async getArchivedFeaturesByProjectId(
@ -84,7 +89,10 @@ export default class ArchiveController extends Controller {
true, true,
projectId, projectId,
); );
res.json({ version: 2, features }); res.json({
version: 2,
features: features.map(serializeDates),
});
} }
async deleteFeature( async deleteFeature(

View File

@ -20,6 +20,7 @@ import { IAuthRequest } from '../unleash-types';
import { DEFAULT_ENV } from '../../util/constants'; import { DEFAULT_ENV } from '../../util/constants';
import { featuresResponse } from '../../openapi/spec/features-response'; import { featuresResponse } from '../../openapi/spec/features-response';
import { FeaturesSchema } from '../../openapi/spec/features-schema'; import { FeaturesSchema } from '../../openapi/spec/features-schema';
import { serializeDates } from '../../util/serialize-dates';
const version = 1; const version = 1;
@ -120,7 +121,10 @@ class FeatureController extends Controller {
const query = await this.prepQuery(req.query); const query = await this.prepQuery(req.query);
const features = await this.service.getFeatureToggles(query); const features = await this.service.getFeatureToggles(query);
res.json({ version, features }); res.json({
version,
features: features.map(serializeDates),
});
} }
async getToggle( async getToggle(

View File

@ -14,11 +14,7 @@ import {
UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_ENVIRONMENT,
UPDATE_FEATURE_STRATEGY, UPDATE_FEATURE_STRATEGY,
} from '../../../types/permissions'; } from '../../../types/permissions';
import { import { FeatureToggleDTO, IStrategyConfig } from '../../../types/model';
FeatureToggleDTO,
IConstraint,
IStrategyConfig,
} from '../../../types/model';
import { extractUsername } from '../../../util/extract-user'; import { extractUsername } from '../../../util/extract-user';
import { IAuthRequest } from '../../unleash-types'; import { IAuthRequest } from '../../unleash-types';
import { createFeatureRequest } from '../../../openapi/spec/create-feature-request'; import { createFeatureRequest } from '../../../openapi/spec/create-feature-request';
@ -26,6 +22,10 @@ import { featureResponse } from '../../../openapi/spec/feature-response';
import { CreateFeatureSchema } from '../../../openapi/spec/create-feature-schema'; import { CreateFeatureSchema } from '../../../openapi/spec/create-feature-schema';
import { FeatureSchema } from '../../../openapi/spec/feature-schema'; import { FeatureSchema } from '../../../openapi/spec/feature-schema';
import { serializeDates } from '../../../util/serialize-dates'; import { serializeDates } from '../../../util/serialize-dates';
import { createStrategyRequest } from '../../../openapi/spec/create-strategy-request';
import { CreateStrategySchema } from '../../../openapi/spec/create-strategy-schema';
import { strategyResponse } from '../../../openapi/spec/strategy-response';
import { StrategySchema } from '../../../openapi/spec/strategy-schema';
import { featuresResponse } from '../../../openapi/spec/features-response'; import { featuresResponse } from '../../../openapi/spec/features-response';
interface FeatureStrategyParams { interface FeatureStrategyParams {
@ -47,12 +47,6 @@ interface StrategyIdParams extends FeatureStrategyParams {
strategyId: string; strategyId: string;
} }
interface StrategyUpdateBody {
name?: string;
constraints?: IConstraint[];
parameters?: object;
}
const PATH = '/:projectId/features'; const PATH = '/:projectId/features';
const PATH_FEATURE = `${PATH}/:featureName`; const PATH_FEATURE = `${PATH}/:featureName`;
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
@ -79,11 +73,13 @@ export default class ProjectFeaturesController extends Controller {
this.logger = config.getLogger('/admin-api/project/features.ts'); this.logger = config.getLogger('/admin-api/project/features.ts');
this.get(`${PATH_ENV}`, this.getEnvironment); this.get(`${PATH_ENV}`, this.getEnvironment);
this.post( this.post(
`${PATH_ENV}/on`, `${PATH_ENV}/on`,
this.toggleEnvironmentOn, this.toggleEnvironmentOn,
UPDATE_FEATURE_ENVIRONMENT, UPDATE_FEATURE_ENVIRONMENT,
); );
this.post( this.post(
`${PATH_ENV}/off`, `${PATH_ENV}/off`,
this.toggleEnvironmentOff, this.toggleEnvironmentOff,
@ -91,22 +87,43 @@ export default class ProjectFeaturesController extends Controller {
); );
this.get(`${PATH_STRATEGIES}`, this.getStrategies); this.get(`${PATH_STRATEGIES}`, this.getStrategies);
this.post(
`${PATH_STRATEGIES}`, this.route({
this.addStrategy, method: 'post',
CREATE_FEATURE_STRATEGY, path: PATH_STRATEGIES,
); handler: this.addStrategy,
permission: CREATE_FEATURE_STRATEGY,
middleware: [
openApiService.validPath({
tags: ['admin'],
requestBody: createStrategyRequest,
responses: { 200: strategyResponse },
}),
],
});
this.get(`${PATH_STRATEGY}`, this.getStrategy); this.get(`${PATH_STRATEGY}`, this.getStrategy);
this.put(
`${PATH_STRATEGY}`, this.route({
this.updateStrategy, method: 'put',
UPDATE_FEATURE_STRATEGY, path: PATH_STRATEGY,
); handler: this.updateStrategy,
permission: UPDATE_FEATURE_STRATEGY,
middleware: [
openApiService.validPath({
tags: ['admin'],
requestBody: createStrategyRequest,
responses: { 200: strategyResponse },
}),
],
});
this.patch( this.patch(
`${PATH_STRATEGY}`, `${PATH_STRATEGY}`,
this.patchStrategy, this.patchStrategy,
UPDATE_FEATURE_STRATEGY, UPDATE_FEATURE_STRATEGY,
); );
this.delete( this.delete(
`${PATH_STRATEGY}`, `${PATH_STRATEGY}`,
this.deleteStrategy, this.deleteStrategy,
@ -318,8 +335,8 @@ export default class ProjectFeaturesController extends Controller {
} }
async addStrategy( async addStrategy(
req: IAuthRequest<FeatureStrategyParams, any, IStrategyConfig, any>, req: IAuthRequest<FeatureStrategyParams, any, IStrategyConfig>,
res: Response, res: Response<StrategySchema>,
): Promise<void> { ): Promise<void> {
const { projectId, featureName, environment } = req.params; const { projectId, featureName, environment } = req.params;
const userName = extractUsername(req); const userName = extractUsername(req);
@ -346,8 +363,8 @@ export default class ProjectFeaturesController extends Controller {
} }
async updateStrategy( async updateStrategy(
req: IAuthRequest<StrategyIdParams, any, StrategyUpdateBody, any>, req: IAuthRequest<StrategyIdParams, any, CreateStrategySchema>,
res: Response, res: Response<StrategySchema>,
): Promise<void> { ): Promise<void> {
const { strategyId, environment, projectId, featureName } = req.params; const { strategyId, environment, projectId, featureName } = req.params;
const userName = extractUsername(req); const userName = extractUsername(req);

View File

@ -69,6 +69,7 @@ import {
validateString, validateString,
} from '../util/validators/constraint-types'; } from '../util/validators/constraint-types';
import { IContextFieldStore } from 'lib/types/stores/context-field-store'; import { IContextFieldStore } from 'lib/types/stores/context-field-store';
import { Saved, Unsaved } from '../types/saved';
interface IFeatureContext { interface IFeatureContext {
featureName: string; featureName: string;
@ -268,7 +269,7 @@ class FeatureToggleService {
featureStrategyToPublic( featureStrategyToPublic(
featureStrategy: IFeatureStrategy, featureStrategy: IFeatureStrategy,
): IStrategyConfig { ): Saved<IStrategyConfig> {
return { return {
id: featureStrategy.id, id: featureStrategy.id,
name: featureStrategy.strategyName, name: featureStrategy.strategyName,
@ -278,10 +279,10 @@ class FeatureToggleService {
} }
async createStrategy( async createStrategy(
strategyConfig: Omit<IStrategyConfig, 'id'>, strategyConfig: Unsaved<IStrategyConfig>,
context: IFeatureStrategyContext, context: IFeatureStrategyContext,
createdBy: string, createdBy: string,
): Promise<IStrategyConfig> { ): Promise<Saved<IStrategyConfig>> {
const { featureName, projectId, environment } = context; const { featureName, projectId, environment } = context;
await this.validateFeatureContext(context); await this.validateFeatureContext(context);
@ -342,7 +343,7 @@ class FeatureToggleService {
updates: Partial<IFeatureStrategy>, updates: Partial<IFeatureStrategy>,
context: IFeatureStrategyContext, context: IFeatureStrategyContext,
userName: string, userName: string,
): Promise<IStrategyConfig> { ): Promise<Saved<IStrategyConfig>> {
const { projectId, environment, featureName } = context; const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id); const existingStrategy = await this.featureStrategiesStore.get(id);
this.validateFeatureStrategyContext(existingStrategy, context); this.validateFeatureStrategyContext(existingStrategy, context);
@ -392,7 +393,7 @@ class FeatureToggleService {
this.validateFeatureStrategyContext(existingStrategy, context); this.validateFeatureStrategyContext(existingStrategy, context);
if (existingStrategy.id === id) { if (existingStrategy.id === id) {
existingStrategy.parameters[name] = value; existingStrategy.parameters[name] = String(value);
const strategy = await this.featureStrategiesStore.updateStrategy( const strategy = await this.featureStrategiesStore.updateStrategy(
id, id,
existingStrategy, existingStrategy,

View File

@ -1,4 +1,4 @@
import openapi, { ExpressOpenApi } from '@unleash/express-openapi'; import openapi, { IExpressOpenApi } from '@unleash/express-openapi';
import { Express, RequestHandler } from 'express'; import { Express, RequestHandler } from 'express';
import { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
@ -8,13 +8,14 @@ import { AdminApiOperation, ClientApiOperation } from '../openapi/types';
export class OpenApiService { export class OpenApiService {
private readonly config: IUnleashConfig; private readonly config: IUnleashConfig;
private readonly api: ExpressOpenApi; private readonly api: IExpressOpenApi;
constructor(config: IUnleashConfig) { constructor(config: IUnleashConfig) {
this.config = config; this.config = config;
this.api = openapi( this.api = openapi(
this.docsPath(), this.docsPath(),
createOpenApiSchema(config.server?.unleashUrl), createOpenApiSchema(config.server?.unleashUrl),
{ coerce: true },
); );
} }

View File

@ -19,7 +19,7 @@ export interface IStrategyConfig {
id?: string; id?: string;
name: string; name: string;
constraints: IConstraint[]; constraints: IConstraint[];
parameters: object; parameters: { [key: string]: string };
sortOrder?: number; sortOrder?: number;
} }
export interface IFeatureStrategy { export interface IFeatureStrategy {
@ -28,7 +28,7 @@ export interface IFeatureStrategy {
projectId: string; projectId: string;
environment: string; environment: string;
strategyName: string; strategyName: string;
parameters: object; parameters: { [key: string]: string };
sortOrder?: number; sortOrder?: number;
constraints: IConstraint[]; constraints: IConstraint[];
createdAt?: Date; createdAt?: Date;

View File

@ -3,11 +3,15 @@ declare module '@unleash/express-openapi' {
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
export interface ExpressOpenApi extends RequestHandler { export interface IExpressOpenApi extends RequestHandler {
validPath: (operation: OpenAPIV3.OperationObject) => RequestHandler; validPath: (operation: OpenAPIV3.OperationObject) => RequestHandler;
schema: (name: string, schema: OpenAPIV3.SchemaObject) => void; schema: (name: string, schema: OpenAPIV3.SchemaObject) => void;
swaggerui: RequestHandler; swaggerui: RequestHandler;
} }
export default function openapi(docsPath: string, any): ExpressOpenApi; export default function openapi(
docsPath: string,
document: Omit<OpenAPIV3.Document, 'paths'>,
options?: { coerce: boolean },
): IExpressOpenApi;
} }

7
src/lib/types/saved.ts Normal file
View File

@ -0,0 +1,7 @@
// Add an id field to an object.
export type Saved<T extends {}, Id extends string | number = string> = T & {
id: Id;
};
// Remove an id field from an object.
export type Unsaved<T extends {}> = Omit<T, 'id'>;

View File

@ -14,7 +14,7 @@ const input = `<!DOCTYPE html>
<link <link
href="https://fonts.googleapis.com/icon?family=Material+Icons" href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet" rel="stylesheet"
/> />
<link <link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700"
rel="stylesheet" rel="stylesheet"
@ -80,7 +80,6 @@ test('rewriteHTML swaps out faviconPath if cdnPrefix is set', () => {
test('rewriteHTML sets favicon path to root', () => { test('rewriteHTML sets favicon path to root', () => {
const result = rewriteHTML(input, ''); const result = rewriteHTML(input, '');
console.log(result);
expect(result.includes('<link rel="icon" href="/favicon.ico" />')).toBe( expect(result.includes('<link rel="icon" href="/favicon.ico" />')).toBe(
true, true,
); );

View File

@ -2,9 +2,13 @@ type SerializedDates<T> = {
[P in keyof T]: T[P] extends Date ? string : T[P]; [P in keyof T]: T[P] extends Date ? string : T[P];
}; };
// Disallow array arguments for serializeDates.
// Use `array.map(serializeDates)` instead.
type NotArray<T> = Exclude<T, unknown[]>;
// Serialize top-level date values to strings. // Serialize top-level date values to strings.
export const serializeDates = <T extends object>( export const serializeDates = <T extends object>(
obj: T, obj: NotArray<T>,
): SerializedDates<T> => { ): SerializedDates<T> => {
const entries = Object.entries(obj).map(([k, v]) => { const entries = Object.entries(obj).map(([k, v]) => {
if (v instanceof Date) { if (v instanceof Date) {

View File

@ -16,6 +16,7 @@ import IncompatibleProjectError from '../../../../../lib/error/incompatible-proj
import { IVariant, WeightType } from '../../../../../lib/types/model'; import { IVariant, WeightType } from '../../../../../lib/types/model';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import supertest from 'supertest'; import supertest from 'supertest';
import { randomId } from '../../../../../lib/util/random-id';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
@ -704,12 +705,7 @@ test('Can add strategy to feature toggle to a "some-env-2"', async () => {
.post( .post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`, `/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
) )
.send({ .send({ name: 'default', parameters: { userId: 'string' } })
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200); .expect(200);
await app.request await app.request
.get(`/api/admin/projects/default/features/${featureName}`) .get(`/api/admin/projects/default/features/${featureName}`)
@ -722,7 +718,6 @@ test('Can add strategy to feature toggle to a "some-env-2"', async () => {
test('Can update strategy on feature toggle', async () => { test('Can update strategy on feature toggle', async () => {
const envName = 'default'; const envName = 'default';
const featureName = 'feature.strategy.update.strat'; const featureName = 'feature.strategy.update.strat';
const projectPath = '/api/admin/projects/default'; const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`; const featurePath = `${projectPath}/features/${featureName}`;
@ -735,23 +730,13 @@ test('Can update strategy on feature toggle', async () => {
// add strategy // add strategy
const { body: strategy } = await app.request const { body: strategy } = await app.request
.post(`${featurePath}/environments/${envName}/strategies`) .post(`${featurePath}/environments/${envName}/strategies`)
.send({ .send({ name: 'default', parameters: { userIds: '' } })
name: 'default',
parameters: {
userIds: '',
},
})
.expect(200); .expect(200);
// update strategy // update strategy
await app.request await app.request
.put(`${featurePath}/environments/${envName}/strategies/${strategy.id}`) .put(`${featurePath}/environments/${envName}/strategies/${strategy.id}`)
.send({ .send({ name: 'default', parameters: { userIds: 1234 } })
name: 'default',
parameters: {
userIds: '1234',
},
})
.expect(200); .expect(200);
const { body } = await app.request.get(`${featurePath}`); const { body } = await app.request.get(`${featurePath}`);
@ -764,6 +749,47 @@ test('Can update strategy on feature toggle', async () => {
}); });
}); });
test('should coerce all strategy parameter values to strings', async () => {
const envName = 'default';
const featureName = randomId();
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { foo: 1234 } })
.expect(200);
const { body } = await app.request.get(`${featurePath}`);
const defaultEnv = body.environments[0];
expect(defaultEnv.strategies).toHaveLength(1);
expect(defaultEnv.strategies[0].parameters).toStrictEqual({
foo: '1234',
});
});
test('should limit the length of parameter values', async () => {
const envName = 'default';
const featureName = randomId();
const projectPath = '/api/admin/projects/default';
const featurePath = `${projectPath}/features/${featureName}`;
await app.request
.post(`${projectPath}/features`)
.send({ name: featureName })
.expect(201);
await app.request
.post(`${featurePath}/environments/${envName}/strategies`)
.send({ name: 'default', parameters: { foo: 'x'.repeat(101) } })
.expect(400);
});
test('Can NOT delete strategy with wrong projectId', async () => { test('Can NOT delete strategy with wrong projectId', async () => {
const envName = 'default'; const envName = 'default';
const featureName = 'feature.strategy.delete.strat.error'; const featureName = 'feature.strategy.delete.strat.error';
@ -1110,7 +1136,7 @@ test('Can patch a strategy based on id', async () => {
) )
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
expect(res.body.parameters.rollout).toBe(42); expect(res.body.parameters.rollout).toBe('42');
}); });
}); });
@ -1209,7 +1235,7 @@ test('Deleting a strategy should include name of feature strategy was deleted fr
.post( .post(
`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`, `/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`,
) )
.send({ name: 'default', constraints: [], properties: {} }) .send({ name: 'default', constraints: [] })
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
strategyId = res.body.id; strategyId = res.body.id;
@ -1257,7 +1283,7 @@ test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async (
.post( .post(
`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`, `/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`,
) )
.send({ name: 'default', constraints: [], properties: {} }) .send({ name: 'default', constraints: [] })
.expect(200); .expect(200);
await app.request await app.request
@ -1299,7 +1325,7 @@ test('Disabling environment creates a FEATURE_ENVIRONMENT_DISABLED event', async
.post( .post(
`/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`, `/api/admin/projects/default/features/${featureName}/environments/${environment}/strategies`,
) )
.send({ name: 'default', constraints: [], properties: {} }) .send({ name: 'default', constraints: [] })
.expect(200); .expect(200);
await app.request await app.request

View File

@ -54,12 +54,21 @@ Object {
"constraintSchema": Object { "constraintSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
"caseInsensitive": Object {
"type": "boolean",
},
"contextName": Object { "contextName": Object {
"type": "string", "type": "string",
}, },
"inverted": Object {
"type": "boolean",
},
"operator": Object { "operator": Object {
"type": "string", "type": "string",
}, },
"value": Object {
"type": "string",
},
"values": Object { "values": Object {
"items": Object { "items": Object {
"type": "string", "type": "string",
@ -93,6 +102,59 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"createStrategySchema": Object {
"additionalProperties": false,
"properties": Object {
"constraints": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"caseInsensitive": Object {
"type": "boolean",
},
"contextName": Object {
"type": "string",
},
"inverted": Object {
"type": "boolean",
},
"operator": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"operator",
],
"type": "object",
},
"type": "array",
},
"name": Object {
"type": "string",
},
"parameters": Object {
"additionalProperties": Object {
"maxLength": 100,
"type": "string",
},
"type": "object",
},
"sortOrder": Object {
"type": "number",
},
},
"type": "object",
},
"featureSchema": Object { "featureSchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
@ -126,7 +188,63 @@ Object {
}, },
"strategies": Object { "strategies": Object {
"items": Object { "items": Object {
"$ref": "#/components/schemas/strategySchema", "additionalProperties": false,
"properties": Object {
"constraints": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"caseInsensitive": Object {
"type": "boolean",
},
"contextName": Object {
"type": "string",
},
"inverted": Object {
"type": "boolean",
},
"operator": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"operator",
],
"type": "object",
},
"type": "array",
},
"id": Object {
"type": "string",
},
"name": Object {
"type": "string",
},
"parameters": Object {
"additionalProperties": Object {
"maxLength": 100,
"type": "string",
},
"type": "object",
},
},
"required": Array [
"id",
"name",
"constraints",
"parameters",
],
"type": "object",
}, },
"type": "array", "type": "array",
}, },
@ -135,7 +253,53 @@ Object {
}, },
"variants": Object { "variants": Object {
"items": Object { "items": Object {
"$ref": "#/components/schemas/variantSchema", "additionalProperties": false,
"properties": Object {
"name": Object {
"type": "string",
},
"overrides": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"contextName": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"values",
],
"type": "object",
},
"type": "array",
},
"payload": Object {
"type": "object",
},
"stickiness": Object {
"type": "string",
},
"weight": Object {
"type": "number",
},
"weightType": Object {
"type": "string",
},
},
"required": Array [
"name",
"weight",
"weightType",
"stickiness",
],
"type": "object",
}, },
"type": "array", "type": "array",
}, },
@ -151,7 +315,159 @@ Object {
"properties": Object { "properties": Object {
"features": Object { "features": Object {
"items": Object { "items": Object {
"$ref": "#/components/schemas/featureSchema", "additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date",
"nullable": true,
"type": "string",
},
"description": Object {
"type": "string",
},
"enabled": Object {
"type": "boolean",
},
"impressionData": Object {
"type": "boolean",
},
"lastSeenAt": Object {
"format": "date",
"nullable": true,
"type": "string",
},
"name": Object {
"type": "string",
},
"project": Object {
"type": "string",
},
"stale": Object {
"type": "boolean",
},
"strategies": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"constraints": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"caseInsensitive": Object {
"type": "boolean",
},
"contextName": Object {
"type": "string",
},
"inverted": Object {
"type": "boolean",
},
"operator": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"operator",
],
"type": "object",
},
"type": "array",
},
"id": Object {
"type": "string",
},
"name": Object {
"type": "string",
},
"parameters": Object {
"additionalProperties": Object {
"maxLength": 100,
"type": "string",
},
"type": "object",
},
},
"required": Array [
"id",
"name",
"constraints",
"parameters",
],
"type": "object",
},
"type": "array",
},
"type": Object {
"type": "string",
},
"variants": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"name": Object {
"type": "string",
},
"overrides": Object {
"items": Object {
"additionalProperties": false,
"properties": Object {
"contextName": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"values",
],
"type": "object",
},
"type": "array",
},
"payload": Object {
"type": "object",
},
"stickiness": Object {
"type": "string",
},
"weight": Object {
"type": "number",
},
"weightType": Object {
"type": "string",
},
},
"required": Array [
"name",
"weight",
"weightType",
"stickiness",
],
"type": "object",
},
"type": "array",
},
},
"required": Array [
"name",
"project",
],
"type": "object",
}, },
"type": "array", "type": "array",
}, },
@ -184,12 +500,47 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"parametersSchema": Object {
"additionalProperties": Object {
"maxLength": 100,
"type": "string",
},
"type": "object",
},
"strategySchema": Object { "strategySchema": Object {
"additionalProperties": false, "additionalProperties": false,
"properties": Object { "properties": Object {
"constraints": Object { "constraints": Object {
"items": Object { "items": Object {
"$ref": "#/components/schemas/constraintSchema", "additionalProperties": false,
"properties": Object {
"caseInsensitive": Object {
"type": "boolean",
},
"contextName": Object {
"type": "string",
},
"inverted": Object {
"type": "boolean",
},
"operator": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"operator",
],
"type": "object",
}, },
"type": "array", "type": "array",
}, },
@ -200,6 +551,10 @@ Object {
"type": "string", "type": "string",
}, },
"parameters": Object { "parameters": Object {
"additionalProperties": Object {
"maxLength": 100,
"type": "string",
},
"type": "object", "type": "object",
}, },
}, },
@ -219,7 +574,23 @@ Object {
}, },
"overrides": Object { "overrides": Object {
"items": Object { "items": Object {
"$ref": "#/components/schemas/overrideSchema", "additionalProperties": false,
"properties": Object {
"contextName": Object {
"type": "string",
},
"values": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"contextName",
"values",
],
"type": "object",
}, },
"type": "array", "type": "array",
}, },
@ -241,7 +612,6 @@ Object {
"weight", "weight",
"weightType", "weightType",
"stickiness", "stickiness",
"overrides",
], ],
"type": "object", "type": "object",
}, },
@ -432,6 +802,124 @@ Object {
], ],
}, },
}, },
"/api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies": Object {
"post": Object {
"parameters": Array [
Object {
"in": "path",
"name": "projectId",
"required": true,
"schema": Object {
"type": "string",
},
},
Object {
"in": "path",
"name": "featureName",
"required": true,
"schema": Object {
"type": "string",
},
},
Object {
"in": "path",
"name": "environment",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/createStrategySchema",
},
},
},
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/strategySchema",
},
},
},
"description": "strategyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies/{strategyId}": Object {
"put": Object {
"parameters": Array [
Object {
"in": "path",
"name": "projectId",
"required": true,
"schema": Object {
"type": "string",
},
},
Object {
"in": "path",
"name": "featureName",
"required": true,
"schema": Object {
"type": "string",
},
},
Object {
"in": "path",
"name": "environment",
"required": true,
"schema": Object {
"type": "string",
},
},
Object {
"in": "path",
"name": "strategyId",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/createStrategySchema",
},
},
},
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/strategySchema",
},
},
},
"description": "strategyResponse",
},
},
"tags": Array [
"admin",
],
},
},
}, },
"security": Array [ "security": Array [
Object { Object {

View File

@ -75,14 +75,12 @@ test('Should be able to update existing strategy configuration', async () => {
expect(createdConfig.name).toEqual('default'); expect(createdConfig.name).toEqual('default');
const updatedConfig = await service.updateStrategy( const updatedConfig = await service.updateStrategy(
createdConfig.id, createdConfig.id,
{ { parameters: { b2b: 'true' } },
parameters: { b2b: true },
},
{ projectId, featureName, environment: DEFAULT_ENV }, { projectId, featureName, environment: DEFAULT_ENV },
username, username,
); );
expect(createdConfig.id).toEqual(updatedConfig.id); expect(createdConfig.id).toEqual(updatedConfig.id);
expect(updatedConfig.parameters).toEqual({ b2b: true }); expect(updatedConfig.parameters).toEqual({ b2b: 'true' });
}); });
test('Should be able to get strategy by id', async () => { test('Should be able to get strategy by id', async () => {