mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
fix: validate patched data with schema (#7616)
https://linear.app/unleash/issue/2-2453/validate-patched-data-against-schema This adds schema validation to patched data, fixing potential issues of patching data to an invalid state. This can be easily reproduced by patching a strategy constraints to be an object (invalid), instead of an array (valid): ```sh curl -X 'PATCH' \ 'http://localhost:4242/api/admin/projects/default/features/test/environments/development/strategies/8cb3fec6-c40a-45f7-8be0-138c5aaa5263' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -d '[ { "path": "/constraints", "op": "replace", "from": "/constraints", "value": {} } ]' ``` Unleash will accept this because there's no validation that the patched data actually looks like a proper strategy, and we'll start seeing Unleash errors due to the invalid state. This PR adapts some of our existing logic in the way we handle validation errors to support any dynamic object. This way we can perform schema validation with any object and still get the benefits of our existing validation error handling. This PR also takes the liberty to expose the full instancePath as propertyName, instead of only the path's last section. We believe this has more upsides than downsides, especially now that we support the validation of any type of object. ![image](https://github.com/user-attachments/assets/f6503261-f6b5-4e1d-9ec3-66547d0d061f)
This commit is contained in:
parent
f15bcdc2a6
commit
0d3dee0e96
@ -7,6 +7,7 @@ type ValidationErrorDescription = {
|
|||||||
message: string;
|
message: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class BadDataError extends UnleashError {
|
class BadDataError extends UnleashError {
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
|
|
||||||
@ -67,13 +68,11 @@ const additionalPropertiesMessage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genericErrorMessage = (
|
const genericErrorMessage = (
|
||||||
requestBody: object,
|
|
||||||
propertyName: string,
|
propertyName: string,
|
||||||
|
propertyValue: object,
|
||||||
errorMessage: string = 'is invalid',
|
errorMessage: string = 'is invalid',
|
||||||
) => {
|
) => {
|
||||||
const input = getProp(requestBody, propertyName.split('/'));
|
const youSent = JSON.stringify(propertyValue);
|
||||||
|
|
||||||
const youSent = JSON.stringify(input);
|
|
||||||
const message = `The \`${propertyName}\` property ${errorMessage}. You sent ${youSent}.`;
|
const message = `The \`${propertyName}\` property ${errorMessage}. You sent ${youSent}.`;
|
||||||
return {
|
return {
|
||||||
message,
|
message,
|
||||||
@ -117,24 +116,24 @@ const enumMessage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fromOpenApiValidationError =
|
export const fromOpenApiValidationError =
|
||||||
(request: { body: object; query: object }) =>
|
(data: object) =>
|
||||||
(validationError: ErrorObject): ValidationErrorDescription => {
|
(validationError: ErrorObject): ValidationErrorDescription => {
|
||||||
const { instancePath, params, message } = validationError;
|
const { instancePath, params, message } = validationError;
|
||||||
const [errorSource, substringOffset] = instancePath.startsWith('/body')
|
|
||||||
? [request.body, '/body/'.length]
|
|
||||||
: [request.query, '/query/'.length];
|
|
||||||
|
|
||||||
const propertyName = instancePath.substring(substringOffset);
|
const propertyValue = getProp(
|
||||||
|
data,
|
||||||
|
instancePath.split('/').filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
switch (validationError.keyword) {
|
switch (validationError.keyword) {
|
||||||
case 'required':
|
case 'required':
|
||||||
return missingRequiredPropertyMessage(
|
return missingRequiredPropertyMessage(
|
||||||
propertyName,
|
instancePath,
|
||||||
params.missingProperty,
|
params.missingProperty,
|
||||||
);
|
);
|
||||||
case 'additionalProperties':
|
case 'additionalProperties':
|
||||||
return additionalPropertiesMessage(
|
return additionalPropertiesMessage(
|
||||||
propertyName,
|
instancePath,
|
||||||
params.additionalProperty,
|
params.additionalProperty,
|
||||||
);
|
);
|
||||||
case 'enum':
|
case 'enum':
|
||||||
@ -142,25 +141,26 @@ export const fromOpenApiValidationError =
|
|||||||
instancePath.substring(instancePath.lastIndexOf('/') + 1),
|
instancePath.substring(instancePath.lastIndexOf('/') + 1),
|
||||||
message,
|
message,
|
||||||
params.allowedValues,
|
params.allowedValues,
|
||||||
getProp(
|
propertyValue,
|
||||||
errorSource,
|
|
||||||
instancePath.substring(substringOffset).split('/'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'oneOf':
|
case 'oneOf':
|
||||||
return oneOfMessage(propertyName, validationError.message);
|
return oneOfMessage(instancePath, validationError.message);
|
||||||
default:
|
default:
|
||||||
return genericErrorMessage(errorSource, propertyName, message);
|
return genericErrorMessage(
|
||||||
|
instancePath,
|
||||||
|
propertyValue,
|
||||||
|
message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fromOpenApiValidationErrors = (
|
export const fromOpenApiValidationErrors = (
|
||||||
request: { body: object; query: object },
|
data: object,
|
||||||
validationErrors: [ErrorObject, ...ErrorObject[]],
|
validationErrors: [ErrorObject, ...ErrorObject[]],
|
||||||
): BadDataError => {
|
): BadDataError => {
|
||||||
const [firstDetail, ...remainingDetails] = validationErrors.map(
|
const [firstDetail, ...remainingDetails] = validationErrors.map(
|
||||||
fromOpenApiValidationError(request),
|
fromOpenApiValidationError(data),
|
||||||
);
|
);
|
||||||
|
|
||||||
return new BadDataError(
|
return new BadDataError(
|
||||||
|
@ -64,7 +64,7 @@ describe('OpenAPI error conversion', () => {
|
|||||||
message:
|
message:
|
||||||
// it tells the user that the property is required
|
// it tells the user that the property is required
|
||||||
expect.stringContaining('required'),
|
expect.stringContaining('required'),
|
||||||
path: 'enabled',
|
path: '/body/enabled',
|
||||||
});
|
});
|
||||||
|
|
||||||
// it tells the user the name of the missing property
|
// it tells the user the name of the missing property
|
||||||
@ -95,7 +95,7 @@ describe('OpenAPI error conversion', () => {
|
|||||||
message:
|
message:
|
||||||
// it provides the message
|
// it provides the message
|
||||||
expect.stringContaining(error.message),
|
expect.stringContaining(error.message),
|
||||||
path: 'parameters',
|
path: '/body/parameters',
|
||||||
});
|
});
|
||||||
|
|
||||||
// it tells the user what they provided
|
// it tells the user what they provided
|
||||||
@ -128,17 +128,13 @@ describe('OpenAPI error conversion', () => {
|
|||||||
message:
|
message:
|
||||||
// it provides the message
|
// it provides the message
|
||||||
expect.stringContaining(error.message),
|
expect.stringContaining(error.message),
|
||||||
path: instancePath.substring('/body/'.length),
|
path: instancePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// it tells the user what happened
|
// it tells the user what happened
|
||||||
expect(result.message).toContain('matches more than one option');
|
expect(result.message).toContain('matches more than one option');
|
||||||
// it tells the user what part of the request body this pertains to
|
// it tells the user what part of the request body this pertains to
|
||||||
expect(result.message).toContain(
|
expect(result.message).toContain(`"${instancePath}" property`);
|
||||||
instancePath === '/body'
|
|
||||||
? 'root object'
|
|
||||||
: `"${instancePath.substring('/body/'.length)}" property`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -164,7 +160,7 @@ describe('OpenAPI error conversion', () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
message: expect.stringContaining(error.params.pattern),
|
message: expect.stringContaining(error.params.pattern),
|
||||||
path: 'description',
|
path: '/body/description',
|
||||||
});
|
});
|
||||||
expect(result.message).toContain('description');
|
expect(result.message).toContain('description');
|
||||||
});
|
});
|
||||||
@ -317,6 +313,79 @@ describe('OpenAPI error conversion', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Handles any data, not only requests', () => {
|
||||||
|
const errors: [ErrorObject, ...ErrorObject[]] = [
|
||||||
|
{
|
||||||
|
keyword: 'maximum',
|
||||||
|
instancePath: '/newprop',
|
||||||
|
schemaPath:
|
||||||
|
'#/components/schemas/addonCreateUpdateSchema/properties/newprop/maximum',
|
||||||
|
params: {
|
||||||
|
comparison: '<=',
|
||||||
|
limit: 5,
|
||||||
|
exclusive: false,
|
||||||
|
},
|
||||||
|
message: 'should be <= 5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keyword: 'required',
|
||||||
|
instancePath: '/',
|
||||||
|
schemaPath:
|
||||||
|
'#/components/schemas/addonCreateUpdateSchema/required',
|
||||||
|
params: {
|
||||||
|
missingProperty: 'enabled',
|
||||||
|
},
|
||||||
|
message: "should have required property 'enabled'",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const serializedUnleashError: ApiErrorSchema =
|
||||||
|
fromOpenApiValidationErrors({ newprop: 7 }, errors).toJSON();
|
||||||
|
|
||||||
|
expect(serializedUnleashError).toMatchObject({
|
||||||
|
name: 'BadDataError',
|
||||||
|
message: expect.stringContaining('`details`'),
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
message: expect.stringContaining('newprop'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: expect.stringContaining('enabled'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Handles invalid data gracefully', () => {
|
||||||
|
const errors: [ErrorObject, ...ErrorObject[]] = [
|
||||||
|
{
|
||||||
|
keyword: 'maximum',
|
||||||
|
instancePath: '/body/newprop',
|
||||||
|
schemaPath:
|
||||||
|
'#/components/schemas/addonCreateUpdateSchema/properties/body/newprop/maximum',
|
||||||
|
params: {
|
||||||
|
comparison: '<=',
|
||||||
|
limit: 5,
|
||||||
|
exclusive: false,
|
||||||
|
},
|
||||||
|
message: 'should be <= 5',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const serializedUnleashError: ApiErrorSchema =
|
||||||
|
fromOpenApiValidationErrors({}, errors).toJSON();
|
||||||
|
|
||||||
|
expect(serializedUnleashError).toMatchObject({
|
||||||
|
name: 'BadDataError',
|
||||||
|
message: expect.stringContaining('`details`'),
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
message: expect.stringContaining('newprop'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Disallowed additional properties', () => {
|
describe('Disallowed additional properties', () => {
|
||||||
it('gives useful messages for base-level properties', () => {
|
it('gives useful messages for base-level properties', () => {
|
||||||
const openApiError = {
|
const openApiError = {
|
||||||
@ -337,10 +406,10 @@ describe('OpenAPI error conversion', () => {
|
|||||||
message: expect.stringContaining(
|
message: expect.stringContaining(
|
||||||
openApiError.params.additionalProperty,
|
openApiError.params.additionalProperty,
|
||||||
),
|
),
|
||||||
path: 'bogus',
|
path: '/body/bogus',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(error.message).toMatch(/\broot\b/i);
|
expect(error.message).toMatch(/\bbody\b/i);
|
||||||
expect(error.message).toMatch(/\badditional properties\b/i);
|
expect(error.message).toMatch(/\badditional properties\b/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -365,8 +434,8 @@ describe('OpenAPI error conversion', () => {
|
|||||||
const error = fromOpenApiValidationError(request2)(openApiError);
|
const error = fromOpenApiValidationError(request2)(openApiError);
|
||||||
|
|
||||||
expect(error).toMatchObject({
|
expect(error).toMatchObject({
|
||||||
message: expect.stringContaining('nestedObject/nested2'),
|
message: expect.stringContaining('/body/nestedObject/nested2'),
|
||||||
path: 'nestedObject/nested2/extraPropertyName',
|
path: '/body/nestedObject/nested2/extraPropertyName',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(error.message).toContain(
|
expect(error.message).toContain(
|
||||||
@ -395,7 +464,7 @@ describe('OpenAPI error conversion', () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
message: expect.stringMatching(/\bnestedObject\/a\/b\b/),
|
message: expect.stringMatching(/\bnestedObject\/a\/b\b/),
|
||||||
path: 'nestedObject/a/b',
|
path: '/body/nestedObject/a/b',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.message).toContain('[]');
|
expect(result.message).toContain('[]');
|
||||||
@ -420,7 +489,7 @@ describe('OpenAPI error conversion', () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
message: expect.stringContaining(illegalValue),
|
message: expect.stringContaining(illegalValue),
|
||||||
path: 'nestedObject/a/b',
|
path: '/body/nestedObject/a/b',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.message).toMatch(/\bnestedObject\/a\/b\b/);
|
expect(result.message).toMatch(/\bnestedObject\/a\/b\b/);
|
||||||
|
@ -32,6 +32,7 @@ import {
|
|||||||
type FeatureSchema,
|
type FeatureSchema,
|
||||||
featuresSchema,
|
featuresSchema,
|
||||||
type FeaturesSchema,
|
type FeaturesSchema,
|
||||||
|
featureStrategySchema,
|
||||||
type FeatureStrategySchema,
|
type FeatureStrategySchema,
|
||||||
getStandardResponses,
|
getStandardResponses,
|
||||||
type ParametersSchema,
|
type ParametersSchema,
|
||||||
@ -54,6 +55,7 @@ import type {
|
|||||||
} from '../../db/transaction';
|
} from '../../db/transaction';
|
||||||
import { BadDataError } from '../../error';
|
import { BadDataError } from '../../error';
|
||||||
import { anonymise } from '../../util';
|
import { anonymise } from '../../util';
|
||||||
|
import { throwOnInvalidSchema } from '../../openapi/validate';
|
||||||
|
|
||||||
interface FeatureStrategyParams {
|
interface FeatureStrategyParams {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -1060,6 +1062,9 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
const strategy = await this.featureService.getStrategy(strategyId);
|
const strategy = await this.featureService.getStrategy(strategyId);
|
||||||
|
|
||||||
const { newDocument } = applyPatch(strategy, patch);
|
const { newDocument } = applyPatch(strategy, patch);
|
||||||
|
|
||||||
|
throwOnInvalidSchema(featureStrategySchema.$id, newDocument);
|
||||||
|
|
||||||
const updatedStrategy = await this.startTransaction(async (tx) =>
|
const updatedStrategy = await this.startTransaction(async (tx) =>
|
||||||
this.transactionalFeatureToggleService(tx).updateStrategy(
|
this.transactionalFeatureToggleService(tx).updateStrategy(
|
||||||
strategyId,
|
strategyId,
|
||||||
|
@ -1500,7 +1500,7 @@ test('Can patch a strategy based on id', async () => {
|
|||||||
strategy!.id
|
strategy!.id
|
||||||
}`,
|
}`,
|
||||||
)
|
)
|
||||||
.send([{ op: 'replace', path: '/parameters/rollout', value: 42 }])
|
.send([{ op: 'replace', path: '/parameters/rollout', value: '42' }])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
await app.request
|
await app.request
|
||||||
.get(
|
.get(
|
||||||
|
@ -1,7 +1,30 @@
|
|||||||
import { validateSchema } from './validate';
|
import { constraintSchema } from './spec';
|
||||||
|
import { throwOnInvalidSchema, validateSchema } from './validate';
|
||||||
|
|
||||||
test('validateSchema', () => {
|
test('validateSchema', () => {
|
||||||
expect(() => validateSchema('unknownSchemaId' as any, {})).toThrow(
|
expect(() => validateSchema('unknownSchemaId' as any, {})).toThrow(
|
||||||
'no schema with key or ref "unknownSchemaId"',
|
'no schema with key or ref "unknownSchemaId"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('throwOnInvalidSchema', () => {
|
||||||
|
expect(() =>
|
||||||
|
throwOnInvalidSchema(constraintSchema.$id, {
|
||||||
|
contextName: 'a',
|
||||||
|
operator: 'NUM_LTE',
|
||||||
|
value: '1',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwOnInvalidSchema', () => {
|
||||||
|
expect(() =>
|
||||||
|
throwOnInvalidSchema(constraintSchema.$id, {
|
||||||
|
contextName: 'a',
|
||||||
|
operator: 'invalid-operator',
|
||||||
|
value: '1',
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'Request validation failed: your request body or params contain invalid data. Refer to the `details` list for more information.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import Ajv, { type ErrorObject } from 'ajv';
|
import Ajv, { type ErrorObject } from 'ajv';
|
||||||
import { type SchemaId, schemas } from './index';
|
import { type SchemaId, schemas } from './index';
|
||||||
import { omitKeys } from '../util/omit-keys';
|
import { omitKeys } from '../util/omit-keys';
|
||||||
|
import { fromOpenApiValidationErrors } from '../error/bad-data-error';
|
||||||
|
|
||||||
export interface ISchemaValidationErrors<S = SchemaId> {
|
export interface ISchemaValidationErrors<S = SchemaId> {
|
||||||
schema: S;
|
schema: S;
|
||||||
@ -38,3 +39,17 @@ export const validateSchema = <S = SchemaId>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const throwOnInvalidSchema = <S = SchemaId>(
|
||||||
|
schema: S,
|
||||||
|
data: object,
|
||||||
|
): void => {
|
||||||
|
const validationErrors = validateSchema(schema, data);
|
||||||
|
if (validationErrors) {
|
||||||
|
const [firstError, ...remainingErrors] = validationErrors.errors;
|
||||||
|
throw fromOpenApiValidationErrors(data, [
|
||||||
|
firstError,
|
||||||
|
...remainingErrors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user