diff --git a/src/remote-config/remote-config-api-client.ts b/src/remote-config/remote-config-api-client.ts index 89c52c36f9..7243c0e43a 100644 --- a/src/remote-config/remote-config-api-client.ts +++ b/src/remote-config/remote-config-api-client.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request'; +import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse } from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import { FirebaseRemoteConfigError, RemoteConfigErrorCode } from './remote-config-utils'; import { FirebaseApp } from '../firebase-app'; @@ -127,19 +127,7 @@ export class RemoteConfigApiClient { } public validateTemplate(template: RemoteConfigTemplate): Promise { - return this.getUrl() - .then((url) => { - const request: HttpRequestConfig = { - method: 'PUT', - url: `${url}/remoteConfig?validate_only=true`, - headers: { ...FIREBASE_REMOTE_CONFIG_HEADERS, 'If-Match': template.etag }, - data: { - conditions: template.conditions, - parameters: template.parameters, - } - }; - return this.httpClient.send(request); - }) + return this.sendPutRequest(template, template.etag, true) .then((resp) => { if (!validator.isNonEmptyString(resp.headers['etag'])) { throw new FirebaseRemoteConfigError( @@ -160,6 +148,56 @@ export class RemoteConfigApiClient { }); } + public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise { + let ifMatch: string = template.etag; + if (options && options.force == true) { + // setting `If-Match: *` forces the Remote Config template to be updated + // and circumvent the ETag, and the protection from that it provides. + ifMatch = '*'; + } + return this.sendPutRequest(template, ifMatch) + .then((resp) => { + if (!validator.isNonEmptyString(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 sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise { + 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'; + } + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'PUT', + url: `${url}/${path}`, + headers: { ...FIREBASE_REMOTE_CONFIG_HEADERS, 'If-Match': etag }, + data: { + conditions: template.conditions, + parameters: template.parameters, + } + }; + return this.httpClient.send(request); + }); + } + private getUrl(): Promise { return this.getProjectIdPrefix() .then((projectIdPrefix) => { diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index 0ac9b71068..853d6d757e 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -81,6 +81,21 @@ export class RemoteConfig implements FirebaseServiceInterface { return new RemoteConfigTemplateImpl(templateResponse); }); } + + /** + * Publishes a Remote Config template. + * + * @param {RemoteConfigTemplate} template The Remote Config template to be validated. + * @param {any=} options Optional options object when publishing a Remote Config template. + * + * @return {Promise} A Promise that fulfills when a template is published. + */ + public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise { + return this.client.publishTemplate(template, options) + .then((templateResponse) => { + return new RemoteConfigTemplateImpl(templateResponse); + }); + } } /** diff --git a/test/unit/remote-config/remote-config-api-client.spec.ts b/test/unit/remote-config/remote-config-api-client.spec.ts index d010260c10..b583752600 100644 --- a/test/unit/remote-config/remote-config-api-client.spec.ts +++ b/test/unit/remote-config/remote-config-api-client.spec.ts @@ -42,11 +42,24 @@ describe('RemoteConfigApiClient', () => { status: 'NOT_FOUND', }, }; + + const VALIDATION_ERROR_MESSAGES = [ + "[VALIDATION_ERROR]: [foo] are not valid condition names. All keys in all conditional value maps must be valid condition names.", + "[VERSION_MISMATCH]: Expected version 6, found 8 for project: 123456789012" + ]; + const EXPECTED_HEADERS = { 'Authorization': 'Bearer mock-token', 'X-Firebase-Client': 'fire-admin-node/', 'Accept-Encoding': 'gzip', }; + + const TEST_RESPONSE = { + conditions: [{ name: 'ios', expression: 'exp' }], + parameters: { param: { defaultValue: { value: 'true' } } }, + version: {}, + }; + 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.'; @@ -100,12 +113,6 @@ describe('RemoteConfigApiClient', () => { }); 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); @@ -114,12 +121,12 @@ describe('RemoteConfigApiClient', () => { 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' })); + .resolves(utils.responseFrom(TEST_RESPONSE, 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.conditions).to.deep.equal(TEST_RESPONSE.conditions); + expect(resp.parameters).to.deep.equal(TEST_RESPONSE.parameters); expect(resp.etag).to.equal('etag-123456789012-1'); expect(stub).to.have.been.calledOnce.and.calledWith({ method: 'GET', @@ -132,7 +139,7 @@ describe('RemoteConfigApiClient', () => { it('should reject when the etag is not present', () => { const stub = sinon .stub(HttpClient.prototype, 'send') - .resolves(utils.responseFrom(testResponse)); + .resolves(utils.responseFrom(TEST_RESPONSE)); stubs.push(stub); const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.'); return apiClient.getTemplate() @@ -182,12 +189,6 @@ describe('RemoteConfigApiClient', () => { }); describe('validateTemplate', () => { - const testResponse = { - conditions: [{ name: 'ios', expression: 'exp' }], - parameters: { param: { defaultValue: { value: 'true' } } }, - version: {}, - }; - it(`should reject when project id is not available`, () => { return clientWithoutProjectId.validateTemplate(REMOTE_CONFIG_TEMPLATE) .should.eventually.be.rejectedWith(noProjectId); @@ -196,12 +197,12 @@ describe('RemoteConfigApiClient', () => { it('should resolve with the requested template on success', () => { const stub = sinon .stub(HttpClient.prototype, 'send') - .resolves(utils.responseFrom(testResponse, 200, { etag: 'etag-123456789012-0' })); + .resolves(utils.responseFrom(TEST_RESPONSE, 200, { etag: 'etag-123456789012-0' })); stubs.push(stub); return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE) .then((resp) => { - expect(resp.conditions).to.deep.equal(testResponse.conditions); - expect(resp.parameters).to.deep.equal(testResponse.parameters); + expect(resp.conditions).to.deep.equal(TEST_RESPONSE.conditions); + expect(resp.parameters).to.deep.equal(TEST_RESPONSE.parameters); // validate template returns an etag with the suffix -0 when successful. // verify that the etag matches the original template etag. expect(resp.etag).to.equal('etag-123456789012-6'); @@ -220,13 +221,20 @@ describe('RemoteConfigApiClient', () => { it('should reject when the etag is not present', () => { const stub = sinon .stub(HttpClient.prototype, 'send') - .resolves(utils.responseFrom(testResponse)); + .resolves(utils.responseFrom(TEST_RESPONSE)); stubs.push(stub); const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.'); return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE) .should.eventually.be.rejected.and.deep.equal(expected); }); + [null, undefined, ''].forEach((etag) => { + it('should reject when the etag in template is null, undefined, or an empty string', () => { + expect(() => apiClient.validateTemplate({ conditions: [], parameters: {}, etag: etag as any })) + .to.throw('ETag must be a non-empty string.'); + }); + }); + it('should reject when a full platform error response is received', () => { const stub = sinon .stub(HttpClient.prototype, 'send') @@ -258,11 +266,7 @@ describe('RemoteConfigApiClient', () => { .should.eventually.be.rejected.and.deep.equal(expected); }); - const errorMessages = [ - "[VALIDATION_ERROR]: [foo] are not valid condition names. All keys in all conditional value maps must be valid condition names.", - "[VERSION_MISMATCH]: Expected version 6, found 8 for project: 123456789012" - ]; - errorMessages.forEach((message) => { + VALIDATION_ERROR_MESSAGES.forEach((message) => { it('should reject with failed-precondition when a validation error occurres', () => { const stub = sinon .stub(HttpClient.prototype, 'send') @@ -280,4 +284,105 @@ describe('RemoteConfigApiClient', () => { }); }); }); + + describe('publishTemplate', () => { + it(`should reject when project id is not available`, () => { + return clientWithoutProjectId.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejectedWith(noProjectId); + }); + + const testOptions = [ + { options: undefined, etag: 'etag-123456789012-6' }, + { options: { force: true }, etag: '*' } + ]; + testOptions.forEach((option) => { + it('should resolve with the requested template on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200, { etag: 'etag-123456789012-6' })); + stubs.push(stub); + return apiClient.publishTemplate(REMOTE_CONFIG_TEMPLATE, option.options) + .then((resp) => { + expect(resp.conditions).to.deep.equal(TEST_RESPONSE.conditions); + expect(resp.parameters).to.deep.equal(TEST_RESPONSE.parameters); + expect(resp.etag).to.equal('etag-123456789012-6'); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'PUT', + url: 'https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig', + headers: { ...EXPECTED_HEADERS, 'If-Match': option.etag }, + data: { + conditions: REMOTE_CONFIG_TEMPLATE.conditions, + parameters: REMOTE_CONFIG_TEMPLATE.parameters, + } + }); + }); + }); + }); + + it('should reject when the etag is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.'); + return apiClient.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + [null, undefined, ''].forEach((etag) => { + it('should reject when the etag in template is null, undefined, or an empty string', () => { + expect(() => apiClient.publishTemplate({ conditions: [], parameters: {}, etag: etag as any })) + .to.throw('ETag must be a non-empty string.'); + }); + }); + + 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.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with 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.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject with 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.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + VALIDATION_ERROR_MESSAGES.forEach((message) => { + it('should reject with failed-precondition when a validation error occurres', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({ + error: { + code: 400, + message: message, + status: "FAILED_PRECONDITION" + } + }, 400)); + stubs.push(stub); + const expected = new FirebaseRemoteConfigError('failed-precondition', message); + return apiClient.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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 index 81563bdd28..3464db9b3d 100644 --- a/test/unit/remote-config/remote-config.spec.ts +++ b/test/unit/remote-config/remote-config.spec.ts @@ -329,4 +329,94 @@ describe('RemoteConfig', () => { }); }); }); + + describe('publishTemplate', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'publishTemplate') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(RemoteConfigApiClient.prototype, 'publishTemplate') + .resolves(null); + stubs.push(stub); + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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, 'publishTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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, 'publishTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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, 'publishTemplate') + .resolves(response); + stubs.push(stub); + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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, 'publishTemplate') + .resolves(REMOTE_CONFIG_RESPONSE); + stubs.push(stub); + + return remoteConfig.publishTemplate(REMOTE_CONFIG_TEMPLATE) + .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\''); + expect(template.conditions[0].tagColor).to.equal('BLUE'); + expect(template.etag).to.equal('etag-123456789012-5'); + // verify that the 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.conditions.find((c) => c.name === '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.tagColor).to.equal('BLUE'); + }); + }); + }); });