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
3 changes: 2 additions & 1 deletion src/dtos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export interface IRBSegment {
} | null
}

// @TODO: rename to IDefinition (Configs and Feature Flags are definitions)
export interface ISplit {
name: string,
changeNumber: number,
Expand All @@ -231,7 +232,7 @@ export interface ISplit {
trafficAllocation?: number,
trafficAllocationSeed?: number
configurations?: {
[treatmentName: string]: string
[treatmentName: string]: string | SplitIO.JsonObject
},
sets?: string[],
impressionsDisabled?: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FallbackConfigsCalculator } from '../';
import SplitIO from '../../../../types/splitio';
import { CONTROL } from '../../../utils/constants';

describe('FallbackConfigsCalculator', () => {
test('returns specific fallback if config name exists', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {
'configA': { variant: 'VARIANT_A', value: { key: 1 } },
},
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('configA', 'label by name');

expect(result).toEqual({
treatment: 'VARIANT_A',
config: { key: 1 },
label: 'fallback - label by name',
});
});

test('returns global fallback if config name is missing and global exists', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {},
global: { variant: 'GLOBAL_VARIANT', value: { global: true } },
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('missingConfig', 'label by global');

expect(result).toEqual({
treatment: 'GLOBAL_VARIANT',
config: { global: true },
label: 'fallback - label by global',
});
});

test('returns control fallback if config name and global are missing', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {},
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('missingConfig', 'label by noFallback');

expect(result).toEqual({
treatment: CONTROL,
config: null,
label: 'label by noFallback',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { isValidConfigName, isValidConfig, sanitizeFallbacks } from '../fallbackSanitizer';
import SplitIO from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';

describe('FallbackConfigsSanitizer', () => {
const validConfig: SplitIO.Config = { variant: 'on', value: { color: 'blue' } };
const invalidVariantConfig: SplitIO.Config = { variant: ' ', value: { color: 'blue' } };
const invalidValueConfig = { variant: 'on', value: 'not_an_object' } as unknown as SplitIO.Config;
const fallbackMock = {
global: undefined,
byName: {}
};

beforeEach(() => {
loggerMock.mockClear();
});

describe('isValidConfigName', () => {
test('returns true for a valid config name', () => {
expect(isValidConfigName('my_config')).toBe(true);
});

test('returns false for a name longer than 100 chars', () => {
const longName = 'a'.repeat(101);
expect(isValidConfigName(longName)).toBe(false);
});

test('returns false if the name contains spaces', () => {
expect(isValidConfigName('invalid config')).toBe(false);
});

test('returns false if the name is not a string', () => {
// @ts-ignore
expect(isValidConfigName(true)).toBe(false);
});
});

describe('isValidConfig', () => {
test('returns true for a valid config', () => {
expect(isValidConfig(validConfig)).toBe(true);
});

test('returns false for null or undefined', () => {
expect(isValidConfig()).toBe(false);
expect(isValidConfig(undefined)).toBe(false);
});

test('returns false for a variant longer than 100 chars', () => {
const long: SplitIO.Config = { variant: 'a'.repeat(101), value: {} };
expect(isValidConfig(long)).toBe(false);
});

test('returns false if variant does not match regex pattern', () => {
const invalid: SplitIO.Config = { variant: 'invalid variant!', value: {} };
expect(isValidConfig(invalid)).toBe(false);
});

test('returns false if value is not an object', () => {
expect(isValidConfig(invalidValueConfig)).toBe(false);
});
});

describe('sanitizeGlobal', () => {
test('returns the config if valid', () => {
expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validConfig })).toEqual({ ...fallbackMock, global: validConfig });
expect(loggerMock.error).not.toHaveBeenCalled();
});

test('returns undefined and logs error if variant is invalid', () => {
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidVariantConfig });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalledWith(
expect.stringContaining('Fallback configs - Discarded fallback')
);
});

test('returns undefined and logs error if value is invalid', () => {
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidValueConfig });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalledWith(
expect.stringContaining('Fallback configs - Discarded fallback')
);
});
});

describe('sanitizeByName', () => {
test('returns a sanitized map with valid entries only', () => {
const input = {
valid_config: validConfig,
'invalid config': validConfig,
bad_variant: invalidVariantConfig,
};

const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });

expect(result).toEqual({ ...fallbackMock, byName: { valid_config: validConfig } });
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid config name + bad_variant
});

test('returns empty object if all invalid', () => {
const input = {
'invalid config': invalidVariantConfig,
};

const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalled();
});

test('returns same object if all valid', () => {
const input = {
...fallbackMock,
byName: {
config_one: validConfig,
config_two: { variant: 'valid_2', value: { key: 'val' } },
}
};

const result = sanitizeFallbacks(loggerMock, input);
expect(result).toEqual(input);
expect(loggerMock.error).not.toHaveBeenCalled();
});
});

describe('sanitizeFallbacks', () => {
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks');
expect(result).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith(
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
);
});

test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, true);
expect(result).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith(
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
);
});

test('sanitizes both global and byName fallbacks for empty object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, { global: {} });
expect(result).toEqual({ global: undefined, byName: {} });
});
});
});
78 changes: 78 additions & 0 deletions src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import SplitIO from '../../../../types/splitio';
import { ILogger } from '../../../logger/types';
import { isObject, isString } from '../../../utils/lang';

enum FallbackDiscardReason {
ConfigName = 'Invalid config name (max 100 chars, no spaces)',
Variant = 'Invalid variant (max 100 chars and must match pattern)',
Value = 'Invalid value (must be an object)',
}

