mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-18 13:48:58 +02:00
feat: add implicit surrounding ^
and $
to patterns (#4664)
This PR updates the back-end handling of feature naming patterns to add implicit leading `^`s and trailing `$`s to the regexes when comparing them. It also adds tests for the new behavior, both for new flag names and for examples. ## Discussion points Regarding stripping incoming ^ and $: We don't actually need to strip incoming `^`s and `$`s: it appears that `^^^^^x$$$$$` is just as valid as `^x$`. As such, we can leave that in. However, if we think it's better to strip, we can do that too. Second, I'm considering moving the flag naming validation into a dedicated module to encapsulate everything a little better. Not sure if this is the time or where it would live, but open to hearing suggestions.
This commit is contained in:
parent
8b452084f3
commit
392beee114
@ -48,7 +48,7 @@ import {
|
|||||||
import { ImportPermissionsService, Mode } from './import-permissions-service';
|
import { ImportPermissionsService, Mode } from './import-permissions-service';
|
||||||
import { ImportValidationMessages } from './import-validation-messages';
|
import { ImportValidationMessages } from './import-validation-messages';
|
||||||
import { findDuplicates } from '../../util/findDuplicates';
|
import { findDuplicates } from '../../util/findDuplicates';
|
||||||
import { FeatureNameCheckResult } from 'lib/services/feature-toggle-service';
|
import { FeatureNameCheckResultWithFeaturePattern } from '../../services/feature-toggle-service';
|
||||||
|
|
||||||
export default class ExportImportService {
|
export default class ExportImportService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -510,7 +510,7 @@ export default class ExportImportService {
|
|||||||
private async getInvalidFeatureNames({
|
private async getInvalidFeatureNames({
|
||||||
project,
|
project,
|
||||||
data,
|
data,
|
||||||
}: ImportTogglesSchema): Promise<FeatureNameCheckResult> {
|
}: ImportTogglesSchema): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||||
return this.featureToggleService.checkFeatureFlagNamesAgainstProjectPattern(
|
return this.featureToggleService.checkFeatureFlagNamesAgainstProjectPattern(
|
||||||
project,
|
project,
|
||||||
data.features.map((f) => f.name),
|
data.features.map((f) => f.name),
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { FeatureNameCheckResult } from 'lib/services/feature-toggle-service';
|
|
||||||
import {
|
import {
|
||||||
FeatureStrategySchema,
|
FeatureStrategySchema,
|
||||||
ImportTogglesValidateItemSchema,
|
ImportTogglesValidateItemSchema,
|
||||||
} from '../../openapi';
|
} from '../../openapi';
|
||||||
import { IContextFieldDto } from '../../types/stores/context-field-store';
|
import { IContextFieldDto } from '../../types/stores/context-field-store';
|
||||||
|
import { FeatureNameCheckResultWithFeaturePattern } from '../../services/feature-toggle-service';
|
||||||
import { ProjectFeaturesLimit } from './import-toggles-store-type';
|
import { ProjectFeaturesLimit } from './import-toggles-store-type';
|
||||||
|
|
||||||
export interface IErrorsParams {
|
export interface IErrorsParams {
|
||||||
@ -12,7 +12,7 @@ export interface IErrorsParams {
|
|||||||
contextFields: IContextFieldDto[];
|
contextFields: IContextFieldDto[];
|
||||||
otherProjectFeatures: string[];
|
otherProjectFeatures: string[];
|
||||||
duplicateFeatures: string[];
|
duplicateFeatures: string[];
|
||||||
featureNameCheckResult: FeatureNameCheckResult;
|
featureNameCheckResult: FeatureNameCheckResultWithFeaturePattern;
|
||||||
featureLimitResult: ProjectFeaturesLimit;
|
featureLimitResult: ProjectFeaturesLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
checkFeatureFlagNamesAgainstPattern,
|
||||||
|
checkFeatureNamingData,
|
||||||
|
} from './feature-naming-validation';
|
||||||
|
|
||||||
|
describe('validate incoming feature naming data', () => {
|
||||||
|
test('patterns with leading ^ and trailing $ are treated the same as without', () => {
|
||||||
|
const basePattern = '[a-z]+';
|
||||||
|
const leading = `^${basePattern}`;
|
||||||
|
const trailing = `${basePattern}$`;
|
||||||
|
const leadingAndTrailing = `^${basePattern}$`;
|
||||||
|
|
||||||
|
const allPatterns = [
|
||||||
|
basePattern,
|
||||||
|
leading,
|
||||||
|
trailing,
|
||||||
|
leadingAndTrailing,
|
||||||
|
];
|
||||||
|
|
||||||
|
const invalidExamples = ['-name-', 'name-', '-name'];
|
||||||
|
const validExample = 'justalpha';
|
||||||
|
|
||||||
|
for (const pattern of allPatterns) {
|
||||||
|
for (const example of invalidExamples) {
|
||||||
|
expect(
|
||||||
|
checkFeatureNamingData({ pattern, example }),
|
||||||
|
).toMatchObject({
|
||||||
|
state: 'invalid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validData = {
|
||||||
|
pattern,
|
||||||
|
example: validExample,
|
||||||
|
};
|
||||||
|
expect(checkFeatureNamingData(validData)).toMatchObject({
|
||||||
|
state: 'valid',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pattern examples are tested against the pattern as if it were surrounded by ^ and $', () => {
|
||||||
|
const pattern = '-[0-9]+';
|
||||||
|
const validExample = '-23';
|
||||||
|
const invalidExample1 = 'feat-15';
|
||||||
|
const invalidExample2 = '-15-';
|
||||||
|
|
||||||
|
expect(
|
||||||
|
checkFeatureNamingData({
|
||||||
|
pattern,
|
||||||
|
example: validExample,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ state: 'valid' });
|
||||||
|
|
||||||
|
for (const example of [invalidExample1, invalidExample2]) {
|
||||||
|
expect(
|
||||||
|
checkFeatureNamingData({
|
||||||
|
pattern,
|
||||||
|
example,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ state: 'invalid' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate feature flag names against a pattern', () => {
|
||||||
|
test('should validate names against a pattern', async () => {
|
||||||
|
const featureNaming = {
|
||||||
|
pattern: 'testpattern.+',
|
||||||
|
example: 'testpattern-one!',
|
||||||
|
description: 'naming description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
||||||
|
const invalidFeatures = ['a', 'b', 'c'];
|
||||||
|
const result = checkFeatureFlagNamesAgainstPattern(
|
||||||
|
[...validFeatures, ...invalidFeatures],
|
||||||
|
featureNaming.pattern,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
state: 'invalid',
|
||||||
|
invalidNames: new Set(invalidFeatures),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validResult = checkFeatureFlagNamesAgainstPattern(
|
||||||
|
validFeatures,
|
||||||
|
featureNaming.pattern,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(validResult).toMatchObject({ state: 'valid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([null, undefined, ''])(
|
||||||
|
'should not validate names if the pattern is %s',
|
||||||
|
(pattern) => {
|
||||||
|
const featureNaming = {
|
||||||
|
pattern,
|
||||||
|
};
|
||||||
|
const features = ['a', 'b'];
|
||||||
|
const result = checkFeatureFlagNamesAgainstPattern(
|
||||||
|
features,
|
||||||
|
featureNaming.pattern,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ state: 'valid' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('should validate names as if the pattern is surrounded by ^ and $.', async () => {
|
||||||
|
const pattern = '-[0-9]+';
|
||||||
|
const featureNaming = {
|
||||||
|
pattern,
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = ['a-95', '-95-', 'b-52-z'];
|
||||||
|
const result = checkFeatureFlagNamesAgainstPattern(
|
||||||
|
features,
|
||||||
|
featureNaming.pattern,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
state: 'invalid',
|
||||||
|
invalidNames: new Set(features),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,57 @@
|
|||||||
|
import { IFeatureNaming } from '../../types/model';
|
||||||
|
|
||||||
|
const compileRegex = (pattern: string) => new RegExp(`^${pattern}$`);
|
||||||
|
|
||||||
|
export const checkFeatureNamingData = (
|
||||||
|
featureNaming?: IFeatureNaming,
|
||||||
|
): { state: 'valid' } | { state: 'invalid'; reason: string } => {
|
||||||
|
if (featureNaming) {
|
||||||
|
const { pattern, example } = featureNaming;
|
||||||
|
if (
|
||||||
|
pattern != null &&
|
||||||
|
example &&
|
||||||
|
!example.match(compileRegex(pattern))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: 'invalid',
|
||||||
|
reason: `You've provided a feature flag naming example ("${example}") that doesn't match your feature flag naming pattern ("${pattern}"). Please provide an example that matches your supplied pattern. Bear in mind that the pattern must match the whole example, as if it were surrounded by '^' and "$".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pattern && example) {
|
||||||
|
return {
|
||||||
|
state: 'invalid',
|
||||||
|
reason: "You've provided a feature flag naming example, but no feature flag naming pattern. You must specify a pattern to use an example.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: 'valid' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureNameCheckResult =
|
||||||
|
| { state: 'valid' }
|
||||||
|
| {
|
||||||
|
state: 'invalid';
|
||||||
|
invalidNames: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkFeatureFlagNamesAgainstPattern = (
|
||||||
|
featureNames: string[],
|
||||||
|
pattern: string | null | undefined,
|
||||||
|
): FeatureNameCheckResult => {
|
||||||
|
if (pattern) {
|
||||||
|
const regex = compileRegex(pattern);
|
||||||
|
const mismatchedNames = featureNames.filter(
|
||||||
|
(name) => !regex.test(name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mismatchedNames.length > 0) {
|
||||||
|
return {
|
||||||
|
state: 'invalid',
|
||||||
|
invalidNames: new Set(mismatchedNames),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state: 'valid' };
|
||||||
|
};
|
@ -94,6 +94,7 @@ import { IFeatureProjectUserParams } from '../routes/admin-api/project/project-f
|
|||||||
import { unique } from '../util/unique';
|
import { unique } from '../util/unique';
|
||||||
import { ISegmentService } from 'lib/segments/segment-service-interface';
|
import { ISegmentService } from 'lib/segments/segment-service-interface';
|
||||||
import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model';
|
import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model';
|
||||||
|
import { checkFeatureFlagNamesAgainstPattern } from '../features/feature-naming-pattern/feature-naming-validation';
|
||||||
|
|
||||||
interface IFeatureContext {
|
interface IFeatureContext {
|
||||||
featureName: string;
|
featureName: string;
|
||||||
@ -112,7 +113,7 @@ export interface IGetFeatureParams {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureNameCheckResult =
|
export type FeatureNameCheckResultWithFeaturePattern =
|
||||||
| { state: 'valid' }
|
| { state: 'valid' }
|
||||||
| {
|
| {
|
||||||
state: 'invalid';
|
state: 'invalid';
|
||||||
@ -1103,24 +1104,20 @@ class FeatureToggleService {
|
|||||||
async checkFeatureFlagNamesAgainstProjectPattern(
|
async checkFeatureFlagNamesAgainstProjectPattern(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
featureNames: string[],
|
featureNames: string[],
|
||||||
): Promise<FeatureNameCheckResult> {
|
): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||||
if (this.flagResolver.isEnabled('featureNamingPattern')) {
|
if (this.flagResolver.isEnabled('featureNamingPattern')) {
|
||||||
const project = await this.projectStore.get(projectId);
|
const project = await this.projectStore.get(projectId);
|
||||||
const patternData = project.featureNaming;
|
const patternData = project.featureNaming;
|
||||||
const namingPattern = patternData?.pattern;
|
const namingPattern = patternData?.pattern;
|
||||||
|
|
||||||
if (namingPattern) {
|
if (namingPattern) {
|
||||||
const regex = new RegExp(namingPattern);
|
const result = checkFeatureFlagNamesAgainstPattern(
|
||||||
const mismatchedNames = featureNames.filter(
|
featureNames,
|
||||||
(name) => !regex.test(name),
|
namingPattern,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mismatchedNames.length > 0) {
|
if (result.state === 'invalid') {
|
||||||
return {
|
return { ...result, featureNaming: patternData };
|
||||||
state: 'invalid',
|
|
||||||
invalidNames: new Set(mismatchedNames),
|
|
||||||
featureNaming: patternData,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,6 +59,7 @@ import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
|||||||
import { uniqueByKey } from '../util/unique';
|
import { uniqueByKey } from '../util/unique';
|
||||||
import { BadDataError, PermissionError } from '../error';
|
import { BadDataError, PermissionError } from '../error';
|
||||||
import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
||||||
|
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
||||||
|
|
||||||
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
||||||
|
|
||||||
@ -169,25 +170,23 @@ export default class ProjectService {
|
|||||||
return this.store.get(id);
|
return this.store.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateFlagNaming = (naming?: IFeatureNaming) => {
|
private validateAndProcessFeatureNamingPattern = (
|
||||||
if (naming) {
|
featureNaming: IFeatureNaming,
|
||||||
const { pattern, example } = naming;
|
): IFeatureNaming => {
|
||||||
if (
|
const validationResult = checkFeatureNamingData(featureNaming);
|
||||||
pattern != null &&
|
|
||||||
example &&
|
|
||||||
!example.match(new RegExp(pattern))
|
|
||||||
) {
|
|
||||||
throw new BadDataError(
|
|
||||||
`You've provided a feature flag naming example ("${example}") that doesn't match your feature flag naming pattern ("${pattern}"). Please provide an example that matches your supplied pattern.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pattern && example) {
|
if (validationResult.state === 'invalid') {
|
||||||
throw new BadDataError(
|
throw new BadDataError(validationResult.reason);
|
||||||
"You've provided a feature flag naming example, but no feature flag naming pattern. You must specify a pattern to use an example.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (featureNaming.pattern && !featureNaming.example) {
|
||||||
|
featureNaming.example = null;
|
||||||
|
}
|
||||||
|
if (featureNaming.pattern && !featureNaming.description) {
|
||||||
|
featureNaming.description = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return featureNaming;
|
||||||
};
|
};
|
||||||
|
|
||||||
async createProject(
|
async createProject(
|
||||||
@ -197,7 +196,9 @@ export default class ProjectService {
|
|||||||
const data = await projectSchema.validateAsync(newProject);
|
const data = await projectSchema.validateAsync(newProject);
|
||||||
await this.validateUniqueId(data.id);
|
await this.validateUniqueId(data.id);
|
||||||
|
|
||||||
this.validateFlagNaming(data.featureNaming);
|
if (data.featureNaming) {
|
||||||
|
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
|
||||||
|
}
|
||||||
|
|
||||||
await this.store.create(data);
|
await this.store.create(data);
|
||||||
|
|
||||||
@ -231,13 +232,9 @@ export default class ProjectService {
|
|||||||
const preData = await this.store.get(updatedProject.id);
|
const preData = await this.store.get(updatedProject.id);
|
||||||
|
|
||||||
if (updatedProject.featureNaming) {
|
if (updatedProject.featureNaming) {
|
||||||
this.validateFlagNaming(updatedProject.featureNaming);
|
this.validateAndProcessFeatureNamingPattern(
|
||||||
}
|
updatedProject.featureNaming,
|
||||||
if (
|
);
|
||||||
updatedProject.featureNaming?.pattern &&
|
|
||||||
!updatedProject.featureNaming?.example
|
|
||||||
) {
|
|
||||||
updatedProject.featureNaming.example = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.store.update(updatedProject);
|
await this.store.update(updatedProject);
|
||||||
|
@ -11,7 +11,11 @@ import { FeatureStrategySchema } from '../../../lib/openapi';
|
|||||||
import User from '../../../lib/types/user';
|
import User from '../../../lib/types/user';
|
||||||
import { IConstraint, IVariant, SKIP_CHANGE_REQUEST } from '../../../lib/types';
|
import { IConstraint, IVariant, SKIP_CHANGE_REQUEST } from '../../../lib/types';
|
||||||
import EnvironmentService from '../../../lib/services/environment-service';
|
import EnvironmentService from '../../../lib/services/environment-service';
|
||||||
import { ForbiddenError, PermissionError } from '../../../lib/error';
|
import {
|
||||||
|
ForbiddenError,
|
||||||
|
PatternError,
|
||||||
|
PermissionError,
|
||||||
|
} from '../../../lib/error';
|
||||||
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
|
import { ISegmentService } from '../../../lib/segments/segment-service-interface';
|
||||||
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
|
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
|
||||||
|
|
||||||
@ -642,7 +646,7 @@ test('getPlaygroundFeatures should return ids and titles (if they exist) on clie
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('flag name validation', () => {
|
describe('flag name validation', () => {
|
||||||
test('should validate names if project has flag name pattern', async () => {
|
test('should validate feature names if the project has flag name pattern', async () => {
|
||||||
const projectId = 'pattern-validation';
|
const projectId = 'pattern-validation';
|
||||||
const featureNaming = {
|
const featureNaming = {
|
||||||
pattern: 'testpattern.+',
|
pattern: 'testpattern.+',
|
||||||
@ -661,51 +665,23 @@ describe('flag name validation', () => {
|
|||||||
|
|
||||||
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
||||||
const invalidFeatures = ['a', 'b', 'c'];
|
const invalidFeatures = ['a', 'b', 'c'];
|
||||||
const result = await service.checkFeatureFlagNamesAgainstProjectPattern(
|
|
||||||
projectId,
|
|
||||||
[...validFeatures, ...invalidFeatures],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
for (const feature of invalidFeatures) {
|
||||||
state: 'invalid',
|
await expect(
|
||||||
invalidNames: new Set(invalidFeatures),
|
service.validateFeatureFlagNameAgainstPattern(
|
||||||
featureNaming: featureNaming,
|
feature,
|
||||||
});
|
|
||||||
|
|
||||||
const validResult =
|
|
||||||
await service.checkFeatureFlagNamesAgainstProjectPattern(
|
|
||||||
projectId,
|
|
||||||
validFeatures,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(validResult).toMatchObject({ state: 'valid' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([null, undefined, ''])(
|
|
||||||
'should not validate names if the pattern is %s',
|
|
||||||
async (pattern) => {
|
|
||||||
const projectId = `empty-pattern-validation-${pattern}`;
|
|
||||||
const featureNaming = {
|
|
||||||
pattern,
|
|
||||||
};
|
|
||||||
const project = {
|
|
||||||
id: projectId,
|
|
||||||
name: projectId,
|
|
||||||
mode: 'open' as const,
|
|
||||||
defaultStickiness: 'default',
|
|
||||||
featureNaming,
|
|
||||||
};
|
|
||||||
|
|
||||||
await stores.projectStore.create(project);
|
|
||||||
|
|
||||||
const features = ['a', 'b'];
|
|
||||||
const result =
|
|
||||||
await service.checkFeatureFlagNamesAgainstProjectPattern(
|
|
||||||
projectId,
|
projectId,
|
||||||
features,
|
),
|
||||||
);
|
).rejects.toBeInstanceOf(PatternError);
|
||||||
|
}
|
||||||
|
|
||||||
expect(result).toMatchObject({ state: 'valid' });
|
for (const feature of validFeatures) {
|
||||||
},
|
await expect(
|
||||||
);
|
service.validateFeatureFlagNameAgainstPattern(
|
||||||
|
feature,
|
||||||
|
projectId,
|
||||||
|
),
|
||||||
|
).resolves.toBeFalsy();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user