diff --git a/etc/firebase-admin.remote-config.api.md b/etc/firebase-admin.remote-config.api.md index 1a19d1bb6c..675fdf54a3 100644 --- a/etc/firebase-admin.remote-config.api.md +++ b/etc/firebase-admin.remote-config.api.md @@ -55,6 +55,7 @@ export interface ExperimentParameterValue { // @public export interface ExperimentValue { experimentId: string; + exposurePercent?: number; variantValue: ExperimentVariantValue[]; } diff --git a/src/remote-config/remote-config-api.ts b/src/remote-config/remote-config-api.ts index 6566fe231e..0f2648d797 100644 --- a/src/remote-config/remote-config-api.ts +++ b/src/remote-config/remote-config-api.ts @@ -451,6 +451,12 @@ export interface ExperimentValue { * served by the Experiment. */ variantValue: ExperimentVariantValue[]; + + /** + * The percentage of users included in the Experiment, represented as a number + * between 0 and 100. + */ + exposurePercent?: number; } /** diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index 6ae44c4064..ddd4809537 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -34,6 +34,7 @@ import { InAppDefaultValue, ServerConfig, RemoteConfigParameterValue, + ExperimentParameterValue, EvaluationContext, ServerTemplateData, NamedCondition, @@ -243,6 +244,8 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate { this.parameters = {}; } + validateExperimentExposurePercents(this.parameters); + if (typeof config.parameterGroups !== 'undefined') { if (!validator.isNonNullObject(config.parameterGroups)) { throw new FirebaseRemoteConfigError( @@ -448,6 +451,52 @@ class ServerConfigImpl implements ServerConfig { } } +function validateExperimentExposurePercents( + parameters: { [key: string]: RemoteConfigParameter } +): void { + // Walk each parameter and validate any exposurePercent present in + // conditional values only. Experiment exposure is condition-scoped. + for (const [parameterName, parameter] of Object.entries(parameters)) { + if (!validator.isNonNullObject(parameter)) { + continue; + } + + if (!validator.isNonNullObject(parameter.conditionalValues)) { + continue; + } + + for (const conditionalValue of Object.values(parameter.conditionalValues)) { + validateExperimentExposurePercent(conditionalValue, parameterName); + } + } +} + +function validateExperimentExposurePercent( + parameterValue: RemoteConfigParameterValue | undefined, + parameterName: string, +): void { + // Only experiment-backed values can carry `exposurePercent`. + // For other parameter value types, this validator is a no-op. + if (!validator.isNonNullObject(parameterValue) || + !validator.isNonNullObject((parameterValue as ExperimentParameterValue).experimentValue)) { + return; + } + + const exposurePercent = (parameterValue as ExperimentParameterValue).experimentValue.exposurePercent; + // `exposurePercent` is optional. If absent, leave behavior unchanged. + if (typeof exposurePercent === 'undefined') { + return; + } + + // Enforce public contract: numeric and within [0, 100]. + if (!validator.isNumber(exposurePercent) || !Number.isFinite(exposurePercent) || + exposurePercent < 0 || exposurePercent > 100) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Experiment exposure percent must be between 0 and 100 (${parameterName})`); + } +} + /** * Remote Config dataplane template data implementation. */ diff --git a/test/unit/remote-config/remote-config.spec.ts b/test/unit/remote-config/remote-config.spec.ts index 74d277290e..621290a603 100644 --- a/test/unit/remote-config/remote-config.spec.ts +++ b/test/unit/remote-config/remote-config.spec.ts @@ -134,7 +134,8 @@ describe('RemoteConfig', () => { variantValue: [ { variantId: 'variant_A', value: 'true' }, { variantId: 'variant_B', noChange: true } - ] + ], + exposurePercent: 25, } } }, @@ -233,7 +234,8 @@ describe('RemoteConfig', () => { variantValue: [ { variantId: 'variant_A', value: 'true' }, { variantId: 'variant_B', noChange: true } - ] + ], + exposurePercent: 25, } } }, @@ -607,6 +609,25 @@ describe('RemoteConfig', () => { }); }); + it('should throw if experiment exposure percent is out of range', () => { + sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE); + (sourceTemplate.parameters as any).experiment_enabled + .conditionalValues.ios.experimentValue.exposurePercent = 101; + const jsonString = JSON.stringify(sourceTemplate); + expect(() => remoteConfig.createTemplateFromJSON(jsonString)) + .to.throw('Experiment exposure percent must be between 0 and 100 (experiment_enabled)'); + }); + + it('should accept experiment exposure percent for boundary and middle values', () => { + [0, 52, 100].forEach((validExposurePercent) => { + sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE); + (sourceTemplate.parameters as any).experiment_enabled + .conditionalValues.ios.experimentValue.exposurePercent = validExposurePercent; + const jsonString = JSON.stringify(sourceTemplate); + expect(() => remoteConfig.createTemplateFromJSON(jsonString)).to.not.throw(); + }); + }); + it('should succeed when a valid json string is provided', () => { const jsonString = JSON.stringify(REMOTE_CONFIG_RESPONSE); const newTemplate = remoteConfig.createTemplateFromJSON(jsonString); @@ -652,6 +673,7 @@ describe('RemoteConfig', () => { expect(p4.conditionalValues).to.not.be.undefined; const experimentParam = p4.conditionalValues!['ios'] as ExperimentParameterValue; expect(experimentParam.experimentValue.experimentId).to.equal('experiment_1'); + expect(experimentParam.experimentValue.exposurePercent).to.equal(25); expect(experimentParam.experimentValue.variantValue.length).to.equal(2); expect(experimentParam.experimentValue.variantValue[0]).to.deep.equal({ variantId: 'variant_A', value: 'true' }); expect(experimentParam.experimentValue.variantValue[1]).to.deep.equal({ variantId: 'variant_B', noChange: true }); @@ -1668,6 +1690,19 @@ describe('RemoteConfig', () => { .should.eventually.be.rejected.and.have.property( 'message', 'Remote Config conditions must be an array'); }); + + it('should reject when API response contains invalid experiment exposure percent', () => { + const response = deepCopy(REMOTE_CONFIG_RESPONSE); + (response.parameters as any).experiment_enabled + .conditionalValues.ios.experimentValue.exposurePercent = 101; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, operationName) + .resolves(response); + stubs.push(stub); + return rcOperation() + .should.eventually.be.rejected.and.have.property( + 'message', 'Experiment exposure percent must be between 0 and 100 (experiment_enabled)'); + }); } function runValidResponseTests(rcOperation: () => Promise,