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
36 changes: 31 additions & 5 deletions src/remote-config/remote-config-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class RemoteConfigApiClient {
}

public validateTemplate(template: RemoteConfigTemplate): Promise<RemoteConfigTemplate> {
this.validateRemoteConfigTemplate(template);
return this.sendPutRequest(template, template.etag, true)
.then((resp) => {
if (!validator.isNonEmptyString(resp.headers['etag'])) {
Expand All @@ -149,6 +150,7 @@ export class RemoteConfigApiClient {
}

public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise<RemoteConfigTemplate> {
this.validateRemoteConfigTemplate(template);
let ifMatch: string = template.etag;
if (options && options.force == true) {
// setting `If-Match: *` forces the Remote Config template to be updated
Expand All @@ -174,11 +176,6 @@ export class RemoteConfigApiClient {
}

private sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise<HttpResponse> {
if (!validator.isNonEmptyString(etag)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'ETag must be a non-empty string.');
}
let path = 'remoteConfig';
if (validateOnly) {
path += '?validate_only=true';
Expand Down Expand Up @@ -245,6 +242,35 @@ export class RemoteConfigApiClient {
const message = error.message || `Unknown server error: ${response.text}`;
return new FirebaseRemoteConfigError(code, message);
}

/**
* Checks if the given RemoteConfigTemplate object is valid.
* The object must have valid parameters, conditions, and an etag.
*
* @param {RemoteConfigTemplate} template A RemoteConfigTemplate object to be validated.
*/
private validateRemoteConfigTemplate(template: RemoteConfigTemplate): void {
if (!validator.isNonNullObject(template)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
`Invalid Remote Config template: ${JSON.stringify(template)}`);
}
if (!validator.isNonEmptyString(template.etag)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'ETag must be a non-empty string.');
}
if (!validator.isNonNullObject(template.parameters)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'Remote Config parameters must be a non-null object');
}
if (!validator.isArray(template.conditions)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'Remote Config conditions must be an array');
}
}
}

interface ErrorResponse {
Expand Down
175 changes: 175 additions & 0 deletions test/integration/remote-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*!
* 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.
*/

import * as admin from '../../lib/index';
import * as chai from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import { deepCopy } from '../../src/utils/deep-copy';

chai.should();
chai.use(chaiAsPromised);

const expect = chai.expect;

const VALID_PARAMETERS = {
// eslint-disable-next-line @typescript-eslint/camelcase
holiday_promo_enabled: {
defaultValue: { useInAppDefault: true },
description: 'promo indicator'
},
// eslint-disable-next-line @typescript-eslint/camelcase
welcome_message: {
defaultValue: { value: 'welcome text' + Date.now() },
conditionalValues: {
ios: { value: 'welcome ios text' },
andriod: { value: 'welcome andriod text' },
},
}
};

const VALID_CONDITIONS = [{
name: 'ios',
expression: 'device.os == \'ios\'',
tagColor: 'BLUE'
},
{
name: 'andriod',
expression: 'device.os == \'android\'',
tagColor: 'GREEN'
}];

let currentTemplate: admin.remoteConfig.RemoteConfigTemplate;

describe('admin.remoteConfig', () => {
before(async () => {
// obtain the most recent template (etag) to perform operations
currentTemplate = await admin.remoteConfig().getTemplate();
});

it('verify that the etag is read-only', () => {
expect(() => {
(currentTemplate as any).etag = "new-etag";
}).to.throw('Cannot set property etag of #<RemoteConfigTemplateImpl> which has only a getter');
});

describe('validateTemplate', () => {
it('should succeed with a vaild template', () => {
// set parameters and conditions
currentTemplate.conditions = VALID_CONDITIONS;
currentTemplate.parameters = VALID_PARAMETERS;
return admin.remoteConfig().validateTemplate(currentTemplate)
.then((template) => {
expect(template.etag).matches(/^etag-[0-9]*-[0-9]*$/);
expect(template.conditions.length).to.equal(2);
expect(template.conditions).to.deep.equal(VALID_CONDITIONS);
expect(template.parameters).to.deep.equal(VALID_PARAMETERS);
});
});

it('should propagate API errors', () => {
// rejects with invalid-argument when conditions used in parameters do not exist
currentTemplate.conditions = [];
currentTemplate.parameters = VALID_PARAMETERS;
return admin.remoteConfig().validateTemplate(currentTemplate)
.should.eventually.be.rejected.and.have.property('code', 'remote-config/invalid-argument');
});
});

describe('publishTemplate', () => {
it('should succeed with a vaild template', () => {
// set parameters and conditions
currentTemplate.conditions = VALID_CONDITIONS;
currentTemplate.parameters = VALID_PARAMETERS;
return admin.remoteConfig().publishTemplate(currentTemplate)
.then((template) => {
expect(template.etag).matches(/^etag-[0-9]*-[0-9]*$/);
expect(template.conditions.length).to.equal(2);
expect(template.conditions).to.deep.equal(VALID_CONDITIONS);
expect(template.parameters).to.deep.equal(VALID_PARAMETERS);
});
});

it('should propagate API errors', () => {
// rejects with invalid-argument when conditions used in parameters do not exist
currentTemplate.conditions = [];
currentTemplate.parameters = VALID_PARAMETERS;
return admin.remoteConfig().publishTemplate(currentTemplate)
.should.eventually.be.rejected.and.have.property('code', 'remote-config/invalid-argument');
});
});

describe('getTemplate', () => {
it('verfy that getTemplate() returns the most recently published template', () => {
return admin.remoteConfig().getTemplate()
.then((template) => {
expect(template.etag).matches(/^etag-[0-9]*-[0-9]*$/);
expect(template.conditions.length).to.equal(2);
expect(template.conditions).to.deep.equal(VALID_CONDITIONS);
expect(template.parameters).to.deep.equal(VALID_PARAMETERS);
});
});
});

describe('createTemplateFromJSON', () => {
const INVALID_STRINGS: any[] = [null, undefined, '', 1, true, {}, []];
const INVALID_JSON_STRINGS: any[] = ['abc', 'foo', 'a:a', '1:1'];

INVALID_STRINGS.forEach((invalidJson) => {
it(`should throw if the json string is ${JSON.stringify(invalidJson)}`, () => {
expect(() => admin.remoteConfig().createTemplateFromJSON(invalidJson))
.to.throw('JSON string must be a valid non-empty string');
});
});

INVALID_JSON_STRINGS.forEach((invalidJson) => {
it(`should throw if the json string is ${JSON.stringify(invalidJson)}`, () => {
expect(() => admin.remoteConfig().createTemplateFromJSON(invalidJson))
.to.throw(/^Failed to parse the JSON string: ([\D\w]*)\. SyntaxError: Unexpected token ([\D\w]*) in JSON at position ([0-9]*)$/);
});
});

const invalidEtags = [...INVALID_STRINGS];
const sourceTemplate = {
parameters: VALID_PARAMETERS,
conditions: VALID_CONDITIONS,
etag: 'etag-1234-1',
};

const invalidEtagTemplate = deepCopy(sourceTemplate)
invalidEtags.forEach((invalidEtag) => {
invalidEtagTemplate.etag = invalidEtag;
const jsonString = JSON.stringify(invalidEtagTemplate);
it(`should throw if the ETag is ${JSON.stringify(invalidEtag)}`, () => {
expect(() => admin.remoteConfig().createTemplateFromJSON(jsonString))
.to.throw(`Invalid Remote Config template response: ${jsonString}`);
});
});

it('should succeed when a valid json string is provided', () => {
const jsonString = JSON.stringify(sourceTemplate);
const newTemplate = admin.remoteConfig().createTemplateFromJSON(jsonString);
expect(newTemplate.etag).to.equal(sourceTemplate.etag);
expect(() => {
(currentTemplate as any).etag = "new-etag";
}).to.throw(
'Cannot set property etag of #<RemoteConfigTemplateImpl> which has only a getter'
);
expect(newTemplate.conditions.length).to.equal(2);
expect(newTemplate.conditions).to.deep.equal(VALID_CONDITIONS);
expect(newTemplate.parameters).to.deep.equal(VALID_PARAMETERS);
});
});
});
48 changes: 46 additions & 2 deletions test/unit/remote-config/remote-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ describe('RemoteConfig', () => {
});
});

const INVALID_PARAMETERS: any[] = [null, '', 'abc', 1, true, []];
const INVALID_CONDITIONS: any[] = [null, '', 'abc', 1, true, {}];
const INVALID_ETAG_TEMPLATES: any[] = [{ parameters: {}, conditions: [], etag: '' }, Object()];
const INVALID_TEMPLATES: any[] = [null, 'abc', 123];

describe('validateTemplate', () => {
it('should propagate API errors', () => {
const stub = sinon
Expand Down Expand Up @@ -295,6 +300,9 @@ describe('RemoteConfig', () => {
'message', `Remote Config conditions must be an array`);
});

// validate input template
testInvalidInputTemplates((t: RemoteConfigTemplate) => { remoteConfig.validateTemplate(t); });

it('should resolve with Remote Config template on success', () => {
const stub = sinon
.stub(RemoteConfigApiClient.prototype, 'validateTemplate')
Expand Down Expand Up @@ -386,6 +394,9 @@ describe('RemoteConfig', () => {
'message', `Remote Config conditions must be an array`);
});

// validate input template
testInvalidInputTemplates((t: RemoteConfigTemplate) => { remoteConfig.publishTemplate(t); });

it('should resolve with Remote Config template on success', () => {
const stub = sinon
.stub(RemoteConfigApiClient.prototype, 'publishTemplate')
Expand Down Expand Up @@ -423,8 +434,6 @@ describe('RemoteConfig', () => {
describe('createTemplateFromJSON', () => {
const INVALID_STRINGS: any[] = [null, undefined, '', 1, true, {}, []];
const INVALID_JSON_STRINGS: any[] = ['abc', 'foo', 'a:a', '1:1'];
const INVALID_PARAMETERS: any[] = [null, '', 'abc', 1, true, []];
const INVALID_CONDITIONS: any[] = [null, '', 'abc', 1, true, {}];

INVALID_STRINGS.forEach((invalidJson) => {
it(`should throw if the json string is ${JSON.stringify(invalidJson)}`, () => {
Expand Down Expand Up @@ -499,4 +508,39 @@ describe('RemoteConfig', () => {
expect(cond.tagColor).to.equal('BLUE');
});
});

function testInvalidInputTemplates(rcOperation: Function): void {
const inputTemplate = deepCopy(REMOTE_CONFIG_TEMPLATE);
INVALID_PARAMETERS.forEach((invalidParameter) => {
it(`should throw if the parameters is ${JSON.stringify(invalidParameter)}`, () => {
(inputTemplate as any).parameters = invalidParameter;
inputTemplate.conditions = [];
expect(() => rcOperation(inputTemplate))
.to.throw('Remote Config parameters must be a non-null object');
});
});

INVALID_CONDITIONS.forEach((invalidConditions) => {
it(`should throw if the conditions is ${JSON.stringify(invalidConditions)}`, () => {
(inputTemplate as any).conditions = invalidConditions;
inputTemplate.parameters = {};
expect(() => rcOperation(inputTemplate))
.to.throw('Remote Config conditions must be an array');
});
});

INVALID_ETAG_TEMPLATES.forEach((invalidEtagTemplate) => {
it(`should throw if the template is ${JSON.stringify(invalidEtagTemplate)}`, () => {
expect(() => rcOperation(invalidEtagTemplate))
.to.throw('ETag must be a non-empty string.');
});
});

INVALID_TEMPLATES.forEach((invalidTemplate) => {
it(`should throw if the template is ${JSON.stringify(invalidTemplate)}`, () => {
expect(() => rcOperation(invalidTemplate))
.to.throw(`Invalid Remote Config template: ${JSON.stringify(invalidTemplate)}`);
});
});
}
});