1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

Update 5.0 branch to tag (#3645)

This commit is contained in:
Jaanus Sellin 2023-04-28 11:53:20 +03:00 committed by GitHub
parent 7a47f18769
commit 9f82c08ba2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 205 additions and 101 deletions

6
.mergify.yml Normal file
View File

@ -0,0 +1,6 @@
pull_request_rules:
- name: Automatic update of all PRs
conditions:
- base=main # Targeting the main branch
actions:
update: {} # Update PR with base branch

View File

@ -9,7 +9,6 @@ import {
StyledInput, StyledInput,
StyledTextField, StyledTextField,
} from './ProjectForm.styles'; } from './ProjectForm.styles';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import Select from 'component/common/select'; import Select from 'component/common/select';
@ -60,9 +59,6 @@ const ProjectForm: React.FC<IProjectForm> = ({
validateProjectId, validateProjectId,
clearErrors, clearErrors,
}) => { }) => {
const { uiConfig } = useUiConfig();
const { projectScopedStickiness } = uiConfig.flags;
return ( return (
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
<StyledContainer> <StyledContainer>
@ -109,10 +105,7 @@ const ProjectForm: React.FC<IProjectForm> = ({
/> />
<ConditionallyRender <ConditionallyRender
condition={ condition={setProjectStickiness != null}
Boolean(projectScopedStickiness) &&
setProjectStickiness != null
}
show={ show={
<> <>
<StyledDescription> <StyledDescription>

View File

@ -1,15 +1,10 @@
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
import useProject from './api/getters/useProject/useProject'; import useProject from './api/getters/useProject/useProject';
const DEFAULT_STICKINESS = 'default'; const DEFAULT_STICKINESS = 'default';
export const useDefaultProjectSettings = (projectId: string) => { export const useDefaultProjectSettings = (projectId: string) => {
const { uiConfig } = useUiConfig();
const { projectScopedStickiness } = uiConfig.flags;
const { project, loading, error } = useProject(projectId); const { project, loading, error } = useProject(projectId);
return { return {
defaultStickiness: Boolean(projectScopedStickiness) defaultStickiness: Boolean(project.defaultStickiness)
? project.defaultStickiness ? project.defaultStickiness
: DEFAULT_STICKINESS, : DEFAULT_STICKINESS,
mode: project.mode, mode: project.mode,

View File

@ -46,7 +46,6 @@ export interface IFlags {
proPlanAutoCharge?: boolean; proPlanAutoCharge?: boolean;
notifications?: boolean; notifications?: boolean;
bulkOperations?: boolean; bulkOperations?: boolean;
projectScopedStickiness?: boolean;
personalAccessTokensKillSwitch?: boolean; personalAccessTokensKillSwitch?: boolean;
demo?: boolean; demo?: boolean;
strategyTitle?: boolean; strategyTitle?: boolean;

View File

@ -1,7 +1,7 @@
{ {
"name": "unleash-server", "name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.", "description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "5.0.0-beta.0", "version": "5.0.0",
"keywords": [ "keywords": [
"unleash", "unleash",
"feature toggle", "feature toggle",

View File

@ -85,7 +85,6 @@ exports[`should create default config 1`] = `
"optimal304Differ": false, "optimal304Differ": false,
"personalAccessTokensKillSwitch": false, "personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false, "proPlanAutoCharge": false,
"projectScopedStickiness": false,
"responseTimeWithAppNameKillSwitch": false, "responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false, "strategyDisable": false,
"strategyTitle": false, "strategyTitle": false,
@ -113,7 +112,6 @@ exports[`should create default config 1`] = `
"optimal304Differ": false, "optimal304Differ": false,
"personalAccessTokensKillSwitch": false, "personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false, "proPlanAutoCharge": false,
"projectScopedStickiness": false,
"responseTimeWithAppNameKillSwitch": false, "responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false, "strategyDisable": false,
"strategyTitle": false, "strategyTitle": false,

View File

@ -1,9 +1,47 @@
import { ErrorObject } from 'ajv'; import { ErrorObject } from 'ajv';
import { import {
ApiErrorSchema, ApiErrorSchema,
fromLegacyError,
fromOpenApiValidationError, fromOpenApiValidationError,
fromOpenApiValidationErrors, fromOpenApiValidationErrors,
UnleashApiErrorNameWithoutExtraData,
UnleashApiErrorTypes,
UnleashError,
} from './api-error'; } from './api-error';
import BadDataError from './bad-data-error';
describe('v5 deprecation: backwards compatibility', () => {
it.each(UnleashApiErrorTypes)(
'Adds details to error type: "%s"',
(name: UnleashApiErrorNameWithoutExtraData) => {
const message = `Error type: ${name}`;
const error = new UnleashError({ name, message }).toJSON();
expect(error.message).toBe(message);
expect(error.details).toStrictEqual([
{
message,
description: message,
},
]);
},
);
});
describe('Standard/legacy error conversion', () => {
it('Moves message to the details list for baddataerror', () => {
const message = `: message!`;
const result = fromLegacyError(new BadDataError(message)).toJSON();
expect(result.message.includes('`details`'));
expect(result.details).toStrictEqual([
{
message,
description: message,
},
]);
});
});
describe('OpenAPI error conversion', () => { describe('OpenAPI error conversion', () => {
it('Gives useful error messages for missing properties', () => { it('Gives useful error messages for missing properties', () => {
@ -21,9 +59,9 @@ describe('OpenAPI error conversion', () => {
const { description } = fromOpenApiValidationError({})(error); const { description } = fromOpenApiValidationError({})(error);
// it tells the user that the property is required // it tells the user that the property is required
expect(description.includes('required')); expect(description.includes('required')).toBeTruthy();
// it tells the user the name of the missing property // it tells the user the name of the missing property
expect(description.includes(error.params.missingProperty)); expect(description.includes(error.params.missingProperty)).toBeTruthy();
}); });
it('Gives useful error messages for type errors', () => { it('Gives useful error messages for type errors', () => {
@ -45,9 +83,11 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it provides the message // it provides the message
expect(description.includes(error.message)); expect(description.includes(error.message)).toBeTruthy();
// it tells the user what they provided // it tells the user what they provided
expect(description.includes(JSON.stringify(parameterValue))); expect(
description.includes(JSON.stringify(parameterValue)),
).toBeTruthy();
}); });
it('Gives useful pattern error messages', () => { it('Gives useful pattern error messages', () => {
@ -69,11 +109,11 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it tells the user what the pattern it should match is // it tells the user what the pattern it should match is
expect(description.includes(error.params.pattern)); expect(description.includes(error.params.pattern)).toBeTruthy();
// it tells the user which property it pertains to // it tells the user which property it pertains to
expect(description.includes('description')); expect(description.includes('description')).toBeTruthy();
// it tells the user what they provided // it tells the user what they provided
expect(description.includes(requestDescription)); expect(description.includes(requestDescription)).toBeTruthy();
}); });
it('Gives useful min/maxlength error messages', () => { it('Gives useful min/maxlength error messages', () => {
@ -95,11 +135,13 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it tells the user what the pattern it should match is // it tells the user what the pattern it should match is
expect(description.includes(error.params.limit.toString())); expect(
description.includes(error.params.limit.toString()),
).toBeTruthy();
// it tells the user which property it pertains to // it tells the user which property it pertains to
expect(description.includes('description')); expect(description.includes('description')).toBeTruthy();
// it tells the user what they provided // it tells the user what they provided
expect(description.includes(requestDescription)); expect(description.includes(requestDescription)).toBeTruthy();
}); });
it('Handles numerical min/max errors', () => { it('Handles numerical min/max errors', () => {
@ -123,13 +165,15 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it tells the user what the limit is // it tells the user what the limit is
expect(description.includes(error.params.limit.toString())); expect(
description.includes(error.params.limit.toString()),
).toBeTruthy();
// it tells the user what kind of comparison it performed // it tells the user what kind of comparison it performed
expect(description.includes(error.params.comparison)); expect(description.includes(error.params.comparison)).toBeTruthy();
// it tells the user which property it pertains to // it tells the user which property it pertains to
expect(description.includes('newprop')); expect(description.includes('newprop')).toBeTruthy();
// it tells the user what they provided // it tells the user what they provided
expect(description.includes(propertyValue.toString())); expect(description.includes(propertyValue.toString())).toBeTruthy();
}); });
it('Handles multiple errors', () => { it('Handles multiple errors', () => {
@ -175,6 +219,65 @@ describe('OpenAPI error conversion', () => {
); );
}); });
describe('Disallowed additional properties', () => {
it('gives useful messages for base-level properties', () => {
const openApiError = {
keyword: 'additionalProperties',
instancePath: '',
dataPath: '.body',
schemaPath:
'#/components/schemas/addonCreateUpdateSchema/additionalProperties',
params: { additionalProperty: 'bogus' },
message: 'should NOT have additional properties',
};
const error = fromOpenApiValidationError({ bogus: 5 })(
openApiError,
);
expect(
error.description.includes(
openApiError.params.additionalProperty,
),
).toBeTruthy();
expect(error.description).toMatch(/\broot\b/i);
expect(error.description).toMatch(/\badditional properties\b/i);
});
it('gives useful messages for nested properties', () => {
const request2 = {
nestedObject: {
nested2: { extraPropertyName: 'illegal property' },
},
};
const openApiError = {
keyword: 'additionalProperties',
instancePath: '',
dataPath: '.body.nestedObject.nested2',
schemaPath:
'#/components/schemas/addonCreateUpdateSchema/properties/nestedObject/properties/nested2/additionalProperties',
params: { additionalProperty: 'extraPropertyName' },
message: 'should NOT have additional properties',
};
const error = fromOpenApiValidationError(request2)(openApiError);
expect(
error.description.includes('nestedObject.nested2'),
).toBeTruthy();
expect(
error.description.includes(
openApiError.params.additionalProperty,
),
).toBeTruthy();
expect(
error.description
.toLowerCase()
.includes('additional properties'),
).toBeTruthy();
});
});
it('Handles deeply nested properties gracefully', () => { it('Handles deeply nested properties gracefully', () => {
const error = { const error = {
keyword: 'type', keyword: 'type',
@ -191,9 +294,9 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it should hold the full path to the error // it should hold the full path to the error
expect(description.includes('nestedObject.a.b')); expect(description).toMatch(/\bnestedObject.a.b\b/);
// it should include the value that the user sent // it should include the value that the user sent
expect(description.includes('[]')); expect(description.includes('[]')).toBeTruthy();
}); });
it('Handles deeply nested properties on referenced schemas', () => { it('Handles deeply nested properties on referenced schemas', () => {
@ -212,8 +315,8 @@ describe('OpenAPI error conversion', () => {
})(error); })(error);
// it should hold the full path to the error // it should hold the full path to the error
expect(description.includes('nestedObject.a.b')); expect(description).toMatch(/\bnestedObject.a.b\b/);
// it should include the value that the user sent // it should include the value that the user sent
expect(description.includes(illegalValue)); expect(description.includes(illegalValue)).toBeTruthy();
}); });
}); });

View File

@ -2,32 +2,32 @@ import { v4 as uuidV4 } from 'uuid';
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { ErrorObject } from 'ajv'; import { ErrorObject } from 'ajv';
const UnleashApiErrorTypes = [ export const UnleashApiErrorTypes = [
'OwaspValidationError', 'ContentTypeError',
'PasswordUndefinedError', 'DisabledError',
'NoAccessError',
'UsedTokenError',
'InvalidOperationError',
'IncompatibleProjectError',
'OperationDeniedError',
'NotFoundError',
'NameExistsError',
'FeatureHasTagError', 'FeatureHasTagError',
'RoleInUseError', 'IncompatibleProjectError',
'ProjectWithoutOwnerError', 'InvalidOperationError',
'UnknownError', 'MinimumOneEnvironmentError',
'NameExistsError',
'NoAccessError',
'NotFoundError',
'NotImplementedError',
'OperationDeniedError',
'OwaspValidationError',
'PasswordMismatch', 'PasswordMismatch',
'PasswordMismatchError', 'PasswordMismatchError',
'DisabledError', 'PasswordUndefinedError',
'ContentTypeError', 'ProjectWithoutOwnerError',
'NotImplementedError', 'RoleInUseError',
'UnknownError',
'UsedTokenError',
// server errors; not the end user's fault // server errors; not the end user's fault
'InternalError', 'InternalError',
] as const; ] as const;
const UnleashApiErrorTypesWithExtraData = [ const UnleashApiErrorTypesWithExtraData = [
'MinimumOneEnvironmentError',
'BadDataError', 'BadDataError',
'BadRequestError', 'BadRequestError',
'ValidationError', 'ValidationError',
@ -42,10 +42,8 @@ const AllUnleashApiErrorTypes = [
] as const; ] as const;
type UnleashApiErrorName = typeof AllUnleashApiErrorTypes[number]; type UnleashApiErrorName = typeof AllUnleashApiErrorTypes[number];
type UnleashApiErrorNameWithoutExtraData = Exclude< export type UnleashApiErrorNameWithoutExtraData =
UnleashApiErrorName, typeof UnleashApiErrorTypes[number];
typeof UnleashApiErrorTypesWithExtraData[number]
>;
const statusCode = (errorName: UnleashApiErrorName): number => { const statusCode = (errorName: UnleashApiErrorName): number => {
switch (errorName) { switch (errorName) {
@ -174,6 +172,7 @@ export class UnleashError extends Error {
id: this.id, id: this.id,
name: this.name, name: this.name,
message: this.message, message: this.message,
details: [{ message: this.message, description: this.message }],
...this.additionalParameters, ...this.additionalParameters,
}; };
} }
@ -228,11 +227,10 @@ export const fromLegacyError = (e: Error): UnleashError => {
if ( if (
[ [
'ValidationError',
'BadRequestError',
'BadDataError', 'BadDataError',
'BadRequestError',
'InvalidTokenError', 'InvalidTokenError',
'MinimumOneEnvironmentError', 'ValidationError',
].includes(name) ].includes(name)
) { ) {
return new UnleashError({ return new UnleashError({
@ -240,10 +238,9 @@ export const fromLegacyError = (e: Error): UnleashError => {
| 'ValidationError' | 'ValidationError'
| 'BadRequestError' | 'BadRequestError'
| 'BadDataError' | 'BadDataError'
| 'InvalidTokenError' | 'InvalidTokenError',
| 'MinimumOneEnvironmentError',
message: message:
'Your request body failed to validate. Refer to the `details` list to see what happened.', 'Request validation failed: your request body failed to validate. Refer to the `details` list to see what happened.',
details: [{ description: e.message, message: e.message }], details: [{ description: e.message, message: e.message }],
}); });
} }
@ -279,8 +276,24 @@ export const fromOpenApiValidationError =
description, description,
message: description, message: description,
}; };
} else if (validationError.keyword === 'additionalProperties') {
const path =
(propertyName ? propertyName + '.' : '') +
validationError.params.additionalProperty;
const description = `The ${
propertyName ? `\`${propertyName}\`` : 'root'
} object of the request body does not allow additional properties. Your request included the \`${path}\` property.`;
return {
path,
description,
message: description,
};
} else { } else {
const youSent = JSON.stringify(requestBody[propertyName]); const input = propertyName
.split('.')
.reduce((x, prop) => x[prop], requestBody);
const youSent = JSON.stringify(input);
const description = `The .${propertyName} property ${validationError.message}. You sent ${youSent}.`; const description = `The .${propertyName} property ${validationError.message}. You sent ${youSent}.`;
return { return {
description, description,

View File

@ -372,9 +372,13 @@ export default class ExportImportService {
'Some of the context fields you are trying to import are not supported.', 'Some of the context fields you are trying to import are not supported.',
// @ts-ignore-error We know that the array contains at least one // @ts-ignore-error We know that the array contains at least one
// element here. // element here.
errors: unsupportedContextFields.map((field) => ({ details: unsupportedContextFields.map((field) => {
description: `${field.name} is not supported.`, const description = `${field.name} is not supported.`;
})), return {
description,
message: description,
};
}),
}); });
} }
} }
@ -453,9 +457,14 @@ export default class ExportImportService {
'Some of the strategies you are trying to import are not supported.', 'Some of the strategies you are trying to import are not supported.',
// @ts-ignore-error We know that the array contains at least one // @ts-ignore-error We know that the array contains at least one
// element here. // element here.
errors: unsupportedStrategies.map((strategy) => ({ details: unsupportedStrategies.map((strategy) => {
description: `${strategy.name} is not supported.`, const description = `${strategy.name} is not supported.`;
})),
return {
description,
message: description,
};
}),
}); });
} }
} }

View File

@ -673,11 +673,7 @@ test('reject import with unknown context fields', async () => {
400, 400,
); );
expect( expect(body.details[0].description).toMatch(/\bContextField1\b/);
body.errors.includes((error) =>
error.description.includes('ContextField1'),
),
);
}); });
test('reject import with unsupported strategies', async () => { test('reject import with unsupported strategies', async () => {
@ -697,11 +693,7 @@ test('reject import with unsupported strategies', async () => {
400, 400,
); );
expect( expect(body.details[0].description).toMatch(/\bcustomStrategy\b/);
body.errors.includes((error) =>
error.description.includes('customStrategy'),
),
);
}); });
test('validate import data', async () => { test('validate import data', async () => {

View File

@ -57,7 +57,7 @@ test('require a name when creating a new strategy', async () => {
['name', 'property', 'required'].every((word) => ['name', 'property', 'required'].every((word) =>
res.body.details[0].description.includes(word), res.body.details[0].description.includes(word),
), ),
); ).toBeTruthy();
}); });
}); });

