Skip to content

Commit ef7cd27

Browse files
Merge pull request #451 from splitio/fix-fallbacks-sanitizer
Fix and apply fallback sanitization during settings validation
2 parents 501174d + 5ae4013 commit ef7cd27

File tree

10 files changed

+130
-171
lines changed

10 files changed

+130
-171
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ jobs:
2424
- name: Set up nodejs
2525
uses: actions/setup-node@v4
2626
with:
27-
node-version: 'lts/*'
27+
# @TODO: rollback to 'lts/*'
28+
node-version: '22'
2829
cache: 'npm'
2930

3031
- name: npm CI

src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts

Lines changed: 3 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,15 @@
11
import { FallbackTreatmentsCalculator } from '../';
22
import type { FallbackTreatmentConfiguration } from '../../../../types/splitio';
3-
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
43
import { CONTROL } from '../../../utils/constants';
54

65
describe('FallbackTreatmentsCalculator' , () => {
7-
const longName = 'a'.repeat(101);
8-
9-
test('logs an error if flag name is invalid - by Flag', () => {
10-
let config: FallbackTreatmentConfiguration = {
11-
byFlag: {
12-
'feature A': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
13-
},
14-
};
15-
new FallbackTreatmentsCalculator(loggerMock, config);
16-
expect(loggerMock.error.mock.calls[0][0]).toBe(
17-
'Fallback treatments - Discarded flag \'feature A\': Invalid flag name (max 100 chars, no spaces)'
18-
);
19-
config = {
20-
byFlag: {
21-
[longName]: { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
22-
},
23-
};
24-
new FallbackTreatmentsCalculator(loggerMock, config);
25-
expect(loggerMock.error.mock.calls[1][0]).toBe(
26-
`Fallback treatments - Discarded flag '${longName}': Invalid flag name (max 100 chars, no spaces)`
27-
);
28-
29-
config = {
30-
byFlag: {
31-
'featureB': { treatment: longName, config: '{ value: 1 }' },
32-
},
33-
};
34-
new FallbackTreatmentsCalculator(loggerMock, config);
35-
expect(loggerMock.error.mock.calls[2][0]).toBe(
36-
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
37-
);
38-
39-
config = {
40-
byFlag: {
41-
// @ts-ignore
42-
'featureC': { config: '{ global: true }' },
43-
},
44-
};
45-
new FallbackTreatmentsCalculator(loggerMock, config);
46-
expect(loggerMock.error.mock.calls[3][0]).toBe(
47-
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
48-
);
49-
50-
config = {
51-
byFlag: {
52-
// @ts-ignore
53-
'featureC': { treatment: 'invalid treatment!', config: '{ global: true }' },
54-
},
55-
};
56-
new FallbackTreatmentsCalculator(loggerMock, config);
57-
expect(loggerMock.error.mock.calls[4][0]).toBe(
58-
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
59-
);
60-
});
61-
62-
test('logs an error if flag name is invalid - global', () => {
63-
let config: FallbackTreatmentConfiguration = {
64-
global: { treatment: longName, config: '{ value: 1 }' },
65-
};
66-
new FallbackTreatmentsCalculator(loggerMock, config);
67-
expect(loggerMock.error.mock.calls[2][0]).toBe(
68-
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
69-
);
70-
71-
config = {
72-
// @ts-ignore
73-
global: { config: '{ global: true }' },
74-
};
75-
new FallbackTreatmentsCalculator(loggerMock, config);
76-
expect(loggerMock.error.mock.calls[3][0]).toBe(
77-
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
78-
);
79-
80-
config = {
81-
// @ts-ignore
82-
global: { treatment: 'invalid treatment!', config: '{ global: true }' },
83-
};
84-
new FallbackTreatmentsCalculator(loggerMock, config);
85-
expect(loggerMock.error.mock.calls[4][0]).toBe(
86-
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
87-
);
88-
});
89-
906
test('returns specific fallback if flag exists', () => {
917
const config: FallbackTreatmentConfiguration = {
928
byFlag: {
939
'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
9410
},
9511
};
96-
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
12+
const calculator = new FallbackTreatmentsCalculator(config);
9713
const result = calculator.resolve('featureA', 'label by flag');
9814

9915
expect(result).toEqual({
@@ -108,7 +24,7 @@ describe('FallbackTreatmentsCalculator' , () => {
10824
byFlag: {},
10925
global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' },
11026
};
111-
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
27+
const calculator = new FallbackTreatmentsCalculator(config);
11228
const result = calculator.resolve('missingFlag', 'label by global');
11329

11430
expect(result).toEqual({
@@ -122,7 +38,7 @@ describe('FallbackTreatmentsCalculator' , () => {
12238
const config: FallbackTreatmentConfiguration = {
12339
byFlag: {},
12440
};
125-
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
41+
const calculator = new FallbackTreatmentsCalculator(config);
12642
const result = calculator.resolve('missingFlag', 'label by noFallback');
12743

12844
expect(result).toEqual({
Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,70 @@
1-
import { FallbacksSanitizer } from '../fallbackSanitizer';
1+
import { isValidFlagName, isValidTreatment, sanitizeFallbacks } from '../fallbackSanitizer';
22
import { TreatmentWithConfig } from '../../../../types/splitio';
33
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
44

55
describe('FallbacksSanitizer', () => {
66
const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' };
77
const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null };
8+
const fallbackMock = {
9+
global: undefined,
10+
byFlag: {}
11+
};
812

913
beforeEach(() => {
10-
jest.spyOn(console, 'error').mockImplementation(() => {});
11-
});
12-
13-
afterEach(() => {
14-
(loggerMock.error as jest.Mock).mockRestore();
14+
loggerMock.mockClear();
1515
});
1616

1717
describe('isValidFlagName', () => {
1818
test('returns true for a valid flag name', () => {
1919
// @ts-expect-private-access
20-
expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true);
20+
expect(isValidFlagName('my_flag')).toBe(true);
2121
});
2222

2323
test('returns false for a name longer than 100 chars', () => {
2424
const longName = 'a'.repeat(101);
25-
expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false);
25+
expect(isValidFlagName(longName)).toBe(false);
26+
});
27+
28+
test('returns false if the name contains spaces', () => {
29+
expect(isValidFlagName('invalid flag')).toBe(false);
2630
});
2731

2832
test('returns false if the name contains spaces', () => {
29-
expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false);
33+
// @ts-ignore
34+
expect(isValidFlagName(true)).toBe(false);
3035
});
3136
});
3237

3338
describe('isValidTreatment', () => {
3439
test('returns true for a valid treatment string', () => {
35-
expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true);
40+
expect(isValidTreatment(validTreatment)).toBe(true);
3641
});
3742

3843
test('returns false for null or undefined', () => {
39-
expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false);
40-
expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false);
44+
expect(isValidTreatment()).toBe(false);
45+
expect(isValidTreatment(undefined)).toBe(false);
4146
});
4247

4348
test('returns false for a treatment longer than 100 chars', () => {
44-
const long = { treatment: 'a'.repeat(101) };
45-
expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false);
49+
const long = { treatment: 'a'.repeat(101), config: null };
50+
expect(isValidTreatment(long)).toBe(false);
4651
});
4752

4853
test('returns false if treatment does not match regex pattern', () => {
49-
const invalid = { treatment: 'invalid treatment!' };
50-
expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false);
54+
const invalid = { treatment: 'invalid treatment!', config: null };
55+
expect(isValidTreatment(invalid)).toBe(false);
5156
});
5257
});
5358

5459
describe('sanitizeGlobal', () => {
5560
test('returns the treatment if valid', () => {
56-
expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment);
61+
expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validTreatment })).toEqual({ ...fallbackMock, global: validTreatment });
5762
expect(loggerMock.error).not.toHaveBeenCalled();
5863
});
5964

6065
test('returns undefined and logs error if invalid', () => {
61-
const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment);
62-
expect(result).toBeUndefined();
66+
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidTreatment });
67+
expect(result).toEqual(fallbackMock);
6368
expect(loggerMock.error).toHaveBeenCalledWith(
6469
expect.stringContaining('Fallback treatments - Discarded fallback')
6570
);
@@ -74,9 +79,9 @@ describe('FallbacksSanitizer', () => {
7479
bad_treatment: invalidTreatment,
7580
};
7681

77-
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
82+
const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input});
7883

79-
expect(result).toEqual({ valid_flag: validTreatment });
84+
expect(result).toEqual({ ...fallbackMock, byFlag: { valid_flag: validTreatment } });
8085
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment
8186
});
8287

@@ -85,20 +90,46 @@ describe('FallbacksSanitizer', () => {
8590
'invalid flag': invalidTreatment,
8691
};
8792

88-
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
89-
expect(result).toEqual({});
93+
const result = sanitizeFallbacks(loggerMock, {...fallbackMock, byFlag: input});
94+
expect(result).toEqual(fallbackMock);
9095
expect(loggerMock.error).toHaveBeenCalled();
9196
});
9297

