Skip to content

Commit b4e2526

Browse files
authored
Fb/resilient scope map handling (#19)
* new todo * wip 1 * remove bad approach * better approach * fix tests and numerous bugs * remove TODOs * more tests and fixes * one more test * empty line * rollback some unnecessary stuff
1 parent 072db7a commit b4e2526

File tree

5 files changed

+113
-32
lines changed

5 files changed

+113
-32
lines changed

src/featureToggles.js

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const { REDIS_INTEGRATION_MODE } = redis;
2424
const { Logger } = require("./logger");
2525
const { isOnCF, cfEnv } = require("./env");
2626
const { HandlerCollection } = require("./shared/handlerCollection");
27-
const { ENV, isNull } = require("./shared/static");
27+
const { ENV, isObject } = require("./shared/static");
2828
const { promiseAllDone } = require("./shared/promiseAllDone");
2929
const { LimitedLazyCache } = require("./shared/cache");
3030

@@ -278,27 +278,32 @@ class FeatureToggles {
278278
return [{ featureKey, errorMessage: "feature key is not valid" }];
279279
}
280280

281-
if (scopeMap) {
281+
if (scopeMap !== undefined) {
282+
if (!isObject(scopeMap)) {
283+
return [
284+
{
285+
featureKey,
286+
errorMessage: "scopeMap must be undefined or an object",
287+
},
288+
];
289+
}
282290
const allowedScopesCheckMap = this.__config[featureKey][CONFIG_KEY.ALLOWED_SCOPES_CHECK_MAP];
283291
for (const [scope, value] of Object.entries(scopeMap)) {
284-
if (allowedScopesCheckMap && !allowedScopesCheckMap[scope]) {
292+
if (!FeatureToggles._isValidScopeMapValue(value)) {
285293
return [
286294
{
287295
featureKey,
288-
scopeKey,
289-
errorMessage: 'scope "{0}" is not allowed',
290-
errorMessageValues: [scope],
296+
errorMessage: 'scope "{0}" has invalid type {1}, must be string',
297+
errorMessageValues: [scope, typeof value],
291298
},
292299
];
293300
}
294-
const scopeType = typeof value;
295-
if (scopeType !== "string") {
301+
if (allowedScopesCheckMap && !allowedScopesCheckMap[scope]) {
296302
return [
297303
{
298304
featureKey,
299-
scopeKey,
300-
errorMessage: 'scope "{0}" has invalid type {1}, must be string',
301-
errorMessageValues: [scope, scopeType],
305+
errorMessage: 'scope "{0}" is not allowed',
306+
errorMessageValues: [scope],
302307
},
303308
];
304309
}
@@ -453,7 +458,7 @@ class FeatureToggles {
453458
*/
454459
async _validateFallbackValues(fallbackValues) {
455460
let validationErrors = [];
456-
if (isNull(fallbackValues) || typeof fallbackValues !== "object") {
461+
if (!isObject(fallbackValues)) {
457462
return validationErrors;
458463
}
459464

@@ -518,7 +523,7 @@ class FeatureToggles {
518523
this.__redisKey,
519524
featureKey,
520525
async (scopedValues) => {
521-
if (typeof scopedValues !== "object" || scopedValues === null) {
526+
if (!isObject(scopedValues)) {
522527
return null;
523528
}
524529
const [validatedScopedValues, scopedValidationErrors] = await this._validateScopedValues(
@@ -761,11 +766,34 @@ class FeatureToggles {
761766
// START OF GET_FEATURE_VALUE SECTION
762767
// ========================================
763768

769+
static _isValidScopeMapValue(value) {
770+
return typeof value === "string";
771+
}
772+
773+
/**
774+
* This is used to make sure scopeMap is either undefined or a shallow map with string entries. This happens for all
775+
* public interfaces with a scopeMap parameter, except {@link validateFeatureValue} and {@link changeFeatureValue}.
776+
* For these two interfaces, we want the "bad" scopeMaps to cause validation errors.
777+
* Also not for {@link getScopeKey}, where the sanitization must not happen in place.
778+
*/
779+
static _sanitizeScopeMap(scopeMap) {
780+
if (!isObject(scopeMap)) {
781+
return undefined;
782+
}
783+
for (const [scope, value] of Object.entries(scopeMap)) {
784+
if (!FeatureToggles._isValidScopeMapValue(value)) {
785+
Reflect.deleteProperty(scopeMap, scope);
786+
}
787+
}
788+
return scopeMap;
789+
}
790+
791+
// NOTE: getScopeMap does the scopeMap sanitization on the fly, because it must not modify scopeMap in place.
764792
static getScopeKey(scopeMap) {
765-
if (typeof scopeMap !== "object" || scopeMap === null) {
793+
if (!isObject(scopeMap)) {
766794
return SCOPE_ROOT_KEY;
767795
}
768-
const scopeMapKeys = Object.keys(scopeMap);
796+
const scopeMapKeys = Object.keys(scopeMap).filter((scope) => FeatureToggles._isValidScopeMapValue(scopeMap[scope]));
769797
if (scopeMapKeys.length === 0) {
770798
return SCOPE_ROOT_KEY;
771799
}
@@ -781,8 +809,8 @@ class FeatureToggles {
781809
// NOTE: there are multiple scopeMaps for every scopeKey with more than one inner entry. This will return the unique
782810
// scopeMap whose keys are sorted, i.e., matching the keys in the scopeKey.
783811
static getScopeMap(scopeKey) {
784-
return typeof scopeKey !== "string" || scopeKey === SCOPE_ROOT_KEY
785-
? {}
812+
return !this._isValidScopeKey(scopeKey) || scopeKey === undefined || scopeKey === SCOPE_ROOT_KEY
813+
? undefined
786814
: scopeKey.split(SCOPE_KEY_OUTER_SEPARATOR).reduce((acc, scopeInnerEntry) => {
787815
const [scopeInnerKey, value] = scopeInnerEntry.split(SCOPE_KEY_INNER_SEPARATOR);
788816
acc[scopeInnerKey] = value;
@@ -877,6 +905,7 @@ class FeatureToggles {
877905
*/
878906
getFeatureValue(featureKey, scopeMap = undefined) {
879907
this._ensureInitialized();
908+
scopeMap = FeatureToggles._sanitizeScopeMap(scopeMap);
880909
return FeatureToggles._getFeatureValueForScopeAndStateAndFallback(
881910
this.__superScopeCache,
882911
this.__stateScopedValues,

src/shared/static.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ const ENV = Object.freeze({
1111

1212
const isNull = (...args) => args.reduce((result, arg) => result || arg === undefined || arg === null, false);
1313

14+
const isObject = (input) => typeof input === "object" && input !== null;
15+
1416
module.exports = {
1517
ENV,
1618
isNull,
19+
isObject,
1720
};

test/featureToggles.test.js

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,9 @@ describe("feature toggles test", () => {
172172
];
173173

174174
let i = 0;
175-
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`{}`);
176-
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`{}`);
177-
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`{}`);
175+
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`undefined`);
176+
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`undefined`);
177+
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`undefined`);
178178
expect(FeatureToggles.getScopeMap(scopeKeys[i++])).toMatchInlineSnapshot(`
179179
{
180180
"tenantId": "t1",
@@ -380,14 +380,8 @@ describe("feature toggles test", () => {
380380
expect(await featureToggles.validateFeatureValue(...inputArgsList[i++])).toMatchInlineSnapshot(`
381381
[
382382
{
383-
"errorMessage": "value "{0}" has invalid type {1}, must be {2}",
384-
"errorMessageValues": [
385-
1,
386-
"number",
387-
"boolean",
388-
],
383+
"errorMessage": "scopeMap must be undefined or an object",
389384
"featureKey": "test/feature_a",
390-
"scopeKey": "//",
391385
},
392386
]
393387
`);
@@ -403,7 +397,6 @@ describe("feature toggles test", () => {
403397
"usr",
404398
],
405399
"featureKey": "test/feature_aa",
406-
"scopeKey": "usr::u1",
407400
},
408401
]
409402
`);
@@ -415,7 +408,6 @@ describe("feature toggles test", () => {
415408
"Tenant",
416409
],
417410
"featureKey": "test/feature_aa",
418-
"scopeKey": "Tenant::t1",
419411
},
420412
]
421413
`);

test/integration-local/__snapshots__/featureToggles.test.js.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ exports[`local integration test after init getFeatureValues, getFeaturesInfos 1`
9393
},
9494
"test/feature_e": {
9595
"config": {
96+
"ALLOWED_SCOPES": [
97+
"component",
98+
"layer",
99+
"tenant",
100+
],
96101
"TYPE": "number",
97102
"VALIDATION": "^\\d{1}$",
98103
},

test/integration-local/featureToggles.test.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const config = {
4848
fallbackValue: 5,
4949
type: "number",
5050
validation: "^\\d{1}$",
51+
allowedScopes: ["component", "layer", "tenant"],
5152
},
5253
[FEATURE.F]: {
5354
fallbackValue: "best",
@@ -193,6 +194,51 @@ describe("local integration test", () => {
193194
expect(redisWrapperLoggerSpy.error).toHaveBeenCalledTimes(0);
194195
});
195196

197+
it("getFeatureValue with bad scopes", async () => {
198+
const oldRootValue = 5;
199+
const trapValue = 9;
200+
const badScopeMap1 = null; // not an object
201+
const badScopeMap2 = { tanent: "undefined" }; // scope that is not allowed
202+
const badScopeMap3 = { tenant: undefined, layer: undefined };
203+
const trapScopeMap = { tenant: "undefined", layer: "undefined" };
204+
205+
expect(await changeFeatureValue(FEATURE.E, trapValue, badScopeMap1)).toMatchInlineSnapshot(`
206+
[
207+
{
208+
"errorMessage": "scopeMap must be undefined or an object",
209+
"featureKey": "test/feature_e",
210+
},
211+
]
212+
`);
213+
expect(await changeFeatureValue(FEATURE.E, trapValue, badScopeMap2)).toMatchInlineSnapshot(`
214+
[
215+
{
216+
"errorMessage": "scope "{0}" is not allowed",
217+
"errorMessageValues": [
218+
"tanent",
219+
],
220+
"featureKey": "test/feature_e",
221+
},
222+
]
223+
`);
224+
expect(await changeFeatureValue(FEATURE.E, trapValue, badScopeMap3)).toMatchInlineSnapshot(`
225+
[
226+
{
227+
"errorMessage": "scope "{0}" has invalid type {1}, must be string",
228+
"errorMessageValues": [
229+
"tenant",
230+
"undefined",
231+
],
232+
"featureKey": "test/feature_e",
233+
},
234+
]
235+
`);
236+
expect(await changeFeatureValue(FEATURE.E, trapValue, trapScopeMap)).toMatchInlineSnapshot(`undefined`);
237+
expect(getFeatureValue(FEATURE.E, badScopeMap1)).toEqual(oldRootValue);
238+
expect(getFeatureValue(FEATURE.E, badScopeMap2)).toEqual(oldRootValue);
239+
expect(getFeatureValue(FEATURE.E, badScopeMap3)).toEqual(oldRootValue);
240+
});
241+
196242
it("getFeatureValue, changeFeatureValue with scopes", async () => {
197243
const rootOldValue = getFeatureValue(FEATURE.E);
198244

@@ -362,6 +408,15 @@ describe("local integration test", () => {
362408
expect(await validateFeatureValue(FEATURE.C, "", { tenant: "t1" })).toMatchInlineSnapshot(`[]`);
363409

364410
// invalid
411+
expect(await validateFeatureValue(FEATURE.C, "", null)).toMatchInlineSnapshot(`
412+
[
413+
{
414+
"errorMessage": "scopeMap must be undefined or an object",
415+
"featureKey": "test/feature_c",
416+
},
417+
]
418+
`);
419+
365420
expect(await validateFeatureValue(FEATURE.C, "", { tenant: { subTenant: "bla" } })).toMatchInlineSnapshot(`
366421
[
367422
{
@@ -371,7 +426,6 @@ describe("local integration test", () => {
371426
"object",
372427
],
373428
"featureKey": "test/feature_c",
374-
"scopeKey": "tenant::[object Object]",
375429
},
376430
]
377431
`);
@@ -384,7 +438,6 @@ describe("local integration test", () => {
384438
"object",
385439
],
386440
"featureKey": "test/feature_c",
387-
"scopeKey": "tenant::a,b,c",
388441
},
389442
]
390443
`);
@@ -397,7 +450,6 @@ describe("local integration test", () => {
397450
"function",
398451
],
399452
"featureKey": "test/feature_c",
400-
"scopeKey": "tenant::() => "1"",
401453
},
402454
]
403455
`);

0 commit comments

Comments
 (0)