1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

fix: do not allow creation/update of feature toggle with invalid strategy name (#4555)

This commit is contained in:
Jaanus Sellin 2023-08-23 16:56:22 +03:00 committed by GitHub
parent 604ec5a9ef
commit 0fb078d4c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 120 additions and 52 deletions

View File

@ -39,6 +39,8 @@ import {
createFakeSegmentService,
createSegmentService,
} from '../segment/createSegmentService';
import StrategyStore from '../../db/strategy-store';
import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store';
export const createFeatureToggleService = (
db: Db,
@ -76,6 +78,7 @@ export const createFeatureToggleService = (
flagResolver,
);
const groupStore = new GroupStore(db);
const strategyStore = new StrategyStore(db, getLogger);
const accountStore = new AccountStore(db, getLogger);
const accessStore = new AccessStore(db, eventBus, getLogger);
const roleStore = new RoleStore(db, eventBus, getLogger);
@ -105,6 +108,7 @@ export const createFeatureToggleService = (
featureTagStore,
featureEnvironmentStore,
contextFieldStore,
strategyStore,
},
{ getLogger, flagResolver },
segmentService,
@ -119,6 +123,7 @@ export const createFakeFeatureToggleService = (
): FeatureToggleService => {
const { getLogger, flagResolver } = config;
const eventStore = new FakeEventStore();
const strategyStore = new FakeStrategiesStore();
const featureStrategiesStore = new FakeFeatureStrategiesStore();
const featureToggleStore = new FakeFeatureToggleStore();
const featureToggleClientStore = new FakeFeatureToggleClientStore();
@ -152,6 +157,7 @@ export const createFakeFeatureToggleService = (
featureTagStore,
featureEnvironmentStore,
contextFieldStore,
strategyStore,
},
{ getLogger, flagResolver },
segmentService,

View File

@ -42,6 +42,7 @@ import {
WeightType,
StrategiesOrderChangedEvent,
PotentiallyStaleOnEvent,
IStrategyStore,
} from '../types';
import { Logger } from '../logger';
import BadDataError from '../error/bad-data-error';
@ -118,6 +119,8 @@ class FeatureToggleService {
private featureStrategiesStore: IFeatureStrategiesStore;
private strategyStore: IStrategyStore;
private featureToggleStore: IFeatureToggleStore;
private featureToggleClientStore: IFeatureToggleClientStore;
@ -150,6 +153,7 @@ class FeatureToggleService {
featureTagStore,
featureEnvironmentStore,
contextFieldStore,
strategyStore,
}: Pick<
IUnleashStores,
| 'featureStrategiesStore'
@ -160,6 +164,7 @@ class FeatureToggleService {
| 'featureTagStore'
| 'featureEnvironmentStore'
| 'contextFieldStore'
| 'strategyStore'
>,
{
getLogger,
@ -171,6 +176,7 @@ class FeatureToggleService {
) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
this.strategyStore = strategyStore;
this.featureToggleStore = featureToggleStore;
this.featureToggleClientStore = featureToggleClientStore;
this.tagStore = featureTagStore;
@ -225,24 +231,24 @@ class FeatureToggleService {
validateUpdatedProperties(
{ featureName, projectId }: IFeatureContext,
strategy: IFeatureStrategy,
existingStrategy: IFeatureStrategy,
): void {
if (strategy.projectId !== projectId) {
if (existingStrategy.projectId !== projectId) {
throw new InvalidOperationError(
'You can not change the projectId for an activation strategy.',
);
}
if (strategy.featureName !== featureName) {
if (existingStrategy.featureName !== featureName) {
throw new InvalidOperationError(
'You can not change the featureName for an activation strategy.',
);
}
if (
strategy.parameters &&
'stickiness' in strategy.parameters &&
strategy.parameters.stickiness === ''
existingStrategy.parameters &&
'stickiness' in existingStrategy.parameters &&
existingStrategy.parameters.stickiness === ''
) {
throw new InvalidOperationError(
'You can not have an empty string for stickiness.',
@ -271,6 +277,19 @@ class FeatureToggleService {
}
}
async validateStrategyType(
strategyName: string | undefined,
): Promise<void> {
if (strategyName !== undefined) {
const exists = await this.strategyStore.exists(strategyName);
if (!exists) {
throw new BadDataError(
`Could not find strategy type with name ${strategyName}`,
);
}
}
}
async validateConstraints(
constraints: IConstraint[],
): Promise<IConstraint[]> {
@ -502,6 +521,7 @@ class FeatureToggleService {
const { featureName, projectId, environment } = context;
await this.validateFeatureBelongsToProject(context);
await this.validateStrategyType(strategyConfig.name);
await this.validateProjectCanAccessSegments(
projectId,
strategyConfig.segments,
@ -602,7 +622,7 @@ class FeatureToggleService {
*/
async updateStrategy(
id: string,
updates: Partial<IFeatureStrategy>,
updates: Partial<IStrategyConfig>,
context: IFeatureStrategyContext,
userName: string,
user?: User,
@ -640,13 +660,15 @@ class FeatureToggleService {
async unprotectedUpdateStrategy(
id: string,
updates: Partial<IFeatureStrategy>,
updates: Partial<IStrategyConfig>,
context: IFeatureStrategyContext,
userName: string,
): Promise<Saved<IStrategyConfig>> {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
this.validateUpdatedProperties(context, existingStrategy);
await this.validateStrategyType(updates.name);
await this.validateProjectCanAccessSegments(
projectId,
updates.segments,

View File

@ -57,26 +57,28 @@ const createSegment = async (segmentName: string) => {
const createStrategy = async (
featureName: string,
payload: IStrategyConfig,
expectedCode = 200,
) => {
return app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/default/strategies`,
)
.send(payload)
.expect(200);
.expect(expectedCode);
};
const updateStrategy = async (
featureName: string,
strategyId: string,
payload: IStrategyConfig,
expectedCode = 200,
) => {
const { body } = await app.request
.put(
`/api/admin/projects/default/features/${featureName}/environments/default/strategies/${strategyId}`,
)
.send(payload)
.expect(200);
.expect(expectedCode);
return body;
};
@ -110,8 +112,6 @@ afterEach(async () => {
),
),
);
await db.stores.strategyStore.deleteAll();
});
afterAll(async () => {
@ -2355,7 +2355,7 @@ test('should handle strategy variants', async () => {
await app.createFeature(feature.name);
const strategyWithInvalidVariant = {
name: uuidv4(),
name: 'flexibleRollout',
constraints: [],
variants: [
{
@ -2386,7 +2386,7 @@ test('should handle strategy variants', async () => {
stickiness: 'default',
};
const strategyWithValidVariant = {
name: uuidv4(),
name: 'flexibleRollout',
constraints: [],
variants: [variant],
};
@ -2438,7 +2438,7 @@ test('should reject invalid constraint values for multi-valued constraints', asy
});
const mockStrategy = (values: string[]) => ({
name: uuidv4(),
name: 'flexibleRollout',
constraints: [{ contextName: 'userId', operator: 'IN', values }],
});
@ -2497,7 +2497,7 @@ test('should add default constraint values for single-valued constraints', async
};
const mockStrategy = (constraint: unknown) => ({
name: uuidv4(),
name: 'flexibleRollout',
constraints: [constraint],
});
@ -2544,7 +2544,7 @@ test('should allow long parameter values', async () => {
});
const strategy = {
name: uuidv4(),
name: 'flexibleRollout',
parameters: { a: 'b'.repeat(500) },
};
@ -2582,7 +2582,7 @@ test('should change strategy sort order when payload is valid', async () => {
`/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`,
)
.send({
name: 'gradualrollout',
name: 'flexibleRollout',
parameters: {
userId: 'string',
},
@ -2668,7 +2668,7 @@ test('should return strategies in correct order when new strategies are added',
`/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`,
)
.send({
name: 'gradualrollout',
name: 'flexibleRollout',
parameters: {
userId: 'string',
},
@ -2705,7 +2705,7 @@ test('should return strategies in correct order when new strategies are added',
`/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`,
)
.send({
name: 'gradualrollout',
name: 'flexibleRollout',
parameters: {
userId: 'string',
},
@ -2717,7 +2717,7 @@ test('should return strategies in correct order when new strategies are added',
`/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`,
)
.send({
name: 'gradualrollout',
name: 'flexibleRollout',
parameters: {
userId: 'string',
},
@ -2993,7 +2993,7 @@ test('should return disabled strategies', async () => {
`/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`,
)
.send({
name: 'gradualrollout',
name: 'flexibleRollout',
parameters: {
userId: 'string',
},
@ -3355,3 +3355,42 @@ test('Updating feature strategy sort-order should trigger a an event', async ()
expect(res.body.events[0].type).toBe('strategy-order-changed');
});
});
test('should not be allowed to create with invalid strategy type name', async () => {
const feature = { name: uuidv4(), impressionData: false };
await app.createFeature(feature.name);
await createStrategy(
feature.name,
{
name: 'random-type',
parameters: {
userId: 'string',
},
},
400,
);
});
test('should not be allowed to update with invalid strategy type name', async () => {
const feature = { name: uuidv4(), impressionData: false };
await app.createFeature(feature.name);
const { body: strategyOne } = await createStrategy(feature.name, {
name: 'default',
parameters: {
userId: 'string',
},
});
await updateStrategy(
feature.name,
strategyOne.id,
{
name: 'random-type',
parameters: {
userId: 'string',
},
segments: [],
},
400,
);
});

View File

@ -382,21 +382,6 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () =>
},
userName,
);
await app.services.featureToggleServiceV2.createStrategy(
{
name: 'default',
constraints: [
{ contextName: 'userId', operator: 'IN', values: ['123'] },
],
parameters: {},
},
{
projectId,
featureName,
environment,
},
userName,
);
await app.services.apiTokenService.createApiTokenWithProjects({
tokenName: apiTokenName,
type: ApiTokenType.CLIENT,

View File

@ -29,7 +29,7 @@ test('gets all strategies', async () => {
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.strategies).toHaveLength(2);
expect(res.body.strategies).toHaveLength(3);
});
});

View File

@ -217,7 +217,7 @@ test('Can get strategies for specific environment', async () => {
`/api/admin/projects/default/features/${featureName}/environments/testing/strategies`,
)
.send({
name: 'custom1',
name: 'default',
})
.expect(200);
@ -229,7 +229,7 @@ test('Can get strategies for specific environment', async () => {
expect(res.body.name).toBe(featureName);
expect(res.body.strategies).toHaveLength(1);
expect(
res.body.strategies.find((s) => s.name === 'custom1'),
res.body.strategies.find((s) => s.name === 'default'),
).toBeDefined();
});
});

View File

@ -61,7 +61,7 @@ beforeAll(async () => {
);
await featureToggleServiceV2.createStrategy(
{
name: 'custom-testing',
name: 'flexibleRollout',
constraints: [],
parameters: {},
},
@ -152,7 +152,7 @@ test('returns feature toggle with testing environment config', async () => {
expect(features).toHaveLength(2);
expect(f1.strategies).toHaveLength(1);
expect(f1.strategies[0].name).toBe('custom-testing');
expect(f1.strategies[0].name).toBe('flexibleRollout');
expect(f2.strategies).toHaveLength(1);
expect(query.project[0]).toBe(project);
expect(query.environment).toBe(environment);

View File

@ -66,7 +66,7 @@ const updateSegment = (
const mockStrategy = (segments: number[] = []) => {
return {
name: randomId(),
name: 'flexibleRollout',
parameters: {},
constraints: [],
segments,

View File

@ -463,18 +463,10 @@ test('should filter features by strategies', async () => {
enabled: false,
strategies: [],
});
await createFeatureToggle({
name: 'featureWithUnknownStrategy',
enabled: true,
strategies: [{ name: 'unknown', constraints: [], parameters: {} }],
});
await createFeatureToggle({
name: 'featureWithMultipleStrategies',
enabled: true,
strategies: [
{ name: 'default', constraints: [], parameters: {} },
{ name: 'unknown', constraints: [], parameters: {} },
],
strategies: [{ name: 'default', constraints: [], parameters: {} }],
});
await app.request
.get('/api/frontend')

View File

@ -14,6 +14,30 @@
"type": "string"
}
]
},
{
"name": "flexibleRollout",
"description": "Roll out to a percentage of your userbase, and ensure that the experience is the same for the user on each visit.",
"parameters": [
{
"name": "rollout",
"type": "percentage",
"description": "",
"required": false
},
{
"name": "stickiness",
"type": "string",
"description": "Used define stickiness. Possible values: default, userId, sessionId, random",
"required": true
},
{
"name": "groupId",
"type": "string",
"description": "Used to define a activation groups, which allows you to correlate across feature toggles.",
"required": true
}
]
}
],
"contextFields": [