mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +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 { ImportValidationMessages } from './import-validation-messages';
|
||||
import { findDuplicates } from '../../util/findDuplicates';
|
||||
import { FeatureNameCheckResult } from 'lib/services/feature-toggle-service';
|
||||
import { FeatureNameCheckResultWithFeaturePattern } from '../../services/feature-toggle-service';
|
||||
|
||||
export default class ExportImportService {
|
||||
private logger: Logger;
|
||||
@ -510,7 +510,7 @@ export default class ExportImportService {
|
||||
private async getInvalidFeatureNames({
|
||||
project,
|
||||
data,
|
||||
}: ImportTogglesSchema): Promise<FeatureNameCheckResult> {
|
||||
}: ImportTogglesSchema): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||
return this.featureToggleService.checkFeatureFlagNamesAgainstProjectPattern(
|
||||
project,
|
||||
data.features.map((f) => f.name),
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { FeatureNameCheckResult } from 'lib/services/feature-toggle-service';
|
||||
import {
|
||||
FeatureStrategySchema,
|
||||
ImportTogglesValidateItemSchema,
|
||||
} from '../../openapi';
|
||||
import { IContextFieldDto } from '../../types/stores/context-field-store';
|
||||
import { FeatureNameCheckResultWithFeaturePattern } from '../../services/feature-toggle-service';
|
||||
import { ProjectFeaturesLimit } from './import-toggles-store-type';
|
||||
|
||||
export interface IErrorsParams {
|
||||
@ -12,7 +12,7 @@ export interface IErrorsParams {
|
||||
contextFields: IContextFieldDto[];
|
||||
otherProjectFeatures: string[];
|
||||
duplicateFeatures: string[];
|
||||
featureNameCheckResult: FeatureNameCheckResult;
|
||||
featureNameCheckResult: FeatureNameCheckResultWithFeaturePattern;
|
||||
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 { ISegmentService } from 'lib/segments/segment-service-interface';
|
||||
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 {
|
||||
featureName: string;
|
||||
@ -112,7 +113,7 @@ export interface IGetFeatureParams {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export type FeatureNameCheckResult =
|
||||
export type FeatureNameCheckResultWithFeaturePattern =
|
||||
| { state: 'valid' }
|
||||
| {
|
||||
state: 'invalid';
|
||||
@ -1103,24 +1104,20 @@ class FeatureToggleService {
|
||||
async checkFeatureFlagNamesAgainstProjectPattern(
|
||||
projectId: string,
|
||||
featureNames: string[],
|
||||
): Promise<FeatureNameCheckResult> {
|
||||
): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||
if (this.flagResolver.isEnabled('featureNamingPattern')) {
|
||||
const project = await this.projectStore.get(projectId);
|
||||
const patternData = project.featureNaming;
|
||||
const namingPattern = patternData?.pattern;
|
||||
|
||||
if (namingPattern) {
|
||||
const regex = new RegExp(namingPattern);
|
||||
const mismatchedNames = featureNames.filter(
|
||||
(name) => !regex.test(name),
|
||||
const result = checkFeatureFlagNamesAgainstPattern(
|
||||
featureNames,
|
||||
namingPattern,
|
||||
);
|
||||
|
||||
if (mismatchedNames.length > 0) {
|
||||
return {
|
||||
state: 'invalid',
|
||||
invalidNames: new Set(mismatchedNames),
|
||||
featureNaming: patternData,
|
||||
};
|
||||
if (result.state === 'invalid') {
|
||||
return { ...result, featureNaming: patternData };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
import { uniqueByKey } from '../util/unique';
|
||||
import { BadDataError, PermissionError } from '../error';
|
||||
import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
||||
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
||||
|
||||
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
||||
|
||||
@ -169,25 +170,23 @@ export default class ProjectService {
|
||||
return this.store.get(id);
|
||||
}
|
||||
|
||||
private validateFlagNaming = (naming?: IFeatureNaming) => {
|
||||
if (naming) {
|
||||
const { pattern, example } = naming;
|
||||
if (
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
private validateAndProcessFeatureNamingPattern = (
|
||||
featureNaming: IFeatureNaming,
|
||||
): IFeatureNaming => {
|
||||
const validationResult = checkFeatureNamingData(featureNaming);
|
||||
|
||||
if (!pattern && example) {
|
||||
throw new BadDataError(
|
||||
"You've provided a feature flag naming example, but no feature flag naming pattern. You must specify a pattern to use an example.",
|
||||
);
|
||||
}
|
||||
if (validationResult.state === 'invalid') {
|
||||
throw new BadDataError(validationResult.reason);
|
||||
}
|
||||
|
||||
if (featureNaming.pattern && !featureNaming.example) {
|
||||
featureNaming.example = null;
|
||||
}
|
||||
if (featureNaming.pattern && !featureNaming.description) {
|
||||
featureNaming.description = null;
|
||||
}
|
||||
|
||||
return featureNaming;
|
||||
};
|
||||
|
||||
async createProject(
|
||||
@ -197,7 +196,9 @@ export default class ProjectService {
|
||||
const data = await projectSchema.validateAsync(newProject);
|
||||
await this.validateUniqueId(data.id);
|
||||
|
||||
this.validateFlagNaming(data.featureNaming);
|
||||
if (data.featureNaming) {
|
||||
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
|
||||
}
|
||||
|
||||
await this.store.create(data);
|
||||
|
||||
@ -231,13 +232,9 @@ export default class ProjectService {
|
||||
const preData = await this.store.get(updatedProject.id);
|
||||
|
||||
if (updatedProject.featureNaming) {
|
||||
this.validateFlagNaming(updatedProject.featureNaming);
|
||||
}
|
||||
if (
|
||||
updatedProject.featureNaming?.pattern &&
|
||||
!updatedProject.featureNaming?.example
|
||||
) {
|
||||
updatedProject.featureNaming.example = null;
|
||||
this.validateAndProcessFeatureNamingPattern(
|
||||
updatedProject.featureNaming,
|
||||
);
|
||||
}
|
||||
|
||||
await this.store.update(updatedProject);
|
||||
|
@ -11,7 +11,11 @@ import { FeatureStrategySchema } from '../../../lib/openapi';
|
||||
import User from '../../../lib/types/user';
|
||||
import { IConstraint, IVariant, SKIP_CHANGE_REQUEST } from '../../../lib/types';
|
||||
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 { 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', () => {
|
||||
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 featureNaming = {
|
||||
pattern: 'testpattern.+',
|
||||
@ -661,51 +665,23 @@ describe('flag name validation', () => {
|
||||
|
||||
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
||||
const invalidFeatures = ['a', 'b', 'c'];
|
||||
const result = await service.checkFeatureFlagNamesAgainstProjectPattern(
|
||||
projectId,
|
||||
[...validFeatures, ...invalidFeatures],
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: 'invalid',
|
||||
invalidNames: new Set(invalidFeatures),
|
||||
featureNaming: featureNaming,
|
||||
});
|
||||
|
||||
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(
|
||||
for (const feature of invalidFeatures) {
|
||||
await expect(
|
||||
service.validateFeatureFlagNameAgainstPattern(
|
||||
feature,
|
||||
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