View File

@ -26,14 +26,11 @@ export const handleErrors: (
logger: Logger, logger: Logger,
error: Error, error: Error,
) => void = (res, logger, error) => { ) => void = (res, logger, error) => {
logger.warn(error.message);
// @ts-expect-error
// eslint-disable-next-line no-param-reassign
error.isJoi = true;
const finalError = const finalError =
error instanceof UnleashError ? error : fromLegacyError(error); error instanceof UnleashError ? error : fromLegacyError(error);
logger.warn(finalError.id, finalError.message);
if (['InternalError', 'UnknownError'].includes(finalError.name)) { if (['InternalError', 'UnknownError'].includes(finalError.name)) {
logger.error('Server failed executing request', error); logger.error('Server failed executing request', error);
} }

View File

@ -53,10 +53,6 @@ const flags = {
process.env.UNLEASH_BULK_OPERATIONS, process.env.UNLEASH_BULK_OPERATIONS,
false, false,
), ),
projectScopedStickiness: parseEnvVarBoolean(
process.env.PROJECT_SCOPED_STICKINESS,
false,
),
personalAccessTokensKillSwitch: parseEnvVarBoolean( personalAccessTokensKillSwitch: parseEnvVarBoolean(
process.env.UNLEASH_PAT_KILL_SWITCH, process.env.UNLEASH_PAT_KILL_SWITCH,
false, false,

View File

@ -40,7 +40,6 @@ process.nextTick(async () => {
responseTimeWithAppNameKillSwitch: false, responseTimeWithAppNameKillSwitch: false,
newProjectOverview: true, newProjectOverview: true,
bulkOperations: true, bulkOperations: true,
projectScopedStickiness: true,
optimal304: true, optimal304: true,
optimal304Differ: false, optimal304Differ: false,
}, },

View File

@ -184,8 +184,10 @@ test('Trying to add a strategy configuration to environment not connected to tog
}) })
.expect(400) .expect(400)
.expect((r) => { .expect((r) => {
expect(r.body.message.includes('environment')); expect(
expect(r.body.message.includes('project')); r.body.details[0].message.includes('environment'),
).toBeTruthy();
expect(r.body.details[0].message.includes('project')).toBeTruthy();
}); });
}); });
@ -776,12 +778,12 @@ test('Trying to patch variants on a feature toggle should trigger an OperationDe
]) ])
.expect(403) .expect(403)
.expect((res) => { .expect((res) => {
expect(res.body.message.includes('PATCH')); expect(res.body.message.includes('PATCH')).toBeTruthy();
expect( expect(
res.body.message.includes( res.body.message.includes(
'/api/admin/projects/:project/features/:feature/variants', '/api/admin/projects/:project/features/:feature/variants',
), ),
); ).toBeTruthy();
}); });
}); });