9398
test('returns same object if all valid', () => {
9499
const input = {
95-
flag_one: validTreatment,
96-
flag_two: { treatment: 'valid_2', config: null },
100+
...fallbackMock,
101+
byFlag:{
102+
flag_one: validTreatment,
103+
flag_two: { treatment: 'valid_2', config: null },
104+
}
97105
};
98106

99-
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
107+
const result = sanitizeFallbacks(loggerMock, input);
100108
expect(result).toEqual(input);
101109
expect(loggerMock.error).not.toHaveBeenCalled();
102110
});
103111
});
112+
113+
describe('sanitizeFallbacks', () => {
114+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
115+
const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks');
116+
expect(result).toBeUndefined();
117+
expect(loggerMock.error).toHaveBeenCalledWith(
118+
'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'
119+
);
120+
});
121+
122+
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
123+
const result = sanitizeFallbacks(loggerMock, true);
124+
expect(result).toBeUndefined();
125+
expect(loggerMock.error).toHaveBeenCalledWith(
126+
'Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties'
127+
);
128+
});
129+
130+
test('sanitizes both global and byFlag fallbacks for empty object', () => { // @ts-expect-error
131+
const result = sanitizeFallbacks(loggerMock, { global: {} });
132+
expect(result).toEqual({ global: undefined, byFlag: {} });
133+
});
134+
});
104135
});
Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Treatment, TreatmentWithConfig } from '../../../../types/splitio';
1+
import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../../types/splitio';
22
import { ILogger } from '../../../logger/types';
33
import { isObject, isString } from '../../../utils/lang';
44

