diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..e063a570 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,103 @@ +import { FallbacksSanitizer } from '../fallbackSanitizer'; +import { TreatmentWithConfig } from '../../../../types/splitio'; + +describe('FallbacksSanitizer', () => { + const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' }; + const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null }; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + + describe('isValidFlagName', () => { + it('returns true for a valid flag name', () => { + // @ts-expect-private-access + expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); + }); + + it('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); + }); + + it('returns false if the name contains spaces', () => { + expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + }); + }); + + describe('isValidTreatment', () => { + it('returns true for a valid treatment string', () => { + expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); + }); + + it('returns false for null or undefined', () => { + expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); + expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); + }); + + it('returns false for a treatment longer than 100 chars', () => { + const long = { treatment: 'a'.repeat(101) }; + expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); + }); + + it('returns false if treatment does not match regex pattern', () => { + const invalid = { treatment: 'invalid treatment!' }; + expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + it('returns the treatment if valid', () => { + expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('returns undefined and logs error if invalid', () => { + const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); + expect(result).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback treatments - Discarded fallback') + ); + }); + }); + + describe('sanitizeByFlag', () => { + it('returns a sanitized map with valid entries only', () => { + const input = { + valid_flag: validTreatment, + 'invalid flag': validTreatment, + bad_treatment: invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + + expect(result).toEqual({ valid_flag: validTreatment }); + expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + }); + + it('returns empty object if all invalid', () => { + const input = { + 'invalid flag': invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual({}); + expect(console.error).toHaveBeenCalled(); + }); + + it('returns same object if all valid', () => { + const input = { + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual(input); + expect(console.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/constants.ts b/src/evaluator/fallbackTreatmentsCalculator/constants.ts new file mode 100644 index 00000000..292bae0f --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/constants.ts @@ -0,0 +1,4 @@ +export enum FallbackDiscardReason { + FlagName = 'Invalid flag name (max 100 chars, no spaces)', + Treatment = 'Invalid treatment (max 100 chars and must match pattern)', +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..a8e07e34 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,63 @@ +import { TreatmentWithConfig } from '../../../../types/splitio'; +import { FallbackDiscardReason } from '../constants'; + + +export class FallbacksSanitizer { + + private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + + private static isValidFlagName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); + } + + private static isValidTreatment(t?: TreatmentWithConfig): boolean { + if (!t || !t.treatment) { + return false; + } + + const { treatment } = t; + + if (treatment.length > 100) { + return false; + } + + return FallbacksSanitizer.pattern.test(treatment); + } + + static sanitizeGlobal(treatment?: TreatmentWithConfig): TreatmentWithConfig | undefined { + if (!this.isValidTreatment(treatment)) { + console.error( + `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` + ); + return undefined; + } + return treatment!; + } + + static sanitizeByFlag( + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.entries(byFlagFallbacks); + entries.forEach(([flag, t]) => { + if (!this.isValidFlagName(flag)) { + console.error( + `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` + ); + return; + } + + if (!this.isValidTreatment(t)) { + console.error( + `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` + ); + return; + } + + sanitizedByFlag[flag] = t; + }); + + return sanitizedByFlag; + } +} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ebeba4df..788029d3 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -621,6 +621,10 @@ declare namespace SplitIO { * User consent status if using in client-side. Undefined if using in server-side (Node.js). */ readonly userConsent?: ConsentStatus; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + readonly fallbackTreatments?: FallbackTreatmentOptions; } /** * Log levels. @@ -1228,6 +1232,15 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + type FallbackTreatmentOptions = { + global?: TreatmentWithConfig | Treatment, + byFlag: { + [key: string]: TreatmentWithConfig | Treatment + } + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */