diff --git a/src/remote-config/remote-config-api-client.ts b/src/remote-config/remote-config-api-client.ts index 53f69435f5..0c8c99521a 100644 --- a/src/remote-config/remote-config-api-client.ts +++ b/src/remote-config/remote-config-api-client.ts @@ -14,10 +14,70 @@ * limitations under the License. */ -import { FirebaseRemoteConfigError } from './remote-config-utils'; +import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request'; +import { PrefixedFirebaseError } from '../utils/error'; +import { FirebaseRemoteConfigError, RemoteConfigErrorCode } from './remote-config-utils'; import { FirebaseApp } from '../firebase-app'; +import * as utils from '../utils/index'; import * as validator from '../utils/validator'; +// Remote Config backend constants +const FIREBASE_REMOTE_CONFIG_V1_API = 'https://firebaseremoteconfig.googleapis.com/v1'; +const FIREBASE_REMOTE_CONFIG_HEADERS = { + 'X-Firebase-Client': 'fire-admin-node/', + // There is a known issue in which the ETag is not properly returned in cases where the request + // does not specify a compression type. Currently, it is required to include the header + // `Accept-Encoding: gzip` or equivalent in all requests. + // https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates + 'Accept-Encoding': 'gzip', +}; + +enum ConditionDisplayColor { + UNSPECIFIED = "Unspecified", + BLUE = "Blue", + BROWN = "Brown", + CYAN = "Cyan", + DEEP_ORANGE = "Red Orange", + GREEN = "Green", + INDIGO = "Indigo", + LIME = "Lime", + ORANGE = "Orange", + PINK = "Pink", + PURPLE = "Purple", + TEAL = "Teal", +} + +/** Interface representing a Remote Config parameter `value` in value options. */ +export interface ExplicitParameterValue { + readonly value: string; +} + +/** Interface representing a Remote Config parameter `useInAppDefault` in value options. */ +export interface InAppDefaultValue { + readonly useInAppDefault: boolean; +} + +export type RemoteConfigParameterValue = ExplicitParameterValue | InAppDefaultValue; + +/** Interface representing a Remote Config parameter. */ +export interface RemoteConfigParameter { + readonly defaultValue?: RemoteConfigParameterValue; + readonly conditionalValues?: { [key: string]: RemoteConfigParameterValue }; + readonly description?: string; +} + +interface RemoteConfigCondition { + name: string; + expression: string; + tagColor?: ConditionDisplayColor; +} + +export interface RemoteConfigResponse { + readonly conditions?: RemoteConfigCondition[]; + readonly parameters?: { [key: string]: RemoteConfigParameter }; + readonly etag: string; +} + /** * Class that facilitates sending requests to the Firebase Remote Config backend API. * @@ -25,12 +85,109 @@ import * as validator from '../utils/validator'; */ export class RemoteConfigApiClient { - constructor(app: FirebaseApp) { + private readonly httpClient: HttpClient; + private projectIdPrefix?: string; + + constructor(private readonly app: FirebaseApp) { if (!validator.isNonNullObject(app) || !('options' in app)) { throw new FirebaseRemoteConfigError( 'invalid-argument', - 'First argument passed to admin.RemoteConfig() must be a valid Firebase app ' - + 'instance.'); + 'First argument passed to admin.remoteConfig() must be a valid Firebase app instance.'); } + + this.httpClient = new AuthorizedHttpClient(app); + } + + public getTemplate(): Promise { + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/remoteConfig`, + headers: FIREBASE_REMOTE_CONFIG_HEADERS + }; + return this.httpClient.send(request); + }) + .then((resp) => { + if (!Object.prototype.hasOwnProperty.call(resp.headers, 'etag')) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'ETag header is not present in the server response.'); + } + return { + conditions: resp.data.conditions, + parameters: resp.data.parameters, + etag: resp.headers['etag'], + }; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private getUrl(): Promise { + return this.getProjectIdPrefix() + .then((projectIdPrefix) => { + return `${FIREBASE_REMOTE_CONFIG_V1_API}/${projectIdPrefix}`; + }); + } + + private getProjectIdPrefix(): Promise { + if (this.projectIdPrefix) { + return Promise.resolve(this.projectIdPrefix); + } + + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseRemoteConfigError( + 'unknown-error', + 'Failed to determine project ID. Initialize the SDK with service account credentials, or ' + + 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT ' + + 'environment variable.'); + } + + this.projectIdPrefix = `projects/${projectId}`; + return this.projectIdPrefix; + }); + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response.isJson()) { + return new FirebaseRemoteConfigError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + + const error: Error = (response.data as ErrorResponse).error || {}; + let code: RemoteConfigErrorCode = 'unknown-error'; + if (error.status && error.status in ERROR_CODE_MAPPING) { + code = ERROR_CODE_MAPPING[error.status]; + } + const message = error.message || `Unknown server error: ${response.text}`; + return new FirebaseRemoteConfigError(code, message); } } + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +const ERROR_CODE_MAPPING: { [key: string]: RemoteConfigErrorCode } = { + INVALID_ARGUMENT: 'invalid-argument', + NOT_FOUND: 'not-found', + RESOURCE_EXHAUSTED: 'resource-exhausted', + UNAUTHENTICATED: 'authentication-error', + UNKNOWN: 'unknown-error', +}; diff --git a/src/remote-config/remote-config-utils.ts b/src/remote-config/remote-config-utils.ts index 3debfd5a37..bf8b808e25 100644 --- a/src/remote-config/remote-config-utils.ts +++ b/src/remote-config/remote-config-utils.ts @@ -23,8 +23,10 @@ export type RemoteConfigErrorCode = | 'invalid-argument' | 'invalid-etag' | 'invalid-template' + | 'not-found' | 'obsolete-etag' | 'permission-denied' + | 'resource-exhausted' | 'unauthenticated' | 'unknown-error'; diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index a73014a9d9..88234d1e3d 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -16,17 +16,13 @@ import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../firebase-service'; import { FirebaseApp } from '../firebase-app'; - -/** Interface representing a Remote Config parameter. */ -export interface RemoteConfigParameter { - key: string; - defaultValue?: string; // If `undefined`, the parameter uses the in-app default value - description?: string; - - // A dictionary of {conditionName: value} - // `undefined` value sets `useInAppDefault` to `true` (equivalent to `No Value`) - conditionalValues?: { [name: string]: string | undefined }; -} +import * as validator from '../utils/validator'; +import { FirebaseRemoteConfigError } from './remote-config-utils'; +import { + RemoteConfigApiClient, + RemoteConfigResponse, + RemoteConfigParameter +} from './remote-config-api-client'; /** Interface representing a Remote Config condition. */ export interface RemoteConfigCondition { @@ -56,11 +52,27 @@ class RemoteConfigInternals implements FirebaseServiceInternalsInterface { export class RemoteConfig implements FirebaseServiceInterface { public readonly INTERNAL: RemoteConfigInternals = new RemoteConfigInternals(); + private readonly client: RemoteConfigApiClient; + /** * @param {FirebaseApp} app The app for this RemoteConfig service. * @constructor */ - constructor(readonly app: FirebaseApp) { } + constructor(readonly app: FirebaseApp) { + this.client = new RemoteConfigApiClient(app); + } + + /** + * Gets the current active version of the Remote Config template of the project. + * + * @return {Promise} A Promise that fulfills when the template is available. + */ + public getTemplate(): Promise { + return this.client.getTemplate() + .then((templateResponse) => { + return new RemoteConfigTemplate(templateResponse); + }); + } } /** @@ -68,28 +80,54 @@ export class RemoteConfig implements FirebaseServiceInterface { */ export class RemoteConfigTemplate { - public parameters: RemoteConfigParameter[]; + public parameters: { [key: string]: RemoteConfigParameter }; public conditions: RemoteConfigCondition[]; - private readonly eTagInternal: string; + private readonly etagInternal: string; - /** - * Gets the ETag of the template. - * - * @return {string} The ETag of the Remote Config template. - */ - get eTag(): string { - return this.eTagInternal; + constructor(config: RemoteConfigResponse) { + if (!validator.isNonNullObject(config) || + !validator.isNonEmptyString(config.etag)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Invalid Remote Config template response: ${JSON.stringify(config)}`); + } + + this.etagInternal = config.etag; + + if (typeof config.parameters !== 'undefined') { + if (!validator.isNonNullObject(config.parameters)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Remote Config parameters must be a non-null object`); + } + this.parameters = config.parameters; + } else { + this.parameters = {}; + } + + if (typeof config.conditions !== 'undefined') { + if (!validator.isArray(config.conditions)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Remote Config conditions must be an array`); + } + this.conditions = config.conditions.map(p => ({ + name: p.name, + expression: p.expression, + color: p.tagColor + })); + } else { + this.conditions = []; + } } /** - * Find an existing Remote Config parameter by key. - * - * @param {string} key The key of the Remote Config parameter. + * Gets the ETag of the template. * - * @return {RemoteConfigParameter} The Remote Config parameter with the provided key. + * @return {string} The ETag of the Remote Config template. */ - public getParameter(key: string): RemoteConfigParameter | undefined { - return this.parameters.find((p) => p.key === key); + get etag(): string { + return this.etagInternal; } /** @@ -108,7 +146,7 @@ export class RemoteConfigTemplate { return { parameters: this.parameters, conditions: this.conditions, - eTag: this.eTag, + etag: this.etag, }; } } diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 6bd00c36d4..d58e06efef 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -64,3 +64,7 @@ import './project-management/ios-app.spec'; // SecurityRules import './security-rules/security-rules.spec'; import './security-rules/security-rules-api-client.spec'; + +// RemoteConfig +import './remote-config/remote-config.spec'; +import './remote-config/remote-config-api-client.spec'; diff --git a/test/unit/remote-config/remote-config-api-client.spec.ts b/test/unit/remote-config/remote-config-api-client.spec.ts new file mode 100644 index 0000000000..fed41f3bf7 --- /dev/null +++ b/test/unit/remote-config/remote-config-api-client.spec.ts @@ -0,0 +1,162 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client'; +import { FirebaseRemoteConfigError } from '../../../src/remote-config/remote-config-utils'; +import { HttpClient } from '../../../src/utils/api-request'; +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { FirebaseApp } from '../../../src/firebase-app'; + +const expect = chai.expect; + +describe('RemoteConfigApiClient', () => { + + const ERROR_RESPONSE = { + error: { + code: 404, + message: 'Requested entity not found', + status: 'NOT_FOUND', + }, + }; + const EXPECTED_HEADERS = { + 'Authorization': 'Bearer mock-token', + 'X-Firebase-Client': 'fire-admin-node/', + 'Accept-Encoding': 'gzip', + }; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials, or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + const clientWithoutProjectId = new RemoteConfigApiClient( + mocks.mockCredentialApp()); + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let app: FirebaseApp; + let apiClient: RemoteConfigApiClient; + + beforeEach(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new RemoteConfigApiClient(app); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + return app.delete(); + }); + + describe('Constructor', () => { + it('should reject when the app is null', () => { + expect(() => new RemoteConfigApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to admin.remoteConfig() must be a valid Firebase app instance.'); + }); + }); + + describe('getTemplate', () => { + const testResponse = { + conditions: [{ name: 'ios', expression: 'exp' }], + parameters: { param: { defaultValue: { value: 'true' } } }, + version: {}, + }; + + it(`should reject when project id is not available`, () => { + return clientWithoutProjectId.getTemplate() + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should resolve with the requested template on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(testResponse, 200, { etag: 'etag-123456789012-1' })); + stubs.push(stub); + return apiClient.getTemplate() + .then((resp) => { + expect(resp.conditions).to.deep.equal(testResponse.conditions); + expect(resp.parameters).to.deep.equal(testResponse.parameters); + expect(resp.etag).to.equal('etag-123456789012-1'); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: 'https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig', + headers: EXPECTED_HEADERS, + }); + }); + }); + + it('should reject when the etag is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(testResponse)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.'); + return apiClient.getTemplate() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject when a full platform error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError('not-found', 'Requested entity not found'); + return apiClient.getTemplate() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError('unknown-error', 'Unknown server error: {}'); + return apiClient.getTemplate() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.getTemplate() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject when rejected with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.getTemplate() + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); +}); diff --git a/test/unit/remote-config/remote-config.spec.ts b/test/unit/remote-config/remote-config.spec.ts new file mode 100644 index 0000000000..e9ad0a1bc0 --- /dev/null +++ b/test/unit/remote-config/remote-config.spec.ts @@ -0,0 +1,221 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { RemoteConfig, RemoteConfigCondition } from '../../../src/remote-config/remote-config'; +import { FirebaseApp } from '../../../src/firebase-app'; +import * as mocks from '../../resources/mocks'; +import { RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client'; +import { FirebaseRemoteConfigError } from '../../../src/remote-config/remote-config-utils'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +const expect = chai.expect; + +describe('RemoteConfig', () => { + + const INTERNAL_ERROR = new FirebaseRemoteConfigError('internal-error', 'message'); + const REMOTE_CONFIG_RESPONSE: { + // This type is effectively a RemoteConfigResponse, but with non-readonly fields + // to allow easier use from within the tests. An improvement would be to + // alter this into a helper that creates customized RemoteConfigResponse based + // on the needs of the test, as that would ensure type-safety. + conditions?: Array<{ name: string; expression: string; tagColor: string }>; + parameters?: object | null; + version?: object; + etag: string; + } = { + conditions: [{ + name: 'ios', + expression: 'device.os == \'ios\'', + tagColor: 'BLUE', + }], + parameters: { + // eslint-disable-next-line @typescript-eslint/camelcase + holiday_promo_enabled: { + defaultValue: { value: 'true' }, + conditionalValues: { ios: { useInAppDefault: true } }, + description: 'this is a promo', + }, + }, + etag: 'etag-123456789012-5', + }; + + let remoteConfig: RemoteConfig; + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + remoteConfig = new RemoteConfig(mockApp); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + describe('Constructor', () => { + const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidApps.forEach((invalidApp) => { + it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { + expect(() => { + const remoteConfigAny: any = RemoteConfig; + return new remoteConfigAny(invalidApp); + }).to.throw( + 'First argument passed to admin.remoteConfig() must be a valid Firebase app ' + + 'instance.'); + }); + }); + + it('should throw given no app', () => { + expect(() => { + const remoteConfigAny: any = RemoteConfig; + return new remoteConfigAny(); + }).to.throw( + 'First argument passed to admin.remoteConfig() must be a valid Firebase app ' + + 'instance.'); + }); + + it('should reject when initialized without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials, or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + const remoteConfigWithoutProjectId = new RemoteConfig(mockCredentialApp); + return remoteConfigWithoutProjectId.getTemplate() + .should.eventually.rejectedWith(noProjectId); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new RemoteConfig(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(remoteConfig.app).to.equal(mockApp); + }); + }); + + describe('getTemplate', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return remoteConfig.getTemplate() + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .resolves(null); + stubs.push(stub); + return remoteConfig.getTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Remote Config template response: null'); + }); + + it('should reject when API response does not contain an ETag', () => { + const response = deepCopy(REMOTE_CONFIG_RESPONSE); + response.etag = ''; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.getTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Remote Config template response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain valid parameters', () => { + const response = deepCopy(REMOTE_CONFIG_RESPONSE); + response.parameters = null; + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.getTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', `Remote Config parameters must be a non-null object`); + }); + + it('should reject when API response does not contain valid conditions', () => { + const response = deepCopy(REMOTE_CONFIG_RESPONSE); + response.conditions = Object(); + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.getTemplate() + .should.eventually.be.rejected.and.have.property( + 'message', `Remote Config conditions must be an array`); + }); + + it('should resolve with Remote Config template on success', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'getTemplate') + .resolves(REMOTE_CONFIG_RESPONSE); + stubs.push(stub); + + return remoteConfig.getTemplate() + .then((template) => { + expect(template.conditions.length).to.equal(1); + expect(template.conditions[0].name).to.equal('ios'); + expect(template.conditions[0].expression).to.equal('device.os == \'ios\''); + // verify the property mapping in RemoteConfigCondition from `tagColor` to `color` + expect(template.conditions[0].color).to.equal('BLUE'); + + expect(template.etag).to.equal('etag-123456789012-5'); + // verify that etag is read-only + expect(() => { + (template as any).etag = "new-etag"; + }).to.throw('Cannot set property etag of # which has only a getter'); + + const key = 'holiday_promo_enabled'; + const p1 = template.parameters[key]; + expect(p1.defaultValue).deep.equals({ value: 'true' }); + expect(p1.conditionalValues).deep.equals({ ios: { useInAppDefault: true } }); + expect(p1.description).equals('this is a promo'); + + const c = template.getCondition('ios'); + expect(c).to.be.not.undefined; + const cond = c as RemoteConfigCondition; + expect(cond.name).to.equal('ios'); + expect(cond.expression).to.equal('device.os == \'ios\''); + expect(cond.color).to.equal('BLUE'); + }); + }); + }); +});