@@ -7,59 +7,65 @@ enum FallbackDiscardReason {
77
Treatment = 'Invalid treatment (max 100 chars and must match pattern)',
88
}
99

10-
export class FallbacksSanitizer {
10+
const TREATMENT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;
1111

12-
private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;
13-
14-
private static isValidFlagName(name: string): boolean {
15-
return name.length <= 100 && !name.includes(' ');
16-
}
12+
export function isValidFlagName(name: string): boolean {
13+
return name.length <= 100 && !name.includes(' ');
14+
}
1715

18-
private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean {
19-
const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t;
16+
export function isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean {
17+
const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t;
2018

21-
if (!isString(treatment) || treatment.length > 100) {
22-
return false;
23-
}
24-
return FallbacksSanitizer.pattern.test(treatment);
19+
if (!isString(treatment) || treatment.length > 100) {
20+
return false;
2521
}
22+
return TREATMENT_PATTERN.test(treatment);
23+
}
2624

27-
static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined {
28-
if (!this.isValidTreatment(treatment)) {
29-
logger.error(
30-
`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`
31-
);
32-
return undefined;
33-
}
34-
return treatment;
25+
function sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined {
26+
if (treatment === undefined) return undefined;
27+
if (!isValidTreatment(treatment)) {
28+
logger.error(`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`);
29+
return undefined;
3530
}
31+
return treatment;
32+
}
3633

37-
static sanitizeByFlag(
38-
logger: ILogger,
39-
byFlagFallbacks: Record<string, Treatment | TreatmentWithConfig>
40-
): Record<string, Treatment | TreatmentWithConfig> {
41-
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};
34+
function sanitizeByFlag(
35+
logger: ILogger,
36+
byFlagFallbacks?: Record<string, Treatment | TreatmentWithConfig>
37+
): Record<string, Treatment | TreatmentWithConfig> {
38+
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};
4239

43-
const entries = Object.keys(byFlagFallbacks);
44-
entries.forEach((flag) => {
45-
const t = byFlagFallbacks[flag];
46-
if (!this.isValidFlagName(flag)) {
47-
logger.error(
48-
`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`
49-
);
50-
return;
51-
}
40+
if (!isObject(byFlagFallbacks)) return sanitizedByFlag;
5241

53-
if (!this.isValidTreatment(t)) {
54-
logger.error(
55-
`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`
56-
);
57-
return;
58-
}
42+
Object.keys(byFlagFallbacks!).forEach((flag) => {
43+
const t = byFlagFallbacks![flag];
5944

60-
sanitizedByFlag[flag] = t;
61-
});
45+
if (!isValidFlagName(flag)) {
46+
logger.error(`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`);
47+
return;
48+
}
49+
50+
if (!isValidTreatment(t)) {
51+
logger.error(`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`);
52+
return;
53+
}
54+
55+
sanitizedByFlag[flag] = t;
56+
});
6257

63-
return sanitizedByFlag;
58+
return sanitizedByFlag;
59+
}
60+
61+
export function sanitizeFallbacks(logger: ILogger, fallbacks: FallbackTreatmentConfiguration): FallbackTreatmentConfiguration | undefined {
62+
if (!isObject(fallbacks)) {
63+
logger.error('Fallback treatments - Discarded configuration: it must be an object with optional `global` and `byFlag` properties');
64+
return;
6465
}
66+
67+
return {
68+
global: sanitizeGlobal(logger, fallbacks.global),
69+
byFlag: sanitizeByFlag(logger, fallbacks.byFlag)
70+
};
6571
}

0 commit comments

Comments
 (0)