const VARIANT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;

export function isValidConfigName(name: string): boolean {
return name.length <= 100 && !name.includes(' ');
}

export function isValidConfig(config?: SplitIO.Config): boolean {
if (!isObject(config)) return false;
if (!isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) return false;
if (!isObject(config!.value)) return false;
return true;
}

function sanitizeGlobal(logger: ILogger, config?: SplitIO.Config): SplitIO.Config | undefined {
if (config === undefined) return undefined;
if (!isValidConfig(config)) {
if (!isObject(config) || !isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) {
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Variant}`);
} else {
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Value}`);
}
return undefined;
}
return config;
}

function sanitizeByName(
logger: ILogger,
byNameFallbacks?: Record<string, SplitIO.Config>
): Record<string, SplitIO.Config> {
const sanitizedByName: Record<string, SplitIO.Config> = {};

if (!isObject(byNameFallbacks)) return sanitizedByName;

Object.keys(byNameFallbacks!).forEach((configName) => {
const config = byNameFallbacks![configName];

if (!isValidConfigName(configName)) {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.ConfigName}`);
return;
}

if (!isValidConfig(config)) {
if (!isObject(config) || !isString(config.variant) || config.variant.length > 100 || !VARIANT_PATTERN.test(config.variant)) {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Variant}`);
} else {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Value}`);
}
return;
}

sanitizedByName[configName] = config;
});

return sanitizedByName;
}

export function sanitizeFallbacks(logger: ILogger, fallbacks: SplitIO.FallbackConfigs): SplitIO.FallbackConfigs | undefined {
if (!isObject(fallbacks)) {
logger.error('Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties');
return;
}

return {
global: sanitizeGlobal(logger, fallbacks.global),
byName: sanitizeByName(logger, fallbacks.byName)
};
}
24 changes: 24 additions & 0 deletions src/evaluator/fallbackConfigsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IFallbackCalculator } from '../fallbackTreatmentsCalculator/index';
import { CONTROL } from '../../utils/constants';
import SplitIO from '../../../types/splitio';

export const FALLBACK_PREFIX = 'fallback - ';

export function FallbackConfigsCalculator(fallbacks: SplitIO.FallbackConfigs = {}): IFallbackCalculator {

return (configName: string, label = '') => {
const fallback = fallbacks.byName?.[configName] || fallbacks.global;

return fallback ?
{
treatment: fallback.variant,
config: fallback.value,
label: `${FALLBACK_PREFIX}${label}`,
} :
{
treatment: CONTROL,
config: null,
label,
};
};
}
10 changes: 7 additions & 3 deletions src/evaluator/fallbackTreatmentsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { FallbackTreatmentConfiguration, TreatmentWithConfig } from '../../../types/splitio';
import { CONTROL } from '../../utils/constants';
import { isString } from '../../utils/lang';
import SplitIO from '../../../types/splitio';

export type IFallbackTreatmentsCalculator = (flagName: string, label?: string) => TreatmentWithConfig & { label: string };
export type IFallbackCalculator = (definitionName: string, label?: string) => {
treatment: string;
config: string | null | SplitIO.JsonObject;
label: string
};

export const FALLBACK_PREFIX = 'fallback - ';

export function FallbackTreatmentsCalculator(fallbacks: FallbackTreatmentConfiguration = {}): IFallbackTreatmentsCalculator {
export function FallbackTreatmentsCalculator(fallbacks: SplitIO.FallbackTreatmentConfiguration = {}): IFallbackCalculator {

return (flagName: string, label = '') => {
const fallback = fallbacks.byFlag?.[flagName] || fallbacks.global;
Expand Down
2 changes: 1 addition & 1 deletion src/evaluator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface IEvaluation {
treatment?: string,
label: string,
changeNumber?: number,
config?: string | null
config?: string | null | SplitIO.JsonObject
}

export type IEvaluationResult = IEvaluation & { treatment: string; impressionsDisabled?: boolean }
Expand Down
4 changes: 2 additions & 2 deletions src/sdkClient/__tests__/clientInputValidation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation';
// Mocks
import { DebugLogger } from '../../logger/browser/DebugLogger';
import { createClientMock } from './testUtils';
import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator';
import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator';

const settings: any = {
log: DebugLogger(),
Expand All @@ -14,7 +14,7 @@ const settings: any = {
const EVALUATION_RESULT = 'on';
const client: any = createClientMock(EVALUATION_RESULT);

const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = FallbackTreatmentsCalculator();
const fallbackTreatmentsCalculator = FallbackTreatmentsCalculator();

const readinessManager: any = {
isReadyFromCache: () => true,
Expand Down
6 changes: 3 additions & 3 deletions src/sdkClient/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) {
* Creator of base client with getTreatments and track methods.
*/
export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient {
const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params;
const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackCalculator } = params;
const { log, mode } = settings;
const isAsync = isConsumerMode(mode);

Expand Down Expand Up @@ -147,7 +147,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
let { treatment, label, config = null } = evaluation;

if (treatment === CONTROL) {
const fallbackTreatment = fallbackTreatmentsCalculator(featureFlagName, label);
const fallbackTreatment = fallbackCalculator(featureFlagName, label);
treatment = fallbackTreatment.treatment;
label = fallbackTreatment.label;
config = fallbackTreatment.config;
Expand All @@ -173,7 +173,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
if (withConfig) {
return {
treatment,
config
config: config as string | null
};
}

Expand Down
Loading