diff --git a/Makefile b/Makefile index 79799fc5..f9c69b92 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ test-data: mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} cp ${gitDataDir}rac-experiments-v3.json ${testDataDir} + cp ${gitDataDir}rac-experiments-v3-obfuscated.json ${testDataDir} cp -r ${gitDataDir}assignment-v2 ${testDataDir} rm -rf ${tempDir} diff --git a/package.json b/package.json index ea4a6172..9608dde0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "1.5.2", + "version": "1.6.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ @@ -21,7 +21,8 @@ "pre-commit": "lint-staged && tsc", "typecheck": "tsc", "test": "yarn test:unit", - "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'" + "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", + "obfuscate-mock-rac": "ts-node test/writeObfuscatedMockRac" }, "jsdelivr": "dist/eppo-sdk.js", "repository": { @@ -52,6 +53,7 @@ "testdouble": "^3.16.6", "ts-jest": "^28.0.5", "ts-loader": "^9.3.1", + "ts-node": "^10.9.1", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-cli": "^4.10.0", diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 9533f359..25551ad7 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -7,6 +7,8 @@ import mock from 'xhr-mock'; import { IAssignmentTestCase, + MOCK_RAC_RESPONSE_FILE, + OBFUSCATED_MOCK_RAC_RESPONSE_FILE, ValueTestType, readAssignmentTestData, readMockRacResponse, @@ -69,7 +71,7 @@ describe('EppoClient E2E test', () => { beforeAll(async () => { mock.setup(); mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { - const rac = readMockRacResponse(); + const rac = readMockRacResponse(MOCK_RAC_RESPONSE_FILE); return res.status(200).body(JSON.stringify(rac)); }); @@ -403,6 +405,7 @@ describe('EppoClient E2E test', () => { }[], experiment: string, valueTestType: ValueTestType = ValueTestType.StringType, + obfuscated = false, ): (EppoValue | null)[] { return subjectsWithAttributes.map((subject) => { switch (valueTestType) { @@ -411,6 +414,8 @@ describe('EppoClient E2E test', () => { subject.subjectKey, experiment, subject.subjectAttributes, + undefined, + obfuscated, ); if (ba === null) return null; return EppoValue.Bool(ba); @@ -542,3 +547,131 @@ describe('EppoClient E2E test', () => { }); }); }); + +describe(' EppoClient getAssignment From Obfuscated RAC', () => { + const storage = new TestConfigurationStore(); + const globalClient = new EppoClient(storage); + + beforeAll(async () => { + mock.setup(); + mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => { + const rac = readMockRacResponse(OBFUSCATED_MOCK_RAC_RESPONSE_FILE); + return res.status(200).body(JSON.stringify(rac)); + }); + await init(storage); + }); + + afterAll(() => { + mock.teardown(); + }); + + it.each(readAssignmentTestData())( + 'test variation assignment splits', + async ({ + experiment, + valueType = ValueTestType.StringType, + subjects, + subjectsWithAttributes, + expectedAssignments, + }: IAssignmentTestCase) => { + `---- Test Case for ${experiment} Experiment ----`; + + const assignments = getAssignmentsWithSubjectAttributes( + subjectsWithAttributes + ? subjectsWithAttributes + : subjects.map((subject) => ({ subjectKey: subject })), + experiment, + valueType, + ); + + switch (valueType) { + case ValueTestType.BoolType: { + const boolAssignments = assignments.map((a) => a?.boolValue ?? null); + expect(boolAssignments).toEqual(expectedAssignments); + break; + } + case ValueTestType.NumericType: { + const numericAssignments = assignments.map((a) => a?.numericValue ?? null); + expect(numericAssignments).toEqual(expectedAssignments); + break; + } + case ValueTestType.StringType: { + const stringAssignments = assignments.map((a) => a?.stringValue ?? null); + expect(stringAssignments).toEqual(expectedAssignments); + break; + } + case ValueTestType.JSONType: { + const jsonStringAssignments = assignments.map((a) => a?.stringValue ?? null); + expect(jsonStringAssignments).toEqual(expectedAssignments); + break; + } + } + }, + ); + + function getAssignmentsWithSubjectAttributes( + subjectsWithAttributes: { + subjectKey: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + subjectAttributes?: Record; + }[], + experiment: string, + valueTestType: ValueTestType = ValueTestType.StringType, + ): (EppoValue | null)[] { + return subjectsWithAttributes.map((subject) => { + switch (valueTestType) { + case ValueTestType.BoolType: { + const ba = globalClient.getBoolAssignment( + subject.subjectKey, + experiment, + subject.subjectAttributes, + undefined, + true, + ); + if (ba === null) return null; + return EppoValue.Bool(ba); + } + case ValueTestType.NumericType: { + const na = globalClient.getNumericAssignment( + subject.subjectKey, + experiment, + subject.subjectAttributes, + undefined, + true, + ); + if (na === null) return null; + return EppoValue.Numeric(na); + } + case ValueTestType.StringType: { + const sa = globalClient.getStringAssignment( + subject.subjectKey, + experiment, + subject.subjectAttributes, + undefined, + true, + ); + if (sa === null) return null; + return EppoValue.String(sa); + } + case ValueTestType.JSONType: { + const sa = globalClient.getJSONStringAssignment( + subject.subjectKey, + experiment, + subject.subjectAttributes, + undefined, + true, + ); + const oa = globalClient.getParsedJSONAssignment( + subject.subjectKey, + experiment, + subject.subjectAttributes, + undefined, + true, + ); + if (oa == null || sa === null) return null; + return EppoValue.JSON(sa, oa); + } + } + }); + } +}); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 29d77e7c..89187d90 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -7,6 +7,7 @@ import { MAX_EVENT_QUEUE_SIZE } from '../constants'; import { IAllocation } from '../dto/allocation-dto'; import { IExperimentConfiguration } from '../dto/experiment-configuration-dto'; import { EppoValue, ValueType } from '../eppo_value'; +import { getMD5Hash } from '../obfuscation'; import { findMatchingRule } from '../rule_evaluator'; import { getShard, isShardInRange } from '../shard'; import { validateNotBlank } from '../validation'; @@ -100,11 +101,17 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): string | null { try { return ( - this.getAssignmentVariation(subjectKey, flagKey, subjectAttributes, assignmentHooks) - .stringValue ?? null + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + obfuscated, + ).stringValue ?? null ); } catch (error) { return this.rethrowIfNotGraceful(error); @@ -117,6 +124,7 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): string | null { try { return ( @@ -125,6 +133,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, ValueType.StringType, ).stringValue ?? null ); @@ -139,6 +148,7 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): boolean | null { try { return ( @@ -147,6 +157,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, ValueType.BoolType, ).boolValue ?? null ); @@ -160,6 +171,7 @@ export default class EppoClient implements IEppoClient { flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): number | null { try { return ( @@ -168,6 +180,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, ValueType.NumericType, ).numericValue ?? null ); @@ -182,6 +195,7 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): string | null { try { return ( @@ -190,6 +204,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, ValueType.JSONType, ).stringValue ?? null ); @@ -204,6 +219,7 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, + obfuscated = false, ): object | null { try { return ( @@ -212,6 +228,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, ValueType.JSONType, ).objectValue ?? null ); @@ -234,6 +251,7 @@ export default class EppoClient implements IEppoClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks: IAssignmentHooks | undefined, + obfuscated: boolean, valueType?: ValueType, ): EppoValue { const { allocationKey, assignment } = this.getAssignmentInternal( @@ -241,6 +259,7 @@ export default class EppoClient implements IEppoClient { flagKey, subjectAttributes, assignmentHooks, + obfuscated, valueType, ); assignmentHooks?.onPostAssignment(flagKey, subjectKey, assignment, allocationKey); @@ -256,6 +275,7 @@ export default class EppoClient implements IEppoClient { flagKey: string, subjectAttributes = {}, assignmentHooks: IAssignmentHooks | undefined, + obfuscated: boolean, expectedValueType?: ValueType, ): { allocationKey: string | null; assignment: EppoValue } { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); @@ -263,7 +283,9 @@ export default class EppoClient implements IEppoClient { const nullAssignment = { allocationKey: null, assignment: EppoValue.Null() }; - const experimentConfig = this.configurationStore.get(flagKey); + const experimentConfig = this.configurationStore.get( + obfuscated ? getMD5Hash(flagKey) : flagKey, + ); const allowListOverride = this.getSubjectVariationOverride( subjectKey, experimentConfig, @@ -288,7 +310,11 @@ export default class EppoClient implements IEppoClient { } // Attempt to match a rule from the list. - const matchedRule = findMatchingRule(subjectAttributes || {}, experimentConfig.rules); + const matchedRule = findMatchingRule( + subjectAttributes || {}, + experimentConfig.rules, + obfuscated, + ); if (!matchedRule) return nullAssignment; // Check if subject is in allocation sample. diff --git a/src/obfuscation.spec.ts b/src/obfuscation.spec.ts new file mode 100644 index 00000000..3fe273d0 --- /dev/null +++ b/src/obfuscation.spec.ts @@ -0,0 +1,11 @@ +import { decodeBase64, encodeBase64 } from './obfuscation'; + +describe('obfuscation', () => { + it('encodes strings to base64', () => { + expect(encodeBase64('5.0')).toEqual('NS4w'); + }); + + it('decodes base64 to string', () => { + expect(Number(decodeBase64('NS4w'))).toEqual(5); + }); +}); diff --git a/src/obfuscation.ts b/src/obfuscation.ts new file mode 100644 index 00000000..bb427a76 --- /dev/null +++ b/src/obfuscation.ts @@ -0,0 +1,13 @@ +import * as md5 from 'md5'; + +export function getMD5Hash(input: string): string { + return md5(input); +} + +export function encodeBase64(input: string): string { + return Buffer.from(input, 'utf8').toString('base64'); +} + +export function decodeBase64(input: string): string { + return Buffer.from(input, 'base64').toString('utf8'); +} diff --git a/src/rule_evaluator.spec.ts b/src/rule_evaluator.spec.ts index 02f8fc4d..2d4107e5 100644 --- a/src/rule_evaluator.spec.ts +++ b/src/rule_evaluator.spec.ts @@ -34,39 +34,39 @@ describe('findMatchingRule', () => { it('returns null if rules array is empty', () => { const rules: IRule[] = []; - expect(findMatchingRule({ name: 'my-user' }, rules)).toEqual(null); + expect(findMatchingRule({ name: 'my-user' }, rules, false)).toEqual(null); }); it('returns null if attributes do not match any rules', () => { const rules = [numericRule]; - expect(findMatchingRule({ totalSales: 101 }, rules)).toEqual(null); + expect(findMatchingRule({ totalSales: 101 }, rules, false)).toEqual(null); }); it('returns the rule if attributes match AND conditions', () => { const rules = [numericRule]; - expect(findMatchingRule({ totalSales: 100 }, rules)).toEqual(numericRule); + expect(findMatchingRule({ totalSales: 100 }, rules, false)).toEqual(numericRule); }); it('returns null if there is no attribute for the condition', () => { const rules = [numericRule]; - expect(findMatchingRule({ unknown: 'test' }, rules)).toEqual(null); + expect(findMatchingRule({ unknown: 'test' }, rules, false)).toEqual(null); }); it('returns the rule if it has no conditions', () => { const rules = [ruleWithEmptyConditions]; - expect(findMatchingRule({ totalSales: 101 }, rules)).toEqual(ruleWithEmptyConditions); + expect(findMatchingRule({ totalSales: 101 }, rules, false)).toEqual(ruleWithEmptyConditions); }); it('returns null if using numeric operator with string', () => { const rules = [numericRule, ruleWithMatchesCondition]; - expect(findMatchingRule({ totalSales: 'stringValue' }, rules)).toEqual(null); - expect(findMatchingRule({ totalSales: '20' }, rules)).toEqual(null); + expect(findMatchingRule({ totalSales: 'stringValue' }, rules, false)).toEqual(null); + expect(findMatchingRule({ totalSales: '20' }, rules, false)).toEqual(null); }); it('handles rule with matches operator', () => { const rules = [ruleWithMatchesCondition]; - expect(findMatchingRule({ user_id: '14' }, rules)).toEqual(ruleWithMatchesCondition); - expect(findMatchingRule({ user_id: 14 }, rules)).toEqual(ruleWithMatchesCondition); + expect(findMatchingRule({ user_id: '14' }, rules, false)).toEqual(ruleWithMatchesCondition); + expect(findMatchingRule({ user_id: 14 }, rules, false)).toEqual(ruleWithMatchesCondition); }); it('handles oneOf rule type with boolean', () => { @@ -90,10 +90,10 @@ describe('findMatchingRule', () => { }, ], }; - expect(findMatchingRule({ enabled: true }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ enabled: false }, [oneOfRule])).toEqual(null); - expect(findMatchingRule({ enabled: true }, [notOneOfRule])).toEqual(null); - expect(findMatchingRule({ enabled: false }, [notOneOfRule])).toEqual(notOneOfRule); + expect(findMatchingRule({ enabled: true }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ enabled: false }, [oneOfRule], false)).toEqual(null); + expect(findMatchingRule({ enabled: true }, [notOneOfRule], false)).toEqual(null); + expect(findMatchingRule({ enabled: false }, [notOneOfRule], false)).toEqual(notOneOfRule); }); it('handles oneOf rule type with string', () => { @@ -117,11 +117,11 @@ describe('findMatchingRule', () => { }, ], }; - expect(findMatchingRule({ userId: 'user1' }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 'user2' }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 'user3' }, [oneOfRule])).toEqual(null); - expect(findMatchingRule({ userId: 'user14' }, [notOneOfRule])).toEqual(null); - expect(findMatchingRule({ userId: 'user15' }, [notOneOfRule])).toEqual(notOneOfRule); + expect(findMatchingRule({ userId: 'user1' }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ userId: 'user2' }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ userId: 'user3' }, [oneOfRule], false)).toEqual(null); + expect(findMatchingRule({ userId: 'user14' }, [notOneOfRule], false)).toEqual(null); + expect(findMatchingRule({ userId: 'user15' }, [notOneOfRule], false)).toEqual(notOneOfRule); }); it('does case insensitive matching with oneOf operator', () => { @@ -135,8 +135,8 @@ describe('findMatchingRule', () => { }, ], }; - expect(findMatchingRule({ country: 'us' }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ country: 'cA' }, [oneOfRule])).toEqual(oneOfRule); + expect(findMatchingRule({ country: 'us' }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ country: 'cA' }, [oneOfRule], false)).toEqual(oneOfRule); }); it('does case insensitive matching with notOneOf operator', () => { @@ -150,7 +150,7 @@ describe('findMatchingRule', () => { }, ], }; - expect(findMatchingRule({ deviceType: '1ab' }, [notOneOf])).toEqual(null); + expect(findMatchingRule({ deviceType: '1ab' }, [notOneOf], false)).toEqual(null); }); it('handles oneOf rule with number', () => { @@ -174,10 +174,10 @@ describe('findMatchingRule', () => { }, ], }; - expect(findMatchingRule({ userId: 1 }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ userId: '2' }, [oneOfRule])).toEqual(oneOfRule); - expect(findMatchingRule({ userId: 3 }, [oneOfRule])).toEqual(null); - expect(findMatchingRule({ userId: 14 }, [notOneOfRule])).toEqual(null); - expect(findMatchingRule({ userId: '15' }, [notOneOfRule])).toEqual(notOneOfRule); + expect(findMatchingRule({ userId: 1 }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ userId: '2' }, [oneOfRule], false)).toEqual(oneOfRule); + expect(findMatchingRule({ userId: 3 }, [oneOfRule], false)).toEqual(null); + expect(findMatchingRule({ userId: 14 }, [notOneOfRule], false)).toEqual(null); + expect(findMatchingRule({ userId: '15' }, [notOneOfRule], false)).toEqual(notOneOfRule); }); }); diff --git a/src/rule_evaluator.ts b/src/rule_evaluator.ts index d11b682e..942e09b3 100644 --- a/src/rule_evaluator.ts +++ b/src/rule_evaluator.ts @@ -1,28 +1,43 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Condition, OperatorType, IRule } from './dto/rule-dto'; +import { decodeBase64, getMD5Hash } from './obfuscation'; export function findMatchingRule( subjectAttributes: Record, rules: IRule[], + obfuscated: boolean, ): IRule | null { for (const rule of rules) { - if (matchesRule(subjectAttributes, rule)) { + if (matchesRule(subjectAttributes, rule, obfuscated)) { return rule; } } return null; } -function matchesRule(subjectAttributes: Record, rule: IRule): boolean { - const conditionEvaluations = evaluateRuleConditions(subjectAttributes, rule.conditions); +function matchesRule( + subjectAttributes: Record, + rule: IRule, + obfuscated: boolean, +): boolean { + const conditionEvaluations = evaluateRuleConditions( + subjectAttributes, + rule.conditions, + obfuscated, + ); return !conditionEvaluations.includes(false); } function evaluateRuleConditions( subjectAttributes: Record, conditions: Condition[], + obfuscated: boolean, ): boolean[] { - return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)); + return conditions.map((condition) => + obfuscated + ? evaluateObfuscatedCondition(subjectAttributes, condition) + : evaluateCondition(subjectAttributes, condition), + ); } function evaluateCondition(subjectAttributes: Record, condition: Condition): boolean { @@ -40,24 +55,60 @@ function evaluateCondition(subjectAttributes: Record, condition: Co case OperatorType.MATCHES: return new RegExp(condition.value as string).test(value as string); case OperatorType.ONE_OF: - return isOneOf(value, condition.value); + return isOneOf( + value.toString().toLowerCase(), + condition.value.map((value: string) => value.toLowerCase()), + ); case OperatorType.NOT_ONE_OF: - return isNotOneOf(value, condition.value); + return isNotOneOf( + value.toString().toLowerCase(), + condition.value.map((value: string) => value.toLowerCase()), + ); + } + } + return false; +} + +function evaluateObfuscatedCondition( + subjectAttributes: Record, + condition: Condition, +): boolean { + const hashedSubjectAttributes: Record = Object.entries(subjectAttributes).reduce( + (accum, [key, val]) => ({ [getMD5Hash(key)]: val, ...accum }), + {}, + ); + const value = hashedSubjectAttributes[condition.attribute]; + if (value != null) { + switch (condition.operator) { + case getMD5Hash(OperatorType.GTE): + return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a >= b); + case getMD5Hash(OperatorType.GT): + return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a > b); + case getMD5Hash(OperatorType.LTE): + return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a <= b); + case getMD5Hash(OperatorType.LT): + return compareNumber(value, Number(decodeBase64(condition.value)), (a, b) => a < b); + case getMD5Hash(OperatorType.MATCHES): + return new RegExp(decodeBase64(condition.value)).test(value as string); + case getMD5Hash(OperatorType.ONE_OF): + return isOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); + case getMD5Hash(OperatorType.NOT_ONE_OF): + return isNotOneOf(getMD5Hash(value.toString().toLowerCase()), condition.value); } } return false; } -function isOneOf(attributeValue: any, conditionValue: string[]) { - return getMatchingStringValues(attributeValue.toString(), conditionValue).length > 0; +function isOneOf(attributeValue: string, conditionValue: string[]) { + return getMatchingStringValues(attributeValue, conditionValue).length > 0; } -function isNotOneOf(attributeValue: any, conditionValue: string[]) { - return getMatchingStringValues(attributeValue.toString(), conditionValue).length === 0; +function isNotOneOf(attributeValue: string, conditionValue: string[]) { + return getMatchingStringValues(attributeValue, conditionValue).length === 0; } function getMatchingStringValues(attributeValue: string, conditionValues: string[]): string[] { - return conditionValues.filter((value) => value.toLowerCase() === attributeValue.toLowerCase()); + return conditionValues.filter((value) => value === attributeValue); } function compareNumber( diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 719e17b6..75732b23 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -6,7 +6,9 @@ import { IValue } from '../src/eppo_value'; export const TEST_DATA_DIR = './test/data/'; export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'assignment-v2/'; -export const MOCK_RAC_RESPONSE_FILE = 'rac-experiments-v3.json'; +const MOCK_RAC_FILENAME = 'rac-experiments-v3'; +export const MOCK_RAC_RESPONSE_FILE = `${MOCK_RAC_FILENAME}.json`; +export const OBFUSCATED_MOCK_RAC_RESPONSE_FILE = `${MOCK_RAC_FILENAME}-obfuscated.json`; export enum ValueTestType { BoolType = 'boolean', @@ -26,8 +28,10 @@ export interface IAssignmentTestCase { expectedAssignments: IValue[]; } -export function readMockRacResponse(): Record { - return JSON.parse(fs.readFileSync(TEST_DATA_DIR + MOCK_RAC_RESPONSE_FILE, 'utf-8')); +export function readMockRacResponse(filename: string): { + flags: Record; +} { + return JSON.parse(fs.readFileSync(TEST_DATA_DIR + filename, 'utf-8')); } export function readAssignmentTestData(): IAssignmentTestCase[] { diff --git a/test/writeObfuscatedMockRac.ts b/test/writeObfuscatedMockRac.ts new file mode 100644 index 00000000..06f66cda --- /dev/null +++ b/test/writeObfuscatedMockRac.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; + +import { encodeBase64, getMD5Hash } from '../src/obfuscation'; + +import { + MOCK_RAC_RESPONSE_FILE, + OBFUSCATED_MOCK_RAC_RESPONSE_FILE, + TEST_DATA_DIR, + readMockRacResponse, +} from './testHelpers'; + +export function generateObfuscatedMockRac() { + const rac = readMockRacResponse(MOCK_RAC_RESPONSE_FILE); + const keys = Object.keys(rac.flags); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flagsCopy: Record = {}; + keys.forEach((key) => { + flagsCopy[getMD5Hash(key)] = rac.flags[key]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + flagsCopy[getMD5Hash(key)].rules?.forEach((rule: any) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rule.conditions.forEach((condition: any) => { + condition['value'] = ['ONE_OF', 'NOT_ONE_OF'].includes(condition['operator']) + ? condition['value'].map((value: string) => getMD5Hash(value.toLowerCase())) + : encodeBase64(`${condition['value']}`); + condition['operator'] = getMD5Hash(condition['operator']); + condition['attribute'] = getMD5Hash(condition['attribute']); + }), + ); + }); + return { flags: flagsCopy }; +} + +const obfuscatedRacFilePath = TEST_DATA_DIR + OBFUSCATED_MOCK_RAC_RESPONSE_FILE; +const obfuscatedRac = generateObfuscatedMockRac(); +fs.writeFileSync(obfuscatedRacFilePath, JSON.stringify(obfuscatedRac, null, 2)); diff --git a/tsconfig.json b/tsconfig.json index 78af2e1a..639d2bdf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,4 +16,4 @@ "src/**/*.spec.ts", "test/**/*" ] -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 91be1299..df5a843f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -284,6 +284,13 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz" @@ -567,6 +574,14 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz" integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9": version "0.3.13" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz" @@ -620,6 +635,26 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz" @@ -1025,11 +1060,21 @@ acorn-walk@^7.1.1: resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + acorn@^7.1.1: version "7.4.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.4.1: + version "8.10.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== + acorn@^8.5.0, acorn@^8.7.1: version "8.7.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz" @@ -1096,6 +1141,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" @@ -1418,6 +1468,11 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -1527,6 +1582,11 @@ diff-sequences@^28.1.1: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" @@ -3048,7 +3108,7 @@ make-dir@^3.0.0: dependencies: semver "^6.0.0" -make-error@1.x: +make-error@1.x, make-error@^1.1.1: version "1.3.6" resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -3902,6 +3962,25 @@ ts-loader@^9.3.1: micromatch "^4.0.0" semver "^7.3.4" +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz" @@ -3996,6 +4075,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz" @@ -4233,3 +4317,8 @@ yargs@^17.3.1: string-width "^4.2.3" y18n "^5.0.5" yargs-parser "^21.0.0" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==