diff --git a/src/index.d.ts b/src/index.d.ts index 7eb23f6d8e..b0f157ee7e 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -809,7 +809,9 @@ declare namespace admin.projectManagement { listAndroidApps(): Promise; listIosApps(): Promise; + listAppMetadata(): Promise; androidApp(appId: string): admin.projectManagement.AndroidApp; + setDisplayName(newDisplayName: string): Promise; iosApp(appId: string): admin.projectManagement.IosApp; shaCertificate(shaHash: string): admin.projectManagement.ShaCertificate; createAndroidApp( diff --git a/src/project-management/app-metadata.ts b/src/project-management/app-metadata.ts index 4e7ff71bce..7f55551d53 100644 --- a/src/project-management/app-metadata.ts +++ b/src/project-management/app-metadata.ts @@ -21,19 +21,19 @@ export enum AppPlatform { } export interface AppMetadata { - readonly appId: string; - readonly displayName?: string; - readonly platform: AppPlatform; - readonly projectId: string; - readonly resourceName: string; + appId: string; + displayName?: string; + platform: AppPlatform; + projectId: string; + resourceName: string; } export interface AndroidAppMetadata extends AppMetadata { - readonly platform: AppPlatform.ANDROID; - readonly packageName: string; + platform: AppPlatform.ANDROID; + packageName: string; } export interface IosAppMetadata extends AppMetadata { - readonly platform: AppPlatform.IOS; - readonly bundleId: string; + platform: AppPlatform.IOS; + bundleId: string; } diff --git a/src/project-management/project-management-api-request.ts b/src/project-management/project-management-api-request.ts index 253dedd957..abbdc9d656 100644 --- a/src/project-management/project-management-api-request.ts +++ b/src/project-management/project-management-api-request.ts @@ -139,6 +139,18 @@ export class ProjectManagementRequestHandler { 'v1beta1'); } + /** + * @param {string} parentResourceName Fully-qualified resource name of the project whose iOS apps + * you want to list. + */ + public listAppMetadata(parentResourceName: string): Promise { + return this.invokeRequestHandler( + 'GET', + `${parentResourceName}:searchApps?page_size=${LIST_APPS_MAX_PAGE_SIZE}`, + /* requestData */ null, + 'v1beta1'); + } + /** * @param {string} parentResourceName Fully-qualified resource name of the project that you want * to create the Android app within. diff --git a/src/project-management/project-management.ts b/src/project-management/project-management.ts index 808d404d01..37840d7dee 100644 --- a/src/project-management/project-management.ts +++ b/src/project-management/project-management.ts @@ -22,7 +22,7 @@ import * as validator from '../utils/validator'; import { AndroidApp, ShaCertificate } from './android-app'; import { IosApp } from './ios-app'; import { ProjectManagementRequestHandler, assertServerResponse } from './project-management-api-request'; -import { AppMetadata } from './app-metadata'; +import { AppMetadata, AppPlatform } from './app-metadata'; /** * Internals of a Project Management instance. @@ -152,16 +152,44 @@ export class ProjectManagement implements FirebaseServiceInterface { * Lists summary of all apps in the project */ public listAppMetadata(): Promise { - throw new FirebaseProjectManagementError( - 'service-unavailable', 'This service is not available'); + return this.requestHandler.listAppMetadata(this.resourceName) + .then((responseData) => this.transformResponseToAppMetadata(responseData)); } /** * Update display name of the project */ - public setDisplayName(displayName: string): Promise { - throw new FirebaseProjectManagementError( - 'service-unavailable', 'This service is not available'); + public setDisplayName(newDisplayName: string): Promise { + return this.requestHandler.setDisplayName(this.resourceName, newDisplayName); + } + + private transformResponseToAppMetadata(responseData: any): AppMetadata[] { + this.assertListAppsResponseData(responseData, 'listAppMetadata()'); + + if (!responseData.apps) { + return []; + } + + return responseData.apps.map((appJson: any) => { + assertServerResponse( + validator.isNonEmptyString(appJson.appId), + responseData, + `"apps[].appId" field must be present in the listAppMetadata() response data.`); + assertServerResponse( + validator.isNonEmptyString(appJson.platform), + responseData, + `"apps[].platform" field must be present in the listAppMetadata() response data.`); + const metadata: AppMetadata = { + appId: appJson.appId, + platform: (AppPlatform as any)[appJson.platform] || AppPlatform.PLATFORM_UNKNOWN, + projectId: this.projectId, + resourceName: appJson.name, + }; + if (appJson.displayName) { + metadata.displayName = appJson.displayName; + } + return metadata; + }); } /** @@ -174,20 +202,12 @@ export class ProjectManagement implements FirebaseServiceInterface { return listPromise .then((responseData: any) => { - assertServerResponse( - validator.isNonNullObject(responseData), - responseData, - `${callerName}\'s responseData must be a non-null object.`); + this.assertListAppsResponseData(responseData, callerName); if (!responseData.apps) { return []; } - assertServerResponse( - validator.isArray(responseData.apps), - responseData, - `"apps" field must be present in the ${callerName} response data.`); - return responseData.apps.map((appJson: any) => { assertServerResponse( validator.isNonEmptyString(appJson.appId), @@ -201,4 +221,18 @@ export class ProjectManagement implements FirebaseServiceInterface { }); }); } + + private assertListAppsResponseData(responseData: any, callerName: string): void { + assertServerResponse( + validator.isNonNullObject(responseData), + responseData, + `${callerName}\'s responseData must be a non-null object.`); + + if (responseData.apps) { + assertServerResponse( + validator.isArray(responseData.apps), + responseData, + `"apps" field must be present in the ${callerName} response data.`); + } + } } diff --git a/test/integration/project-management.spec.ts b/test/integration/project-management.spec.ts index e470e51153..0f13120c76 100644 --- a/test/integration/project-management.spec.ts +++ b/test/integration/project-management.spec.ts @@ -16,6 +16,7 @@ import * as _ from 'lodash'; import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; import * as admin from '../../lib/index'; import { projectId } from './setup'; @@ -23,12 +24,17 @@ const APP_NAMESPACE_PREFIX = 'com.adminsdkintegrationtest.a'; const APP_NAMESPACE_SUFFIX_LENGTH = 15; const APP_DISPLAY_NAME_PREFIX = 'Created By Firebase AdminSDK Nodejs Integration Testing '; +const PROJECT_DISPLAY_NAME_PREFIX = 'Nodejs AdminSDK Testing '; const APP_DISPLAY_NAME_SUFFIX_LENGTH = 15; +const PROJECT_DISPLAY_NAME_SUFFIX_LENGTH = 6; const SHA_256_HASH = 'aaaaccccaaaaccccaaaaccccaaaaccccaaaaccccaaaaccccaaaaccccaaaacccc'; const expect = chai.expect; +chai.should(); +chai.use(chaiAsPromised); + describe('admin.projectManagement', () => { let androidApp: admin.projectManagement.AndroidApp; @@ -75,6 +81,28 @@ describe('admin.projectManagement', () => { }); }); + describe('setDisplayName()', () => { + it('successfully set project\'s display name', () => { + const newDisplayName = generateUniqueProjectDisplayName(); + // TODO(caot): verify that project name has been renamed successfully + return admin.projectManagement().setDisplayName(newDisplayName) + .should.eventually.be.fulfilled; + }); + }); + + describe('listAppMetadata()', () => { + it('successfully lists metadata of all apps', () => { + return admin.projectManagement().listAppMetadata() + .then((metadatas) => { + expect(metadatas.length).to.be.at.least(2); + const testAppMetadatas = metadatas.filter((metadata) => + isIntegrationTestAppDisplayName(metadata.displayName) && + (metadata.appId === androidApp.appId || metadata.appId === iosApp.appId)); + expect(testAppMetadatas).to.have.length(2); + }); + }); + }); + describe('androidApp.getMetadata()', () => { it('successfully sets Android app\'s display name', () => { return androidApp.getMetadata().then((appMetadata) => { @@ -234,6 +262,13 @@ function generateUniqueAppDisplayName() { return APP_DISPLAY_NAME_PREFIX + generateRandomString(APP_DISPLAY_NAME_SUFFIX_LENGTH); } +/** + * @return {string} string that can be used as a unique project display name. + */ +function generateUniqueProjectDisplayName() { + return PROJECT_DISPLAY_NAME_PREFIX + generateRandomString(PROJECT_DISPLAY_NAME_SUFFIX_LENGTH); +} + /** * @return {boolean} True if the specified appNamespace belongs to these integration tests. */ @@ -241,6 +276,13 @@ function isIntegrationTestApp(appNamespace: string): boolean { return (appNamespace.indexOf(APP_NAMESPACE_PREFIX) > -1); } +/** + * @return {boolean} True if the specified appDisplayName belongs to these integration tests. + */ +function isIntegrationTestAppDisplayName(appDisplayName: string): boolean { + return appDisplayName && (appDisplayName.indexOf(APP_DISPLAY_NAME_PREFIX) > -1); +} + /** * @return {string} A randomly generated alphanumeric string, of the specified length. */ diff --git a/test/unit/project-management/project-management-api-request.spec.ts b/test/unit/project-management/project-management-api-request.spec.ts index 594c0b53dd..9b9e6ec0c4 100644 --- a/test/unit/project-management/project-management-api-request.spec.ts +++ b/test/unit/project-management/project-management-api-request.spec.ts @@ -27,6 +27,7 @@ import { HttpClient } from '../../../src/utils/api-request'; import * as mocks from '../../resources/mocks'; import * as utils from '../utils'; import { ShaCertificate } from '../../../src/project-management/android-app'; +import { AppPlatform } from '../../../src/project-management/app-metadata'; chai.should(); chai.use(sinonChai); @@ -41,11 +42,15 @@ describe('ProjectManagementRequestHandler', () => { const PORT = 443; const PROJECT_RESOURCE_NAME: string = 'projects/test-project-id'; const APP_ID: string = 'test-app-id'; + const APP_ID_ANDROID: string = 'test-android-app-id'; + const APP_ID_IOS: string = 'test-ios-app-id'; const ANDROID_APP_RESOURCE_NAME: string = `projects/-/androidApp/${APP_ID}`; const IOS_APP_RESOURCE_NAME: string = `projects/-/iosApp/${APP_ID}`; const PACKAGE_NAME: string = 'test-package-name'; const BUNDLE_ID: string = 'test-bundle-id'; const DISPLAY_NAME: string = 'test-display-name'; + const DISPLAY_NAME_ANDROID: string = 'test-display-name-android'; + const DISPLAY_NAME_IOS: string = 'test-display-name-ios'; const OPERATION_RESOURCE_NAME: string = 'test-operation-resource-name'; const mockAccessToken: string = utils.generateRandomAccessToken(); @@ -183,6 +188,44 @@ describe('ProjectManagementRequestHandler', () => { }); }); + describe('listAppMetadata', () => { + testHttpErrors(() => requestHandler.listAppMetadata(PROJECT_RESOURCE_NAME)); + + it('should succeed', () => { + const expectedResult = { + apps: [ + { + appId: APP_ID_ANDROID, + displayName: DISPLAY_NAME_ANDROID, + platform: AppPlatform.ANDROID, + }, + { + appId: APP_ID_IOS, + displayName: DISPLAY_NAME_IOS, + platform: AppPlatform.IOS, + }], + }; + + const stub = sinon.stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(expectedResult)); + stubs.push(stub); + + const url = + `https://${HOST}:${PORT}/v1beta1/${PROJECT_RESOURCE_NAME}:searchApps?page_size=100`; + return requestHandler.listAppMetadata(PROJECT_RESOURCE_NAME) + .then((result) => { + expect(result).to.deep.equal(expectedResult); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url, + data: null, + headers: expectedHeaders, + timeout: 10000, + }); + }); + }); + }); + describe('createAndroidApp', () => { testHttpErrors(() => requestHandler.createAndroidApp(PROJECT_RESOURCE_NAME, PACKAGE_NAME)); diff --git a/test/unit/project-management/project-management.spec.ts b/test/unit/project-management/project-management.spec.ts index 3898d06cde..9a7de91a2d 100644 --- a/test/unit/project-management/project-management.spec.ts +++ b/test/unit/project-management/project-management.spec.ts @@ -26,13 +26,21 @@ import { ProjectManagementRequestHandler } from '../../../src/project-management import { FirebaseProjectManagementError } from '../../../src/utils/error'; import * as mocks from '../../resources/mocks'; import { IosApp } from '../../../src/project-management/ios-app'; +import { AppPlatform, AppMetadata } from '../../../src/project-management/app-metadata'; const expect = chai.expect; const APP_ID = 'test-app-id'; +const APP_ID_ANDROID: string = 'test-app-id-android'; +const APP_ID_IOS: string = 'test-app-id-ios'; const PACKAGE_NAME = 'test-package-name'; const BUNDLE_ID = 'test-bundle-id'; +const DISPLAY_NAME_ANDROID: string = 'test-display-name-android'; +const DISPLAY_NAME_IOS: string = 'test-display-name-ios'; const EXPECTED_ERROR = new FirebaseProjectManagementError('internal-error', 'message'); +const RESOURCE_NAME = 'projects/test/resources-name'; +const RESOURCE_NAME_ANDROID = 'projects/test/resources-name:android'; +const RESOURCE_NAME_IOS = 'projects/test/resources-name:ios'; const VALID_SHA_256_HASH = '0123456789abcdefABCDEF01234567890123456701234567890123456789abcd'; @@ -380,19 +388,185 @@ describe('ProjectManagement', () => { return projectManagement.createIosApp(BUNDLE_ID) .should.eventually.deep.equal(createdIosApp); }); + }); - describe('listAppMetadata', () => { - it('should throw service-unavailable error', () => { - expect(() => projectManagement.listAppMetadata()) - .to.throw('This service is not available'); - }); + describe('listAppMetadata', () => { + const VALID_LIST_APP_METADATA_API_RESPONSE = { + apps: [ + { + appId: APP_ID_ANDROID, + displayName: DISPLAY_NAME_ANDROID, + platform: 'ANDROID', + name: RESOURCE_NAME_ANDROID, + }, + { + appId: APP_ID_IOS, + displayName: DISPLAY_NAME_IOS, + platform: 'IOS', + name: RESOURCE_NAME_IOS, + }, + { + appId: APP_ID, + platform: 'WEB', + name: RESOURCE_NAME, + }, + ], + }; + + it('should propagate API errors', () => { + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.reject(EXPECTED_ERROR)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.be.rejected.and.equal(EXPECTED_ERROR); }); - describe('setDisplayName', () => { - it('should throw service-unavailable error', () => { - expect(() => projectManagement.setDisplayName('new project name')) - .to.throw('This service is not available'); - }); + it('should throw with null API response', () => { + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(null)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.be.rejected + .and.have.property( + 'message', + 'listAppMetadata()\'s responseData must be a non-null object. Response data: null'); + }); + + it('should return empty array when API response missing "apps" field', () => { + const partialApiResponse = {}; + + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(partialApiResponse)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.deep.equal([]); + }); + + it('should throw when API response has non-array "apps" field', () => { + const partialApiResponse = { apps: 'none' }; + + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(partialApiResponse)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.be.rejected + .and.have.property( + 'message', + '"apps" field must be present in the listAppMetadata() response data. Response data: ' + + JSON.stringify(partialApiResponse, null, 2)); + }); + + it('should throw with API response missing "apps[].appId" field', () => { + const partialApiResponse = { + apps: [{}], + }; + + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(partialApiResponse)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.be.rejected + .and.have.property( + 'message', + '"apps[].appId" field must be present in the listAppMetadata() response data. ' + + `Response data: ${JSON.stringify(partialApiResponse, null, 2)}`); + }); + + it('should throw with API response missing "apps[].platform" field', () => { + const missingPlatformApiResponse = { + apps: [{ + appId: APP_ID, + }], + }; + + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(missingPlatformApiResponse)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.be.rejected + .and.have.property( + 'message', + '"apps[].platform" field must be present in the listAppMetadata() response data. ' + + `Response data: ${JSON.stringify(missingPlatformApiResponse, null, 2)}`); + }); + + it('should resolve with list of apps metadata on success', () => { + const expectedAppMetadata: AppMetadata[] = [ + { + appId: VALID_LIST_APP_METADATA_API_RESPONSE.apps[0].appId, + displayName: VALID_LIST_APP_METADATA_API_RESPONSE.apps[0].displayName, + platform: AppPlatform.ANDROID, + projectId: mocks.projectId, + resourceName: RESOURCE_NAME_ANDROID, + }, + { + appId: VALID_LIST_APP_METADATA_API_RESPONSE.apps[1].appId, + displayName: VALID_LIST_APP_METADATA_API_RESPONSE.apps[1].displayName, + platform: AppPlatform.IOS, + projectId: mocks.projectId, + resourceName: RESOURCE_NAME_IOS, + }, + { + appId: VALID_LIST_APP_METADATA_API_RESPONSE.apps[2].appId, + platform: AppPlatform.PLATFORM_UNKNOWN, + projectId: mocks.projectId, + resourceName: RESOURCE_NAME, + }, + ]; + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(VALID_LIST_APP_METADATA_API_RESPONSE)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.deep.equal(expectedAppMetadata); + }); + + it('should resolve with "apps[].platform" to be "PLATFORM_UNKNOWN" for web app', () => { + const webPlatformApiResponse = { + apps: [{ + appId: APP_ID, + platform: 'WEB', + name: RESOURCE_NAME, + }], + }; + const expectedAppMetadata: AppMetadata[] = [{ + appId: APP_ID, + platform: AppPlatform.PLATFORM_UNKNOWN, + projectId: mocks.projectId, + resourceName: RESOURCE_NAME, + }]; + + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'listAppMetadata') + .returns(Promise.resolve(webPlatformApiResponse)); + stubs.push(stub); + return projectManagement.listAppMetadata() + .should.eventually.deep.equal(expectedAppMetadata); + }); + }); + + describe('setDisplayName', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'setDisplayName') + .returns(Promise.reject(EXPECTED_ERROR)); + stubs.push(stub); + return projectManagement.setDisplayName(DISPLAY_NAME_ANDROID) + .should.eventually.be.rejected.and.equal(EXPECTED_ERROR); + }); + + it('should resolve on success', () => { + const stub = sinon + .stub(ProjectManagementRequestHandler.prototype, 'setDisplayName') + .returns(Promise.resolve()); + stubs.push(stub); + return projectManagement.setDisplayName(DISPLAY_NAME_ANDROID).should.eventually.be.fulfilled; }); }); });