Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
4 changes: 4 additions & 0 deletions src/evaluator/fallbackTreatmentsCalculator/constants.ts
Original file line number Diff line number Diff line change
@@ -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)',
}
Original file line number Diff line number Diff line change
@@ -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<string, TreatmentWithConfig>
): Record<string, TreatmentWithConfig> {
const sanitizedByFlag: Record<string, TreatmentWithConfig> = {};

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;
}
}
13 changes: 13 additions & 0 deletions types/splitio.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down