diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 2bfe61d6..c9676145 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -14,7 +14,7 @@ import FetchHttpClient, { IHttpClient, IUniversalFlagConfigResponse, } from './http-client'; -import { StoreBackedConfiguration } from './i-configuration'; +import { ImmutableConfiguration } from './i-configuration'; import { BanditParameters, BanditVariation, Flag } from './interfaces'; describe('ConfigurationRequestor', () => { @@ -506,7 +506,7 @@ describe('ConfigurationRequestor', () => { ); const config = requestor.getConfiguration(); - expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config).toBeInstanceOf(ImmutableConfiguration); expect(config.getFlagKeys()).toEqual([]); }); @@ -521,7 +521,7 @@ describe('ConfigurationRequestor', () => { await requestor.fetchAndStoreConfigurations(); const config = requestor.getConfiguration(); - expect(config).toBeInstanceOf(StoreBackedConfiguration); + expect(config).toBeInstanceOf(ImmutableConfiguration); expect(config.getFlagKeys()).toEqual(['test_flag']); }); }); @@ -534,10 +534,11 @@ describe('ConfigurationRequestor', () => { banditVariationStore, banditModelStore, ); - const config = requestor.getConfiguration(); await requestor.fetchAndStoreConfigurations(); + const config = requestor.getConfiguration(); + expect(config.getFlagKeys()).toEqual(['test_flag']); expect(config.getFlagConfigDetails()).toEqual({ configEnvironment: { name: 'Test' }, @@ -554,10 +555,11 @@ describe('ConfigurationRequestor', () => { banditVariationStore, banditModelStore, ); - const config = requestor.getConfiguration(); await requestor.fetchAndStoreConfigurations(); + const config = requestor.getConfiguration(); + // Verify flag configuration expect(config.getFlagKeys()).toEqual(['test_flag']); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 1f48e09e..25b8b8eb 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -11,6 +11,7 @@ import { BanditVariation, BanditParameters, Flag, BanditReference } from './inte export default class ConfigurationRequestor { private banditModelVersions: string[] = []; private readonly configuration: StoreBackedConfiguration; + private configurationCopy: IConfiguration; constructor( private readonly httpClient: IHttpClient, @@ -25,6 +26,7 @@ export default class ConfigurationRequestor { this.banditVariationConfigurationStore, this.banditModelConfigurationStore, ); + this.configurationCopy = this.configuration.copy(); } public isFlagConfigExpired(): Promise { @@ -32,7 +34,7 @@ export default class ConfigurationRequestor { } public getConfiguration(): IConfiguration { - return this.configuration; + return this.configurationCopy; } async fetchAndStoreConfigurations(): Promise { @@ -92,6 +94,7 @@ export default class ConfigurationRequestor { banditModelPacket, ) ) { + this.configurationCopy = this.configuration.copy(); // TODO: Notify that config updated. } } diff --git a/src/i-configuration.spec.ts b/src/i-configuration.spec.ts index e368579f..392f9121 100644 --- a/src/i-configuration.spec.ts +++ b/src/i-configuration.spec.ts @@ -1,6 +1,14 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; -import { StoreBackedConfiguration } from './i-configuration'; -import { BanditParameters, BanditVariation, Environment, Flag, ObfuscatedFlag } from './interfaces'; +import { ImmutableConfiguration, StoreBackedConfiguration } from './i-configuration'; +import { + BanditParameters, + BanditVariation, + ConfigDetails, + Environment, + Flag, + ObfuscatedFlag, + VariationType, +} from './interfaces'; import { BanditKey, FlagKey } from './types'; describe('StoreBackedConfiguration', () => { @@ -428,4 +436,221 @@ describe('StoreBackedConfiguration', () => { ]); }); }); + + describe('copy', () => { + it('should prevent modification of stored config', () => { + const config = new StoreBackedConfiguration(mockFlagStore); + const mockFlag: Flag = { key: 'test-flag', enabled: true } as Flag; + mockFlagStore.get.mockReturnValue(mockFlag); + mockFlagStore.entries.mockReturnValue({ [mockFlag.key]: mockFlag }); + + const storedFlag = config.getFlag('test-flag'); + expect(storedFlag?.enabled).toBeTruthy(); + + const roConfig = config.copy(); + expect(roConfig).toBeInstanceOf(ImmutableConfiguration); + + const flag = roConfig.getFlag('test-flag'); + expect(flag).toBeTruthy(); + if (flag) { + flag.enabled = false; + } else { + fail('flag is null'); + } + + expect(flag?.enabled).toBeFalsy(); + expect(storedFlag?.enabled).toBeTruthy(); + }); + }); +}); + +describe('ImmutableConfiguration', () => { + let config: ImmutableConfiguration; + + const mockFlags: Record = { + 'feature-a': { + key: 'feature-a', + variationType: VariationType.BOOLEAN, + enabled: true, + variations: {}, + allocations: [], + totalShards: 10_000, + }, + 'feature-b': { + key: 'feature-b', + variationType: VariationType.STRING, + enabled: true, + variations: {}, + allocations: [], + totalShards: 10_000, + }, + }; + + const mockBanditVariations: Record = { + 'feature-a': [ + { + key: 'bandit-1', + variationValue: 'bandit-1', + flagKey: 'feature-a', + variationKey: 'bandit-1', + }, + ], + }; + + const mockBandits: Record = { + 'bandit-1': { + banditKey: 'bandit-1', + modelName: 'falcon', + modelVersion: 'v123', + modelData: { + gamma: 0, + defaultActionScore: 0, + actionProbabilityFloor: 0, + coefficients: {}, + }, + }, + }; + + const mockConfigDetails: ConfigDetails = { + configEnvironment: { name: 'test' }, + configFormat: 'SERVER', + configPublishedAt: '2024-03-20T00:00:00Z', + configFetchedAt: '2024-03-20T00:00:00Z', + }; + + beforeEach(() => { + config = new ImmutableConfiguration( + mockFlags, + true, // initialized + false, // obfuscated + mockConfigDetails, + mockBanditVariations, + mockBandits, + ); + }); + + describe('immutability', () => { + it('should create deep copies of input data', () => { + const originalFlags = { ...mockFlags }; + const flags = config.getFlags(); + + // Attempt to modify the returned data + flags['feature-a'].enabled = false; + + // Verify original configuration remains unchanged + expect(originalFlags['feature-a'].enabled).toBeTruthy(); + }); + + it('should create deep copies of bandit variations', () => { + const variations = config.getBanditVariations(); + const originalVariation = [...mockBanditVariations['feature-a']]; + + variations['feature-a'][0].variationValue = 'modified'; + + expect(originalVariation[0].variationValue).toEqual('bandit-1'); + }); + + it('should create deep copies of bandits', () => { + const bandits = config.getBandits(); + const originalBandit = { ...mockBandits['bandit-1'] }; + + bandits['bandit-1'].modelName = 'eagle'; + + expect(originalBandit.modelName).toEqual('falcon'); + }); + + it('should create deep copies of config details', () => { + const details = config.getFlagConfigDetails(); + const originalDetails = { ...mockConfigDetails }; + + details.configFormat = 'CLIENT'; + + expect(originalDetails.configFormat).toEqual('SERVER'); + }); + }); + + describe('flag operations', () => { + it('should get existing flag', () => { + expect(config.getFlag('feature-a')).toEqual(mockFlags['feature-a']); + }); + + it('should return null for non-existent flag', () => { + expect(config.getFlag('non-existent')).toBeNull(); + }); + + it('should get all flags', () => { + expect(config.getFlags()).toEqual(mockFlags); + }); + + it('should get all flag keys', () => { + expect(config.getFlagKeys()).toEqual(['feature-a', 'feature-b']); + }); + }); + + describe('bandit operations', () => { + it('should get bandit variations for flag', () => { + expect(config.getFlagBanditVariations('feature-a')).toEqual( + mockBanditVariations['feature-a'], + ); + }); + + it('should return empty array for flag without bandit variations', () => { + expect(config.getFlagBanditVariations('feature-b')).toEqual([]); + }); + + it('should get specific bandit', () => { + expect(config.getBandit('bandit-1')).toEqual(mockBandits['bandit-1']); + }); + + it('should return null for non-existent bandit', () => { + expect(config.getBandit('non-existent')).toBeNull(); + }); + + it('should get flag variation bandit', () => { + expect(config.getFlagVariationBandit('feature-a', 'bandit-1')).toEqual( + mockBandits['bandit-1'], + ); + }); + + it('should return null for non-matching variation value', () => { + expect(config.getFlagVariationBandit('feature-a', 'control')).toBeNull(); + }); + }); + + describe('configuration state', () => { + it('should return initialization state', () => { + expect(config.isInitialized()).toBe(true); + }); + + it('should return obfuscation state', () => { + expect(config.isObfuscated()).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle configuration without bandit variations', () => { + const configWithoutBandits = new ImmutableConfiguration( + mockFlags, + true, + false, + mockConfigDetails, + ); + + expect(configWithoutBandits.getBanditVariations()).toEqual({}); + expect(configWithoutBandits.getFlagBanditVariations('feature-a')).toEqual([]); + }); + + it('should handle configuration without bandits', () => { + const configWithoutBandits = new ImmutableConfiguration( + mockFlags, + true, + false, + mockConfigDetails, + mockBanditVariations, + ); + + expect(configWithoutBandits.getBandits()).toEqual({}); + expect(configWithoutBandits.getBandit('bandit-1')).toBeNull(); + }); + }); }); diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0df..63dad874 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -1,5 +1,5 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; -import { Entry, hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; +import { Entry } from './configuration-store/configuration-store-utils'; import { OBFUSCATED_FORMATS } from './constants'; import { BanditParameters, @@ -50,22 +50,66 @@ export class StoreBackedConfiguration implements IConfiguration { banditVariationConfig?: ConfigStoreHydrationPacket, banditModelConfig?: ConfigStoreHydrationPacket, ) { - const didUpdateFlags = await hydrateConfigurationStore(this.flagConfigurationStore, flagConfig); + const didUpdateFlags = await StoreBackedConfiguration.hydrateConfigurationStore( + this.flagConfigurationStore, + flagConfig, + ); const promises: Promise[] = []; if (this.banditVariationConfigurationStore && banditVariationConfig) { promises.push( - hydrateConfigurationStore(this.banditVariationConfigurationStore, banditVariationConfig), + StoreBackedConfiguration.hydrateConfigurationStore( + this.banditVariationConfigurationStore, + banditVariationConfig, + ), ); } if (this.banditModelConfigurationStore && banditModelConfig) { promises.push( - hydrateConfigurationStore(this.banditModelConfigurationStore, banditModelConfig), + StoreBackedConfiguration.hydrateConfigurationStore( + this.banditModelConfigurationStore, + banditModelConfig, + ), ); } await Promise.all(promises); return didUpdateFlags; } + private static async hydrateConfigurationStore( + configurationStore: IConfigurationStore | null, + response: { + entries: Record; + environment: Environment; + createdAt: string; + format: string; + salt?: string; + }, + ): Promise { + if (configurationStore) { + const didUpdate = await configurationStore.setEntries(response.entries); + if (didUpdate) { + configurationStore.setEnvironment(response.environment); + configurationStore.setConfigFetchedAt(new Date().toISOString()); + configurationStore.setConfigPublishedAt(response.createdAt); + configurationStore.setFormat(response.format); + configurationStore.salt = response.salt; + } + return didUpdate; + } + return false; + } + + copy(): IConfiguration { + return new ImmutableConfiguration( + this.flagConfigurationStore.entries(), + this.isInitialized(), + this.isObfuscated(), + this.getFlagConfigDetails(), + this.banditVariationConfigurationStore?.entries(), + this.banditModelConfigurationStore?.entries(), + ); + } + getBandit(key: string): BanditParameters | null { return this.banditModelConfigurationStore?.get(key) ?? null; } @@ -131,3 +175,72 @@ export class StoreBackedConfiguration implements IConfiguration { return this.banditVariationConfigurationStore?.entries() ?? {}; } } + +export class ImmutableConfiguration implements IConfiguration { + private readonly flags: Record; + private readonly banditVariations?: Record; + private readonly bandits?: Record; + private readonly flagConfigDetails: ConfigDetails; + + constructor( + flags: Record, + private readonly initialized: boolean, + private readonly obfuscated: boolean, + flagConfigDetails: ConfigDetails, + banditVariations?: Record, + bandits?: Record, + ) { + this.flags = JSON.parse(JSON.stringify(flags)); + this.flagConfigDetails = JSON.parse(JSON.stringify(flagConfigDetails)); + this.banditVariations = banditVariations + ? JSON.parse(JSON.stringify(banditVariations)) + : undefined; + this.bandits = bandits ? JSON.parse(JSON.stringify(bandits || {})) : undefined; + } + + getFlag(key: string): Flag | ObfuscatedFlag | null { + return this.flags[key] ?? null; + } + + getFlags(): Record { + return this.flags; + } + + getBandits(): Record { + return this.bandits ?? {}; + } + + getBanditVariations(): Record { + return this.banditVariations ?? {}; + } + + getFlagBanditVariations(flagKey: string): BanditVariation[] { + return this.banditVariations?.[flagKey] ?? []; + } + + getFlagVariationBandit(flagKey: string, variationValue: string): BanditParameters | null { + const variations = this.getFlagBanditVariations(flagKey); + const banditKey = variations.find((v) => v.variationValue === variationValue)?.key; + return banditKey ? this.getBandit(banditKey) : null; + } + + getBandit(key: string): BanditParameters | null { + return this.bandits?.[key] ?? null; + } + + getFlagConfigDetails(): ConfigDetails { + return this.flagConfigDetails; + } + + getFlagKeys(): string[] { + return Object.keys(this.flags); + } + + isObfuscated(): boolean { + return this.obfuscated; + } + + isInitialized(): boolean { + return this.initialized; + } +}