From 0f9a7defd8873e630bb27374f4f3d29c90af59aa Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Wed, 23 Jun 2021 12:04:23 -0700 Subject: [PATCH] feat(fis): Adding the admin.installations() API for deleting Firebase installation IDs (#1187) * feat(fis): Added admin.installations() API * fix: Marked IID APIs deprecated * fix: Deprecated App.instanceId() method; Added more unit and integration tests * fix: Throwing FirebaseInstallationsError from constructor * fix: Some docs updates * fix: Minor update to API doc comment * fix: Added Installations class to API ref toc * fix: Minor doc updates --- docgen/content-sources/node/toc.yaml | 6 + etc/firebase-admin.api.md | 16 ++ src/firebase-app.ts | 15 ++ src/firebase-namespace-api.ts | 3 + src/firebase-namespace.d.ts | 1 + src/firebase-namespace.ts | 14 ++ src/installations/index.ts | 85 ++++++++ .../installations-request-handler.ts} | 42 ++-- src/installations/installations.ts | 68 +++++++ src/instance-id/index.ts | 22 +- src/instance-id/instance-id.ts | 26 ++- src/utils/error.ts | 34 +++- test/integration/installations.spec.ts | 31 +++ test/integration/instance-id.spec.ts | 2 +- test/unit/firebase-app.spec.ts | 28 +++ test/unit/firebase-namespace.spec.ts | 43 ++++ test/unit/index.spec.ts | 5 +- .../installations-request-handler.spec.ts} | 38 ++-- test/unit/installations/installations.spec.ts | 190 ++++++++++++++++++ test/unit/instance-id/instance-id.spec.ts | 23 ++- 20 files changed, 617 insertions(+), 75 deletions(-) create mode 100644 src/installations/index.ts rename src/{instance-id/instance-id-request-internal.ts => installations/installations-request-handler.ts} (71%) create mode 100644 src/installations/installations.ts create mode 100644 test/integration/installations.spec.ts rename test/unit/{instance-id/instance-id-request.spec.ts => installations/installations-request-handler.spec.ts} (72%) create mode 100644 test/unit/installations/installations.spec.ts diff --git a/docgen/content-sources/node/toc.yaml b/docgen/content-sources/node/toc.yaml index bc19ced9d6..fa5808c5f7 100644 --- a/docgen/content-sources/node/toc.yaml +++ b/docgen/content-sources/node/toc.yaml @@ -144,6 +144,12 @@ toc: - title: "admin.firestore" path: /docs/reference/admin/node/admin.firestore +- title: "admin.installations" + path: /docs/reference/admin/node/admin.installations + section: + - title: "Installations" + path: /docs/reference/admin/node/admin.installations.Installations-1 + - title: "admin.instanceId" path: /docs/reference/admin/node/admin.instanceId section: diff --git a/etc/firebase-admin.api.md b/etc/firebase-admin.api.md index b16b7127cb..6017b0c72a 100644 --- a/etc/firebase-admin.api.md +++ b/etc/firebase-admin.api.md @@ -25,6 +25,8 @@ export namespace app { // (undocumented) firestore(): firestore.Firestore; // (undocumented) + installations(): installations.Installations; + // @deprecated (undocumented) instanceId(): instanceId.InstanceId; // (undocumented) machineLearning(): machineLearning.MachineLearning; @@ -542,13 +544,27 @@ export interface GoogleOAuthAccessToken { export function initializeApp(options?: AppOptions, name?: string): app.App; // @public +export function installations(app?: app.App): installations.Installations; + +// @public (undocumented) +export namespace installations { + export interface Installations { + // (undocumented) + app: app.App; + deleteInstallation(fid: string): Promise; + } +} + +// @public @deprecated export function instanceId(app?: app.App): instanceId.InstanceId; // @public (undocumented) export namespace instanceId { + // @deprecated export interface InstanceId { // (undocumented) app: app.App; + // @deprecated deleteInstanceId(instanceId: string): Promise; } } diff --git a/src/firebase-app.ts b/src/firebase-app.ts index 84b30a52c5..29afbb75d2 100644 --- a/src/firebase-app.ts +++ b/src/firebase-app.ts @@ -31,6 +31,7 @@ import { database } from './database/index'; import { DatabaseService } from './database/database-internal'; import { Firestore } from '@google-cloud/firestore'; import { FirestoreService } from './firestore/firestore-internal'; +import { Installations } from './installations/installations'; import { InstanceId } from './instance-id/instance-id'; import { ProjectManagement } from './project-management/project-management'; import { SecurityRules } from './security-rules/security-rules'; @@ -256,9 +257,23 @@ export class FirebaseApp implements app.App { return service.client; } + /** + * Returns the `Installations` service instance associated with this app. + * + * @return The `Installations` service instance of this app. + */ + public installations(): Installations { + return this.ensureService_('installations', () => { + const fisService: typeof Installations = require('./installations/installations').Installations; + return new fisService(this); + }); + } + /** * Returns the InstanceId service instance associated with this app. * + * This API is deprecated. Use the `installations()` API instead. + * * @return The InstanceId service instance of this app. */ public instanceId(): InstanceId { diff --git a/src/firebase-namespace-api.ts b/src/firebase-namespace-api.ts index 6507fa3a88..509a2d1d18 100644 --- a/src/firebase-namespace-api.ts +++ b/src/firebase-namespace-api.ts @@ -20,6 +20,7 @@ import { auth } from './auth/index'; import { credential } from './credential/index'; import { database } from './database/index'; import { firestore } from './firestore/index'; +import { installations } from './installations/index'; import { instanceId } from './instance-id/index'; import { machineLearning } from './machine-learning/index'; import { messaging } from './messaging/index'; @@ -227,6 +228,8 @@ export namespace app { auth(): auth.Auth; database(url?: string): database.Database; firestore(): firestore.Firestore; + installations(): installations.Installations; + /** @deprecated */ instanceId(): instanceId.InstanceId; machineLearning(): machineLearning.MachineLearning; messaging(): messaging.Messaging; diff --git a/src/firebase-namespace.d.ts b/src/firebase-namespace.d.ts index ab013c3cac..bff64ccdf3 100644 --- a/src/firebase-namespace.d.ts +++ b/src/firebase-namespace.d.ts @@ -20,6 +20,7 @@ export * from './app-check/index'; export * from './auth/index'; export * from './database/index'; export * from './firestore/index'; +export * from './installations/index'; export * from './instance-id/index'; export * from './machine-learning/index'; export * from './messaging/index'; diff --git a/src/firebase-namespace.ts b/src/firebase-namespace.ts index fa1a409b48..311807301a 100644 --- a/src/firebase-namespace.ts +++ b/src/firebase-namespace.ts @@ -27,6 +27,7 @@ import { appCheck } from './app-check/index'; import { auth } from './auth/index'; import { database } from './database/index'; import { firestore } from './firestore/index'; +import { installations } from './installations/index'; import { instanceId } from './instance-id/index'; import { machineLearning } from './machine-learning/index'; import { messaging } from './messaging/index'; @@ -43,6 +44,7 @@ import AppCheck = appCheck.AppCheck; import Auth = auth.Auth; import Database = database.Database; import Firestore = firestore.Firestore; +import Installations = installations.Installations; import InstanceId = instanceId.InstanceId; import MachineLearning = machineLearning.MachineLearning; import Messaging = messaging.Messaging; @@ -311,6 +313,18 @@ export class FirebaseNamespace { return Object.assign(fn, { MachineLearning: machineLearning }); } + /** + * Gets the `Installations` service namespace. The returned namespace can be used to get the + * `Installations` service for the default app or an explicitly specified app. + */ + get installations(): FirebaseServiceNamespace { + const fn: FirebaseServiceNamespace = (app?: App) => { + return this.ensureApp(app).installations(); + }; + const installations = require('./installations/installations').Installations; + return Object.assign(fn, { Installations: installations }); + } + /** * Gets the `InstanceId` service namespace. The returned namespace can be used to get the * `Instance` service for the default app or an explicitly specified app. diff --git a/src/installations/index.ts b/src/installations/index.ts new file mode 100644 index 0000000000..373b904c9c --- /dev/null +++ b/src/installations/index.ts @@ -0,0 +1,85 @@ +/*! + * Copyright 2021 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 { app } from '../firebase-namespace-api'; + +/** + * Gets the {@link installations.Installations `Installations`} service for the + * default app or a given app. + * + * `admin.installations()` can be called with no arguments to access the default + * app's {@link installations.Installations `Installations`} service or as + * `admin.installations(app)` to access the + * {@link installations.Installations `Installations`} service associated with a + * specific app. + * + * @example + * ```javascript + * // Get the Installations service for the default app + * var defaultInstallations = admin.installations(); + * ``` + * + * @example + * ```javascript + * // Get the Installations service for a given app + * var otherInstallations = admin.installations(otherApp); + *``` + * + * @param app Optional app whose `Installations` service to + * return. If not provided, the default `Installations` service is + * returned. + * + * @return The default `Installations` service if + * no app is provided or the `Installations` service associated with the + * provided app. + */ +export declare function installations(app?: app.App): installations.Installations; + +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace installations { + /** + * Gets the {@link Installations `Installations`} service for the + * current app. + * + * @example + * ```javascript + * var installations = app.installations(); + * // The above is shorthand for: + * // var installations = admin.installations(app); + * ``` + * + * @return The `Installations` service for the + * current app. + */ + export interface Installations { + app: app.App; + + /** + * Deletes the specified installation ID and the associated data from Firebase. + * + * Note that Google Analytics for Firebase uses its own form of Instance ID to + * keep track of analytics data. Therefore deleting a Firebase installation ID does + * not delete Analytics data. See + * [Delete a Firebase installation](/docs/projects/manage-installations#delete-installation) + * for more information. + * + * @param fid The Firebase installation ID to be deleted. + * + * @return A promise fulfilled when the installation ID is deleted. + */ + deleteInstallation(fid: string): Promise; + } +} diff --git a/src/instance-id/instance-id-request-internal.ts b/src/installations/installations-request-handler.ts similarity index 71% rename from src/instance-id/instance-id-request-internal.ts rename to src/installations/installations-request-handler.ts index e0d404d602..299fd3136e 100644 --- a/src/instance-id/instance-id-request-internal.ts +++ b/src/installations/installations-request-handler.ts @@ -1,6 +1,6 @@ /*! * @license - * Copyright 2017 Google Inc. + * Copyright 2021 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ */ import { FirebaseApp } from '../firebase-app'; -import { FirebaseInstanceIdError, InstanceIdClientErrorCode } from '../utils/error'; +import { FirebaseInstallationsError, InstallationsClientErrorCode } from '../utils/error'; import { ApiSettings, AuthorizedHttpClient, HttpRequestConfig, HttpError, } from '../utils/api-request'; @@ -33,10 +33,10 @@ const FIREBASE_IID_TIMEOUT = 10000; /** HTTP error codes raised by the backend server. */ const ERROR_CODES: {[key: number]: string} = { - 400: 'Malformed instance ID argument.', + 400: 'Malformed installation ID argument.', 401: 'Request not authorized.', - 403: 'Project does not match instance ID or the client does not have sufficient privileges.', - 404: 'Failed to find the instance ID.', + 403: 'Project does not match installation ID or the client does not have sufficient privileges.', + 404: 'Failed to find the installation ID.', 409: 'Already deleted.', 429: 'Request throttled out by the backend server.', 500: 'Internal server error.', @@ -44,9 +44,9 @@ const ERROR_CODES: {[key: number]: string} = { }; /** - * Class that provides mechanism to send requests to the Firebase Instance ID backend endpoints. + * Class that provides mechanism to send requests to the FIS backend endpoints. */ -export class FirebaseInstanceIdRequestHandler { +export class FirebaseInstallationsRequestHandler { private readonly host: string = FIREBASE_IID_HOST; private readonly timeout: number = FIREBASE_IID_TIMEOUT; @@ -54,7 +54,7 @@ export class FirebaseInstanceIdRequestHandler { private path: string; /** - * @param {FirebaseApp} app The app used to fetch access tokens to sign API requests. + * @param app The app used to fetch access tokens to sign API requests. * * @constructor */ @@ -62,21 +62,21 @@ export class FirebaseInstanceIdRequestHandler { this.httpClient = new AuthorizedHttpClient(app); } - public deleteInstanceId(instanceId: string): Promise { - if (!validator.isNonEmptyString(instanceId)) { - return Promise.reject(new FirebaseInstanceIdError( - InstanceIdClientErrorCode.INVALID_INSTANCE_ID, - 'Instance ID must be a non-empty string.', + public deleteInstallation(fid: string): Promise { + if (!validator.isNonEmptyString(fid)) { + return Promise.reject(new FirebaseInstallationsError( + InstallationsClientErrorCode.INVALID_INSTALLATION_ID, + 'Installation ID must be a non-empty string.', )); } - return this.invokeRequestHandler(new ApiSettings(instanceId, 'DELETE')); + return this.invokeRequestHandler(new ApiSettings(fid, 'DELETE')); } /** * Invokes the request handler based on the API settings object passed. * - * @param {ApiSettings} apiSettings The API endpoint settings to apply to request and response. - * @return {Promise} A promise that resolves when the request is complete. + * @param apiSettings The API endpoint settings to apply to request and response. + * @return A promise that resolves when the request is complete. */ private invokeRequestHandler(apiSettings: ApiSettings): Promise { return this.getPathPrefix() @@ -98,8 +98,8 @@ export class FirebaseInstanceIdRequestHandler { response.data.error : response.text; const template: string = ERROR_CODES[response.status]; const message: string = template ? - `Instance ID "${apiSettings.getEndpoint()}": ${template}` : errorMessage; - throw new FirebaseInstanceIdError(InstanceIdClientErrorCode.API_ERROR, message); + `Installation ID "${apiSettings.getEndpoint()}": ${template}` : errorMessage; + throw new FirebaseInstallationsError(InstallationsClientErrorCode.API_ERROR, message); } // In case of timeouts and other network errors, the HttpClient returns a // FirebaseError wrapped in the response. Simply throw it here. @@ -116,9 +116,9 @@ export class FirebaseInstanceIdRequestHandler { .then((projectId) => { if (!validator.isNonEmptyString(projectId)) { // Assert for an explicit projct ID (either via AppOptions or the cert itself). - throw new FirebaseInstanceIdError( - InstanceIdClientErrorCode.INVALID_PROJECT_ID, - 'Failed to determine project ID for InstanceId. Initialize the ' + throw new FirebaseInstallationsError( + InstallationsClientErrorCode.INVALID_PROJECT_ID, + 'Failed to determine project ID for Installations. Initialize the ' + 'SDK with service account credentials or set project ID as an app option. ' + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', ); diff --git a/src/installations/installations.ts b/src/installations/installations.ts new file mode 100644 index 0000000000..a55e5af295 --- /dev/null +++ b/src/installations/installations.ts @@ -0,0 +1,68 @@ +/*! + * Copyright 2021 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 { FirebaseApp } from '../firebase-app'; +import { FirebaseInstallationsError, InstallationsClientErrorCode } from '../utils/error'; +import { FirebaseInstallationsRequestHandler } from './installations-request-handler'; +import { installations } from './index'; +import * as validator from '../utils/validator'; + +import InstallationsInterface = installations.Installations; + +/** + * The `Installations` service for the current app. + */ +export class Installations implements InstallationsInterface { + + private app_: FirebaseApp; + private requestHandler: FirebaseInstallationsRequestHandler; + + /** + * @param app The app for this Installations service. + * @constructor + */ + constructor(app: FirebaseApp) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseInstallationsError( + InstallationsClientErrorCode.INVALID_ARGUMENT, + 'First argument passed to admin.installations() must be a valid Firebase app instance.', + ); + } + + this.app_ = app; + this.requestHandler = new FirebaseInstallationsRequestHandler(app); + } + + /** + * Deletes the specified installation ID and the associated data from Firebase. + * + * @param fid The Firebase installation ID to be deleted. + * + * @return A promise fulfilled when the installation ID is deleted. + */ + public deleteInstallation(fid: string): Promise { + return this.requestHandler.deleteInstallation(fid); + } + + /** + * Returns the app associated with this Installations instance. + * + * @return The app associated with this Installations instance. + */ + get app(): FirebaseApp { + return this.app_; + } +} diff --git a/src/instance-id/index.ts b/src/instance-id/index.ts index 09cbfe4022..a5ea77eb8b 100644 --- a/src/instance-id/index.ts +++ b/src/instance-id/index.ts @@ -38,6 +38,9 @@ import { app } from '../firebase-namespace-api'; * var otherInstanceId = admin.instanceId(otherApp); *``` * + * This API is deprecated. Developers are advised to use the `admin.installations()` + * API to delete their instance IDs and Firebase installation IDs. + * * @param app Optional app whose `InstanceId` service to * return. If not provided, the default `InstanceId` service will be * returned. @@ -45,24 +48,18 @@ import { app } from '../firebase-namespace-api'; * @return The default `InstanceId` service if * no app is provided or the `InstanceId` service associated with the * provided app. + * + * @deprecated */ export declare function instanceId(app?: app.App): instanceId.InstanceId; /* eslint-disable @typescript-eslint/no-namespace */ export namespace instanceId { /** - * Gets the {@link InstanceId `InstanceId`} service for the + * The {@link InstanceId `InstanceId`} service for the * current app. * - * @example - * ```javascript - * var instanceId = app.instanceId(); - * // The above is shorthand for: - * // var instanceId = admin.instanceId(app); - * ``` - * - * @return The `InstanceId` service for the - * current app. + * @deprecated */ export interface InstanceId { app: app.App; @@ -76,9 +73,14 @@ export namespace instanceId { * [Delete an Instance ID](/support/privacy/manage-iids#delete_an_instance_id) * for more information. * + * This API is deprecated. Developers are advised to use the `Installations.deleteInstallation()` + * API instead. + * * @param instanceId The instance ID to be deleted. * * @return A promise fulfilled when the instance ID is deleted. + * + * @deprecated */ deleteInstanceId(instanceId: string): Promise; } diff --git a/src/instance-id/instance-id.ts b/src/instance-id/instance-id.ts index 80afaeae48..80937ffc0c 100644 --- a/src/instance-id/instance-id.ts +++ b/src/instance-id/instance-id.ts @@ -15,8 +15,9 @@ */ import { FirebaseApp } from '../firebase-app'; -import { FirebaseInstanceIdError, InstanceIdClientErrorCode } from '../utils/error'; -import { FirebaseInstanceIdRequestHandler } from './instance-id-request-internal'; +import { + FirebaseInstallationsError, FirebaseInstanceIdError, InstallationsClientErrorCode, InstanceIdClientErrorCode, +} from '../utils/error'; import { instanceId } from './index'; import * as validator from '../utils/validator'; @@ -39,10 +40,9 @@ import InstanceIdInterface = instanceId.InstanceId; export class InstanceId implements InstanceIdInterface { private app_: FirebaseApp; - private requestHandler: FirebaseInstanceIdRequestHandler; /** - * @param {FirebaseApp} app The app for this InstanceId service. + * @param app The app for this InstanceId service. * @constructor */ constructor(app: FirebaseApp) { @@ -54,7 +54,6 @@ export class InstanceId implements InstanceIdInterface { } this.app_ = app; - this.requestHandler = new FirebaseInstanceIdRequestHandler(app); } /** @@ -71,16 +70,25 @@ export class InstanceId implements InstanceIdInterface { * @return A promise fulfilled when the instance ID is deleted. */ public deleteInstanceId(instanceId: string): Promise { - return this.requestHandler.deleteInstanceId(instanceId) - .then(() => { - // Return nothing on success + return this.app.installations().deleteInstallation(instanceId) + .catch((err) => { + if (err instanceof FirebaseInstallationsError) { + let code = err.code.replace('installations/', ''); + if (code === InstallationsClientErrorCode.INVALID_INSTALLATION_ID.code) { + code = InstanceIdClientErrorCode.INVALID_INSTANCE_ID.code; + } + + throw new FirebaseInstanceIdError({ code, message: err.message }); + } + + throw err; }); } /** * Returns the app associated with this InstanceId instance. * - * @return {FirebaseApp} The app associated with this InstanceId instance. + * @return The app associated with this InstanceId instance. */ get app(): FirebaseApp { return this.app_; diff --git a/src/utils/error.ts b/src/utils/error.ts index caa781e8f3..fcec6d5fd3 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -224,6 +224,23 @@ export class FirebaseInstanceIdError extends FirebaseError { constructor(info: ErrorInfo, message?: string) { // Override default message if custom message provided. super({ code: 'instance-id/' + info.code, message: message || info.message }); + (this as any).__proto__ = FirebaseInstanceIdError.prototype; + } +} + +/** + * Firebase Installations service error code structure. This extends `FirebaseError`. + * + * @param info The error code info. + * @param message The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseInstallationsError extends FirebaseError { + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super({ code: 'installations/' + info.code, message: message || info.message }); + (this as any).__proto__ = FirebaseInstallationsError.prototype; } } @@ -808,7 +825,7 @@ export class MessagingClientErrorCode { }; } -export class InstanceIdClientErrorCode { +export class InstallationsClientErrorCode { public static INVALID_ARGUMENT = { code: 'invalid-argument', message: 'Invalid argument provided.', @@ -817,13 +834,20 @@ export class InstanceIdClientErrorCode { code: 'invalid-project-id', message: 'Invalid project ID provided.', }; - public static INVALID_INSTANCE_ID = { - code: 'invalid-instance-id', - message: 'Invalid instance ID provided.', + public static INVALID_INSTALLATION_ID = { + code: 'invalid-installation-id', + message: 'Invalid installation ID provided.', }; public static API_ERROR = { code: 'api-error', - message: 'Instance ID API call failed.', + message: 'Installation ID API call failed.', + }; +} + +export class InstanceIdClientErrorCode extends InstallationsClientErrorCode { + public static INVALID_INSTANCE_ID = { + code: 'invalid-instance-id', + message: 'Invalid instance ID provided.', }; } diff --git a/test/integration/installations.spec.ts b/test/integration/installations.spec.ts new file mode 100644 index 0000000000..982bd3a218 --- /dev/null +++ b/test/integration/installations.spec.ts @@ -0,0 +1,31 @@ +/*! + * Copyright 2021 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'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('admin.installations', () => { + it('deleteInstallation() fails when called with fictive-ID0 instance ID', () => { + // instance ids have to conform to /[cdef][A-Za-z0-9_-]{9}[AEIMQUYcgkosw048]/ + return admin.installations().deleteInstallation('fictive-ID0') + .should.eventually.be + .rejectedWith('Installation ID "fictive-ID0": Failed to find the installation ID.'); + }); +}); diff --git a/test/integration/instance-id.spec.ts b/test/integration/instance-id.spec.ts index a7d6eb6df2..d98c2802f6 100644 --- a/test/integration/instance-id.spec.ts +++ b/test/integration/instance-id.spec.ts @@ -26,6 +26,6 @@ describe('admin.instanceId', () => { // instance ids have to conform to /[cdef][A-Za-z0-9_-]{9}[AEIMQUYcgkosw048]/ return admin.instanceId().deleteInstanceId('fictive-ID0') .should.eventually.be - .rejectedWith('Instance ID "fictive-ID0": Failed to find the instance ID.'); + .rejectedWith('Installation ID "fictive-ID0": Failed to find the installation ID.'); }); }); diff --git a/test/unit/firebase-app.spec.ts b/test/unit/firebase-app.spec.ts index 0989e718d0..1929637f94 100644 --- a/test/unit/firebase-app.spec.ts +++ b/test/unit/firebase-app.spec.ts @@ -37,6 +37,7 @@ import { machineLearning } from '../../src/machine-learning/index'; import { storage } from '../../src/storage/index'; import { firestore } from '../../src/firestore/index'; import { database } from '../../src/database/index'; +import { installations } from '../../src/installations/index'; import { instanceId } from '../../src/instance-id/index'; import { projectManagement } from '../../src/project-management/index'; import { securityRules } from '../../src/security-rules/index'; @@ -50,6 +51,7 @@ import Messaging = messaging.Messaging; import MachineLearning = machineLearning.MachineLearning; import Storage = storage.Storage; import Firestore = firestore.Firestore; +import Installations = installations.Installations; import InstanceId = instanceId.InstanceId; import ProjectManagement = projectManagement.ProjectManagement; import SecurityRules = securityRules.SecurityRules; @@ -567,6 +569,32 @@ describe('FirebaseApp', () => { }); }); + describe('installations()', () => { + it('should throw if the app has already been deleted', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + return app.delete().then(() => { + expect(() => { + return app.installations(); + }).to.throw(`Firebase app named "${mocks.appName}" has already been deleted.`); + }); + }); + + it('should return the InstanceId client', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + + const fis: Installations = app.installations(); + expect(fis).not.be.null; + }); + + it('should return a cached version of InstanceId on subsequent calls', () => { + const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + const service1: Installations = app.installations(); + const service2: Installations = app.installations(); + expect(service1).to.equal(service2); + }); + }); + describe('instanceId()', () => { it('should throw if the app has already been deleted', () => { const app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); diff --git a/test/unit/firebase-namespace.spec.ts b/test/unit/firebase-namespace.spec.ts index e2efb84f4e..45047df4a7 100644 --- a/test/unit/firebase-namespace.spec.ts +++ b/test/unit/firebase-namespace.spec.ts @@ -52,6 +52,7 @@ import { machineLearning } from '../../src/machine-learning/index'; import { storage } from '../../src/storage/index'; import { firestore } from '../../src/firestore/index'; import { database } from '../../src/database/index'; +import { installations } from '../../src/installations/index'; import { instanceId } from '../../src/instance-id/index'; import { projectManagement } from '../../src/project-management/index'; import { securityRules } from '../../src/security-rules/index'; @@ -60,6 +61,7 @@ import { appCheck } from '../../src/app-check/index'; import { AppCheck as AppCheckImpl } from '../../src/app-check/app-check'; import { Auth as AuthImpl } from '../../src/auth/auth'; +import { Installations as InstallationsImpl } from '../../src/installations/installations'; import { InstanceId as InstanceIdImpl } from '../../src/instance-id/instance-id'; import { MachineLearning as MachineLearningImpl } from '../../src/machine-learning/machine-learning'; import { Messaging as MessagingImpl } from '../../src/messaging/messaging'; @@ -73,6 +75,7 @@ import AppCheck = appCheck.AppCheck; import Auth = auth.Auth; import Database = database.Database; import Firestore = firestore.Firestore; +import Installations = installations.Installations; import InstanceId = instanceId.InstanceId; import MachineLearning = machineLearning.MachineLearning; import Messaging = messaging.Messaging; @@ -603,6 +606,46 @@ describe('FirebaseNamespace', () => { }); }); + describe('#installations()', () => { + it('should throw when called before initializing an app', () => { + expect(() => { + firebaseNamespace.installations(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should throw when default app is not initialized', () => { + firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + expect(() => { + firebaseNamespace.installations(); + }).to.throw(DEFAULT_APP_NOT_FOUND); + }); + + it('should return a valid namespace when the default app is initialized', () => { + const app: App = firebaseNamespace.initializeApp(mocks.appOptions); + const fis: Installations = firebaseNamespace.installations(); + expect(fis).to.not.be.null; + expect(fis.app).to.be.deep.equal(app); + }); + + it('should return a valid namespace when the named app is initialized', () => { + const app: App = firebaseNamespace.initializeApp(mocks.appOptions, 'testApp'); + const fis: Installations = firebaseNamespace.installations(app); + expect(fis).to.not.be.null; + expect(fis.app).to.be.deep.equal(app); + }); + + it('should return a reference to Installations type', () => { + expect(firebaseNamespace.installations.Installations).to.be.deep.equal(InstallationsImpl); + }); + + it('should return a cached version of Installations on subsequent calls', () => { + firebaseNamespace.initializeApp(mocks.appOptions); + const service1: Installations = firebaseNamespace.installations(); + const service2: Installations = firebaseNamespace.installations(); + expect(service1).to.equal(service2); + }); + }); + describe('#instanceId()', () => { it('should throw when called before initializing an app', () => { expect(() => { diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 48c87e24e4..1cccc1def5 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -60,9 +60,12 @@ import './storage/storage.spec'; // Firestore import './firestore/firestore.spec'; +// Installations +import './installations/installations.spec'; +import './installations/installations-request-handler.spec'; + // InstanceId import './instance-id/instance-id.spec'; -import './instance-id/instance-id-request.spec'; // ProjectManagement import './project-management/project-management.spec'; diff --git a/test/unit/instance-id/instance-id-request.spec.ts b/test/unit/installations/installations-request-handler.spec.ts similarity index 72% rename from test/unit/instance-id/instance-id-request.spec.ts rename to test/unit/installations/installations-request-handler.spec.ts index dd28f0f8ef..dcc32b464b 100644 --- a/test/unit/instance-id/instance-id-request.spec.ts +++ b/test/unit/installations/installations-request-handler.spec.ts @@ -28,7 +28,7 @@ import * as mocks from '../../resources/mocks'; import { FirebaseApp } from '../../../src/firebase-app'; import { HttpClient } from '../../../src/utils/api-request'; -import { FirebaseInstanceIdRequestHandler } from '../../../src/instance-id/instance-id-request-internal'; +import { FirebaseInstallationsRequestHandler } from '../../../src/installations/installations-request-handler'; chai.should(); chai.use(sinonChai); @@ -36,7 +36,7 @@ chai.use(chaiAsPromised); const expect = chai.expect; -describe('FirebaseInstanceIdRequestHandler', () => { +describe('FirebaseInstallationsRequestHandler', () => { const projectId = 'project_id'; const mockAccessToken: string = utils.generateRandomAccessToken(); let stubs: sinon.SinonStub[] = []; @@ -69,24 +69,24 @@ describe('FirebaseInstanceIdRequestHandler', () => { describe('Constructor', () => { it('should succeed with a FirebaseApp instance', () => { expect(() => { - return new FirebaseInstanceIdRequestHandler(mockApp); + return new FirebaseInstallationsRequestHandler(mockApp); }).not.to.throw(Error); }); }); - describe('deleteInstanceId', () => { + describe('deleteInstallation', () => { const httpMethod = 'DELETE'; const host = 'console.firebase.google.com'; - const path = `/v1/project/${projectId}/instanceId/test-iid`; + const path = `/v1/project/${projectId}/instanceId/test-fid`; const timeout = 10000; - it('should be fulfilled given a valid instance ID', () => { + it('should be fulfilled given a valid installation ID', () => { const stub = sinon.stub(HttpClient.prototype, 'send') .resolves(utils.responseFrom('')); stubs.push(stub); - const requestHandler = new FirebaseInstanceIdRequestHandler(mockApp); - return requestHandler.deleteInstanceId('test-iid') + const requestHandler = new FirebaseInstallationsRequestHandler(mockApp); + return requestHandler.deleteInstallation('test-fid') .then(() => { expect(stub).to.have.been.calledOnce.and.calledWith({ method: httpMethod, @@ -102,14 +102,14 @@ describe('FirebaseInstanceIdRequestHandler', () => { .rejects(utils.errorFrom({}, 404)); stubs.push(stub); - const requestHandler = new FirebaseInstanceIdRequestHandler(mockApp); - return requestHandler.deleteInstanceId('test-iid') + const requestHandler = new FirebaseInstallationsRequestHandler(mockApp); + return requestHandler.deleteInstallation('test-fid') .then(() => { throw new Error('Unexpected success'); }) .catch((error) => { - expect(error.code).to.equal('instance-id/api-error'); - expect(error.message).to.equal('Instance ID "test-iid": Failed to find the instance ID.'); + expect(error.code).to.equal('installations/api-error'); + expect(error.message).to.equal('Installation ID "test-fid": Failed to find the installation ID.'); }); }); @@ -118,14 +118,14 @@ describe('FirebaseInstanceIdRequestHandler', () => { .rejects(utils.errorFrom({}, 409)); stubs.push(stub); - const requestHandler = new FirebaseInstanceIdRequestHandler(mockApp); - return requestHandler.deleteInstanceId('test-iid') + const requestHandler = new FirebaseInstallationsRequestHandler(mockApp); + return requestHandler.deleteInstallation('test-fid') .then(() => { throw new Error('Unexpected success'); }) .catch((error) => { - expect(error.code).to.equal('instance-id/api-error'); - expect(error.message).to.equal('Instance ID "test-iid": Already deleted.'); + expect(error.code).to.equal('installations/api-error'); + expect(error.message).to.equal('Installation ID "test-fid": Already deleted.'); }); }); @@ -135,13 +135,13 @@ describe('FirebaseInstanceIdRequestHandler', () => { .rejects(utils.errorFrom(expectedResult, 511)); stubs.push(stub); - const requestHandler = new FirebaseInstanceIdRequestHandler(mockApp); - return requestHandler.deleteInstanceId('test-iid') + const requestHandler = new FirebaseInstallationsRequestHandler(mockApp); + return requestHandler.deleteInstallation('test-fid') .then(() => { throw new Error('Unexpected success'); }) .catch((error) => { - expect(error.code).to.equal('instance-id/api-error'); + expect(error.code).to.equal('installations/api-error'); expect(error.message).to.equal('test error'); }); }); diff --git a/test/unit/installations/installations.spec.ts b/test/unit/installations/installations.spec.ts new file mode 100644 index 0000000000..e7816fa556 --- /dev/null +++ b/test/unit/installations/installations.spec.ts @@ -0,0 +1,190 @@ +/*! + * @license + * Copyright 2017 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 * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; + +import { Installations } from '../../../src/installations/installations'; +import { FirebaseInstallationsRequestHandler } from '../../../src/installations/installations-request-handler'; +import { FirebaseApp } from '../../../src/firebase-app'; +import { FirebaseInstallationsError, InstallationsClientErrorCode } from '../../../src/utils/error'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('Installations', () => { + let fis: Installations; + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + let getTokenStub: sinon.SinonStub; + + let nullAccessTokenClient: Installations; + let malformedAccessTokenClient: Installations; + let rejectedPromiseAccessTokenClient: Installations; + + let googleCloudProject: string | undefined; + let gcloudProject: string | undefined; + + const noProjectIdError = 'Failed to determine project ID for Installations. Initialize the SDK ' + + 'with service account credentials or set project ID as an app option. Alternatively set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + beforeEach(() => { + mockApp = mocks.app(); + getTokenStub = utils.stubGetAccessToken(undefined, mockApp); + mockCredentialApp = mocks.mockCredentialApp(); + fis = new Installations(mockApp); + + googleCloudProject = process.env.GOOGLE_CLOUD_PROJECT; + gcloudProject = process.env.GCLOUD_PROJECT; + + nullAccessTokenClient = new Installations(mocks.appReturningNullAccessToken()); + malformedAccessTokenClient = new Installations(mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenClient = new Installations(mocks.appRejectedWhileFetchingAccessToken()); + }); + + afterEach(() => { + getTokenStub.restore(); + process.env.GOOGLE_CLOUD_PROJECT = googleCloudProject; + process.env.GCLOUD_PROJECT = gcloudProject; + return mockApp.delete(); + }); + + + 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 iidAny: any = Installations; + return new iidAny(invalidApp); + }).to.throw('First argument passed to admin.installations() must be a valid Firebase app instance.'); + }); + }); + + it('should throw given no app', () => { + expect(() => { + const iidAny: any = Installations; + return new iidAny(); + }).to.throw('First argument passed to admin.installations() must be a valid Firebase app instance.'); + }); + + it('should reject given an invalid credential without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const installations = new Installations(mockCredentialApp); + return installations.deleteInstallation('iid') + .should.eventually.rejectedWith(noProjectIdError); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new Installations(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(fis.app).to.equal(mockApp); + }); + + it('is read-only', () => { + expect(() => { + (fis as any).app = mockApp; + }).to.throw('Cannot set property app of # which has only a getter'); + }); + }); + + describe('deleteInstallation()', () => { + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + const expectedError = new FirebaseInstallationsError(InstallationsClientErrorCode.API_ERROR); + const testInstallationId = 'test-iid'; + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no installation ID', () => { + return (fis as any).deleteInstallation() + .should.eventually.be.rejected.and.have.property('code', 'installations/invalid-installation-id'); + }); + + it('should be rejected given an invalid installation ID', () => { + return fis.deleteInstallation('') + .should.eventually.be.rejected.and.have.property('code', 'installations/invalid-installation-id'); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenClient.deleteInstallation(testInstallationId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenClient.deleteInstallation(testInstallationId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenClient.deleteInstallation(testInstallationId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve without errors on success', () => { + const stub = sinon.stub(FirebaseInstallationsRequestHandler.prototype, 'deleteInstallation') + .resolves(); + stubs.push(stub); + return fis.deleteInstallation(testInstallationId) + .then(() => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(testInstallationId); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub deleteInstallation to throw a backend error. + const stub = sinon.stub(FirebaseInstallationsRequestHandler.prototype, 'deleteInstallation') + .rejects(expectedError); + stubs.push(stub); + return fis.deleteInstallation(testInstallationId) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(testInstallationId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); +}); diff --git a/test/unit/instance-id/instance-id.spec.ts b/test/unit/instance-id/instance-id.spec.ts index b68f8b3ac1..a35d2a01e2 100644 --- a/test/unit/instance-id/instance-id.spec.ts +++ b/test/unit/instance-id/instance-id.spec.ts @@ -27,9 +27,12 @@ import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; import { InstanceId } from '../../../src/instance-id/instance-id'; -import { FirebaseInstanceIdRequestHandler } from '../../../src/instance-id/instance-id-request-internal'; +import { Installations } from '../../../src/installations/installations'; import { FirebaseApp } from '../../../src/firebase-app'; -import { FirebaseInstanceIdError, InstanceIdClientErrorCode } from '../../../src/utils/error'; +import { + FirebaseInstallationsError, FirebaseInstanceIdError, + InstallationsClientErrorCode, InstanceIdClientErrorCode, +} from '../../../src/utils/error'; chai.should(); chai.use(sinonChai); @@ -50,7 +53,7 @@ describe('InstanceId', () => { let googleCloudProject: string | undefined; let gcloudProject: string | undefined; - const noProjectIdError = 'Failed to determine project ID for InstanceId. Initialize the SDK ' + const noProjectIdError = 'Failed to determine project ID for Installations. Initialize the SDK ' + 'with service account credentials or set project ID as an app option. Alternatively set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.'; @@ -127,7 +130,6 @@ describe('InstanceId', () => { // Stubs used to simulate underlying api calls. let stubs: sinon.SinonStub[] = []; - const expectedError = new FirebaseInstanceIdError(InstanceIdClientErrorCode.API_ERROR); const testInstanceId = 'test-iid'; afterEach(() => { @@ -161,7 +163,7 @@ describe('InstanceId', () => { }); it('should resolve without errors on success', () => { - const stub = sinon.stub(FirebaseInstanceIdRequestHandler.prototype, 'deleteInstanceId') + const stub = sinon.stub(Installations.prototype, 'deleteInstallation') .resolves(); stubs.push(stub); return iid.deleteInstanceId(testInstanceId) @@ -171,10 +173,11 @@ describe('InstanceId', () => { }); }); - it('should throw an error when the backend returns an error', () => { + it('should throw a FirebaseInstanceIdError error when the backend returns an error', () => { // Stub deleteInstanceId to throw a backend error. - const stub = sinon.stub(FirebaseInstanceIdRequestHandler.prototype, 'deleteInstanceId') - .returns(Promise.reject(expectedError)); + const originalError = new FirebaseInstallationsError(InstallationsClientErrorCode.API_ERROR); + const stub = sinon.stub(Installations.prototype, 'deleteInstallation') + .rejects(originalError); stubs.push(stub); return iid.deleteInstanceId(testInstanceId) .then(() => { @@ -183,7 +186,9 @@ describe('InstanceId', () => { // Confirm underlying API called with expected parameters. expect(stub).to.have.been.calledOnce.and.calledWith(testInstanceId); // Confirm expected error returned. - expect(error).to.equal(expectedError); + const expectedError = new FirebaseInstanceIdError(InstanceIdClientErrorCode.API_ERROR); + expect(error).to.be.instanceOf(FirebaseInstanceIdError) + expect(error).to.deep.include(expectedError); }); }); });