View File

@ -777,8 +777,10 @@ test('Should be denied move feature toggle to project where the user does not ha
); );
} catch (e) { } catch (e) {
expect(e.name).toContain('NoAccess'); expect(e.name).toContain('NoAccess');
expect(e.message.includes('permission')); expect(e.message.includes('permission')).toBeTruthy();
expect(e.message.includes(permissions.MOVE_FEATURE_TOGGLE)); expect(
e.message.includes(permissions.MOVE_FEATURE_TOGGLE),
).toBeTruthy();
} }
}); });

View File

@ -538,8 +538,8 @@ test('should not change project if feature toggle project does not match current
'wrong-project-id', 'wrong-project-id',
); );
} catch (err) { } catch (err) {
expect(err.message.toLowerCase().includes('permission')); expect(err.message.toLowerCase().includes('permission')).toBeTruthy();
expect(err.message.includes(MOVE_FEATURE_TOGGLE)); expect(err.message.includes(MOVE_FEATURE_TOGGLE)).toBeTruthy();
} }
}); });
@ -604,8 +604,8 @@ test('should fail if user is not authorized', async () => {
project.id, project.id,
); );
} catch (err) { } catch (err) {
expect(err.message.toLowerCase().includes('permission')); expect(err.message.toLowerCase().includes('permission')).toBeTruthy();
expect(err.message.includes(MOVE_FEATURE_TOGGLE)); expect(err.message.includes(MOVE_FEATURE_TOGGLE)).toBeTruthy();
} }
}); });