Skip to content

Commit 272e670

Browse files
authored
Fb/fix validation for null value (#63)
* wip 1 * wip 2 * wip 3 * wip 4 * test for new behavior
1 parent 54bed49 commit 272e670

File tree

4 files changed

+119
-22
lines changed

4 files changed

+119
-22
lines changed

example-cap-server/http/feature-service.http

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ Content-Type: application/json
105105
"options": {
106106
"clearSubScopes": true
107107
}
108+
},
109+
{
110+
"key": "/fts/check-service-extension",
111+
"value": null,
112+
"options": {
113+
"clearSubScopes": true
114+
}
108115
}
109116
]
110117

src/featureToggles.js

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,12 @@ class FeatureToggles {
349349
return scopeKey === undefined || typeof scopeKey === "string";
350350
}
351351

352+
static _isValidScopeMapValue(value) {
353+
return typeof value === "string";
354+
}
355+
352356
// NOTE: this function is used during initialization, so we cannot check this.__isInitialized
353-
async _validateFeatureValue(featureKey, value, scopeMap, scopeKey) {
357+
async _validateFeatureValue(featureKey, value, { scopeMap, scopeKey, isChange = false } = {}) {
354358
if (!this.__isConfigProcessed) {
355359
return [{ errorMessage: "not initialized" }];
356360
}
@@ -395,9 +399,14 @@ class FeatureToggles {
395399
return [{ featureKey, scopeKey, errorMessage: "scopeKey is not valid" }];
396400
}
397401

398-
// NOTE: value === null is our way of encoding featureKey resetting changes, so it is always allowed
402+
// NOTE: value === null is our way of encoding featureKey resetting changes, so it is allowed for changes but not
403+
// for actual values
399404
if (value === null) {
400-
return [];
405+
if (isChange) {
406+
return [];
407+
} else {
408+
return [{ featureKey, ...(scopeKey && { scopeKey }), errorMessage: "value null is not allowed" }];
409+
}
401410
}
402411

403412
// NOTE: skip validating active properties during initialization
@@ -533,7 +542,10 @@ class FeatureToggles {
533542
async validateFeatureValue(featureKey, value, scopeMap = undefined) {
534543
return scopeMap === undefined
535544
? await this._validateFeatureValue(featureKey, value)
536-
: await this._validateFeatureValue(featureKey, value, scopeMap, FeatureToggles.getScopeKey(scopeMap));
545+
: await this._validateFeatureValue(featureKey, value, {
546+
scopeMap,
547+
scopeKey: FeatureToggles.getScopeKey(scopeMap),
548+
});
537549
}
538550

539551
/**
@@ -560,12 +572,10 @@ class FeatureToggles {
560572
let validatedStateScopedValues = {};
561573

562574
for (const [scopeKey, value] of Object.entries(scopedValues)) {
563-
const entryValidationErrors = await this._validateFeatureValue(
564-
featureKey,
565-
value,
566-
FeatureToggles.getScopeMap(scopeKey),
567-
scopeKey
568-
);
575+
const entryValidationErrors = await this._validateFeatureValue(featureKey, value, {
576+
scopeMap: FeatureToggles.getScopeMap(scopeKey),
577+
scopeKey,
578+
});
569579
let updateValue = value;
570580
if (Array.isArray(entryValidationErrors) && entryValidationErrors.length > 0) {
571581
validationErrors = validationErrors.concat(entryValidationErrors);
@@ -902,10 +912,6 @@ class FeatureToggles {
902912
// START OF GET_FEATURE_VALUE SECTION
903913
// ========================================
904914

905-
static _isValidScopeMapValue(value) {
906-
return typeof value === "string";
907-
}
908-
909915
/**
910916
* This is used to make sure scopeMap is either undefined or a shallow map with string entries. This happens for all
911917
* public interfaces with a scopeMap parameter, except {@link validateFeatureValue} and {@link changeFeatureValue}.
@@ -1242,7 +1248,11 @@ class FeatureToggles {
12421248
scopeMap
12431249
);
12441250

1245-
const validationErrors = await this._validateFeatureValue(featureKey, newValue, scopeMap, scopeKey);
1251+
const validationErrors = await this._validateFeatureValue(featureKey, newValue, {
1252+
scopeMap,
1253+
scopeKey,
1254+
isChange: true,
1255+
});
12461256
if (Array.isArray(validationErrors) && validationErrors.length > 0) {
12471257
logger.warning(
12481258
new VError(
@@ -1289,7 +1299,11 @@ class FeatureToggles {
12891299

12901300
async _changeRemoteFeatureValue(featureKey, newValue, scopeMap, options) {
12911301
const scopeKey = FeatureToggles.getScopeKey(scopeMap);
1292-
const validationErrors = await this._validateFeatureValue(featureKey, newValue, scopeMap, scopeKey);
1302+
const validationErrors = await this._validateFeatureValue(featureKey, newValue, {
1303+
scopeMap,
1304+
scopeKey,
1305+
isChange: true,
1306+
});
12931307
if (Array.isArray(validationErrors) && validationErrors.length > 0) {
12941308
return validationErrors;
12951309
}

test/__snapshots__/featureToggles.test.js.snap

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,26 @@ exports[`feature toggles test basic apis initializeFeatureToggles 1`] = `
201201
},
202202
}
203203
`;
204+
205+
exports[`feature toggles test basic apis initializeFeatureToggles warns for invalid values 1`] = `
206+
{
207+
"test/feature_a": {
208+
"SOURCE": "RUNTIME",
209+
"TYPE": "boolean",
210+
},
211+
"test/feature_b": {
212+
"SOURCE": "RUNTIME",
213+
"TYPE": "string",
214+
},
215+
"test/feature_c": {
216+
"SOURCE": "RUNTIME",
217+
"TYPE": "number",
218+
},
219+
}
220+
`;
221+
222+
exports[`feature toggles test basic apis initializeFeatureToggles warns for invalid values 3`] = `
223+
{
224+
"validationErrors": "[{"featureKey":"test/feature_b","errorMessage":"value null is not allowed"},{"featureKey":"test/feature_c","errorMessage":"value \\"{0}\\" has invalid type {1}, must be {2}","errorMessageValues":["1","string","number"]}]",
225+
}
226+
`;

test/featureToggles.test.js

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ jest.mock("../src/shared/env", () => require("./__mocks__/env"));
2525
const redisWrapperMock = require("../src/redisWrapper");
2626
jest.mock("../src/redisWrapper", () => require("./__mocks__/redisWrapper"));
2727

28-
const mockFallbackValues = Object.fromEntries(
29-
Object.entries(mockConfig).map(([key, { fallbackValue }]) => [key, fallbackValue])
30-
);
31-
const mockActiveKeys = Object.entries(mockConfig)
32-
.filter(([, value]) => value.active !== false)
33-
.map(([key]) => key);
28+
const configToFallbackValues = (config) =>
29+
Object.fromEntries(Object.entries(config).map(([key, { fallbackValue }]) => [key, fallbackValue]));
30+
const configToActiveKeys = (config) =>
31+
Object.entries(config)
32+
.filter(([, value]) => value.active !== false)
33+
.map(([key]) => key);
34+
35+
const mockFallbackValues = configToFallbackValues(mockConfig);
36+
const mockActiveKeys = configToActiveKeys(mockConfig);
3437

3538
let featureToggles = null;
3639
const loggerSpy = {
@@ -243,6 +246,56 @@ describe("feature toggles test", () => {
243246
expect(loggerSpy.error).not.toHaveBeenCalled();
244247
});
245248

249+
it("initializeFeatureToggles warns for invalid values", async () => {
250+
const badMockConfig = {
251+
[FEATURE.A]: {
252+
fallbackValue: false, // valid
253+
type: "boolean",
254+
},
255+
[FEATURE.B]: {
256+
fallbackValue: null, // null not allowed
257+
type: "string",
258+
},
259+
[FEATURE.C]: {
260+
fallbackValue: "1", // type mismatch
261+
type: "number",
262+
},
263+
};
264+
const fallbackValues = configToFallbackValues(badMockConfig);
265+
const activeKeys = configToActiveKeys(badMockConfig);
266+
await featureToggles.initializeFeatures({ config: badMockConfig });
267+
268+
expect(featureToggles.__isInitialized).toBe(true);
269+
expect(featureToggles.__fallbackValues).toStrictEqual(fallbackValues);
270+
expect(featureToggles.__stateScopedValues).toStrictEqual({});
271+
expect(featureToggles.__config).toMatchSnapshot();
272+
expect(redisWrapperMock.watchedHashGetSetObject).toHaveBeenCalledTimes(activeKeys.length);
273+
for (let fieldIndex = 0; fieldIndex < activeKeys.length; fieldIndex++) {
274+
expect(redisWrapperMock.watchedHashGetSetObject).toHaveBeenNthCalledWith(
275+
fieldIndex + 1,
276+
redisKey,
277+
activeKeys[fieldIndex],
278+
expect.any(Function)
279+
);
280+
}
281+
expect(redisWrapperMock.registerMessageHandler).toHaveBeenCalledTimes(1);
282+
expect(redisWrapperMock.registerMessageHandler).toHaveBeenCalledWith(
283+
redisChannel,
284+
featureToggles.__messageHandler
285+
);
286+
expect(redisWrapperMock.subscribe).toHaveBeenCalledTimes(1);
287+
expect(redisWrapperMock.subscribe).toHaveBeenCalledWith(redisChannel);
288+
289+
expect(loggerSpy.warning).toHaveBeenCalledTimes(1);
290+
expect(loggerSpy.warning).toHaveBeenCalledWith(expect.any(VError));
291+
const [loggedErr] = loggerSpy.warning.mock.calls[0];
292+
expect(loggedErr).toMatchInlineSnapshot(
293+
`[FeatureTogglesError: found invalid fallback values during initialization]`
294+
);
295+
expect(VError.info(loggedErr)).toMatchSnapshot();
296+
expect(loggerSpy.error).not.toHaveBeenCalled();
297+
});
298+
246299
it("_changeRemoteFeatureValues", async () => {
247300
await featureToggles.initializeFeatures({ config: mockConfig });
248301
redisWrapperMock.watchedHashGetSetObject.mockClear();

0 commit comments

Comments
 (0)