From 3e679b2b039555884d30f04d08668d02152e2efe Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 7 Aug 2019 17:24:31 -0700 Subject: [PATCH 1/4] Defines the TenantManager class and its underlying methods. Adds unit tests for this new class. Unit tests were copied from the exising auth.spec.ts file. All deprecated methods will be removed from the Auth class in a follow up PR. --- src/auth/tenant-manager.ts | 149 +++++++ test/unit/auth/tenant-manager.spec.ts | 590 ++++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + 3 files changed, 740 insertions(+) create mode 100644 src/auth/tenant-manager.ts create mode 100644 test/unit/auth/tenant-manager.spec.ts diff --git a/src/auth/tenant-manager.ts b/src/auth/tenant-manager.ts new file mode 100644 index 0000000000..468394acae --- /dev/null +++ b/src/auth/tenant-manager.ts @@ -0,0 +1,149 @@ +/*! + * Copyright 2019 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 {AuthRequestHandler} from './auth-api-request'; +import {FirebaseApp} from '../firebase-app'; +import {TenantAwareAuth} from './auth'; +import { + Tenant, TenantServerResponse, ListTenantsResult, TenantOptions, +} from './tenant'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import * as validator from '../utils/validator'; + +/** + * Data structure used to help manage tenant related operations. + * This includes: + * - The ability to create, update, list, get and delete tenants for the underlying project. + * - Getting a TenantAwareAuth instance for running Auth related operations (user mgmt, provider config mgmt, etc) + * in the context of a specified tenant. + */ +export class TenantManager { + private readonly authRequestHandler: AuthRequestHandler; + private readonly tenantsMap: {[key: string]: TenantAwareAuth}; + + /** + * Initializes a TenantManager instance for a specified FirebaseApp. + * @param app The app for this TenantManager instance. + */ + constructor(private readonly app: FirebaseApp) { + this.authRequestHandler = new AuthRequestHandler(app); + this.tenantsMap = {}; + } + + /** + * Returns a TenantAwareAuth instance for the corresponding tenant ID. + * + * @param tenantId The tenant ID whose TenantAwareAuth is to be returned. + * @return The corresponding TenantAwareAuth instance. + */ + public authForTenant(tenantId: string): TenantAwareAuth { + if (!validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + } + if (typeof this.tenantsMap[tenantId] === 'undefined') { + this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); + } + return this.tenantsMap[tenantId]; + } + + /** + * Looks up the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled with the corresponding tenant if it is found. + * + * @param tenantId The tenant ID of the tenant to look up. + * @return A promise that resolves with the corresponding tenant. + */ + public getTenant(tenantId: string): Promise { + return this.authRequestHandler.getTenant(tenantId) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Exports a batch of tenant accounts. Batch size is determined by the maxResults argument. + * Starting point of the batch is determined by the pageToken argument. + * + * @param maxResults The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param pageToken The next page token. If not specified, returns users starting + * without any offset. + * @return A promise that resolves with + * the current batch of downloaded tenants and the next page token. For the last page, an + * empty list of tenants and no page token are returned. + */ + public listTenants( + maxResults?: number, + pageToken?: string): Promise { + return this.authRequestHandler.listTenants(maxResults, pageToken) + .then((response: {tenants: TenantServerResponse[], nextPageToken?: string}) => { + // List of tenants to return. + const tenants: Tenant[] = []; + // Convert each user response to a Tenant. + response.tenants.forEach((tenantResponse: TenantServerResponse) => { + tenants.push(new Tenant(tenantResponse)); + }); + // Return list of tenants and the next page token if available. + const result = { + tenants, + pageToken: response.nextPageToken, + }; + // Delete result.pageToken if undefined. + if (typeof result.pageToken === 'undefined') { + delete result.pageToken; + } + return result; + }); + } + + /** + * Deletes the tenant identified by the provided tenant ID and returns a promise that is + * fulfilled when the tenant is found and successfully deleted. + * + * @param tenantId The tenant ID of the tenant to delete. + * @return A promise that resolves when the tenant is successfully deleted. + */ + public deleteTenant(tenantId: string): Promise { + return this.authRequestHandler.deleteTenant(tenantId); + } + + /** + * Creates a new tenant with the properties provided. + * + * @param tenantOptions The properties to set on the new tenant to be created. + * @return A promise that resolves with the newly created tenant. + */ + public createTenant(tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.createTenant(tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } + + /** + * Updates an existing tenant identified by the tenant ID with the properties provided. + * + * @param tenantId The tenant identifier of the tenant to update. + * @param tenantOptions The properties to update on the existing tenant. + * @return A promise that resolves with the modified tenant. + */ + public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { + return this.authRequestHandler.updateTenant(tenantId, tenantOptions) + .then((response: TenantServerResponse) => { + return new Tenant(response); + }); + } +} \ No newline at end of file diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts new file mode 100644 index 0000000000..889e256a43 --- /dev/null +++ b/test/unit/auth/tenant-manager.spec.ts @@ -0,0 +1,590 @@ +/*! + * Copyright 2019 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 mocks from '../../resources/mocks'; +import {FirebaseApp} from '../../../src/firebase-app'; +import {AuthRequestHandler} from '../../../src/auth/auth-api-request'; +import {Tenant, TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; +import {TenantManager} from '../../../src/auth/tenant-manager'; +import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('TenantManager', () => { + const TENANT_ID = 'tenantId'; + let mockApp: FirebaseApp; + let tenantManager: TenantManager; + let nullAccessTokenTenantManager: TenantManager; + let malformedAccessTokenTenantManager: TenantManager; + let rejectedPromiseAccessTokenTenantManager: TenantManager; + + + beforeEach(() => { + mockApp = mocks.app(); + tenantManager = new TenantManager(mockApp); + nullAccessTokenTenantManager = new TenantManager( + mocks.appReturningNullAccessToken()); + malformedAccessTokenTenantManager = new TenantManager( + mocks.appReturningMalformedAccessToken()); + rejectedPromiseAccessTokenTenantManager = new TenantManager( + mocks.appRejectedWhileFetchingAccessToken()); + + }); + + afterEach(() => { + return mockApp.delete(); + }); + + describe('authForTenant()', () => { + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should throw given invalid tenant ID: ' + JSON.stringify(invalidTenantId), () => { + expect(() => { + return tenantManager.authForTenant(invalidTenantId as any); + }).to.throw('The tenant ID must be a valid non-empty string.'); + }); + }); + + it('should return a TenantAwareAuth with the expected tenant ID', () => { + expect(tenantManager.authForTenant(TENANT_ID).tenantId).to.equal(TENANT_ID); + }); + + it('should return a TenantAwareAuth with read-only tenant ID', () => { + expect(() => { + (tenantManager.authForTenant(TENANT_ID) as any).tenantId = 'OTHER_TENANT_ID'; + }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); + }); + + it('should cache the returned TenantAwareAuth', () => { + const tenantAwareAuth1 = tenantManager.authForTenant('tenantId1'); + const tenantAwareAuth2 = tenantManager.authForTenant('tenantId2'); + expect(tenantManager.authForTenant('tenantId1')).to.equal(tenantAwareAuth1); + expect(tenantManager.authForTenant('tenantId2')).to.equal(tenantAwareAuth2); + expect(tenantAwareAuth1).to.not.be.equal(tenantAwareAuth2); + expect(tenantAwareAuth1.tenantId).to.equal('tenantId1'); + expect(tenantAwareAuth2.tenantId).to.equal('tenantId2'); + }); + }); + + describe('getTenant()', () => { + const tenantId = 'tenant_id'; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).getTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return tenantManager.getTenant(invalidTenantId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.getTenant(tenantId) + .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 rejectedPromiseAccessTokenTenantManager.getTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on success', () => { + // Stub getTenant to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(stub); + return tenantManager.getTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected tenant returned. + expect(result).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub getTenant to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'getTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return tenantManager.getTenant(tenantId) + .then((tenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('listTenants()', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + const pageToken = 'PAGE_TOKEN'; + const maxResult = 500; + const listTenantsResponse: any = { + tenants : [ + {name: 'projects/project_id/tenants/tenant_id1'}, + {name: 'projects/project_id/tenants/tenant_id2'}, + ], + nextPageToken: 'NEXT_PAGE_TOKEN', + }; + const expectedResult: ListTenantsResult = { + tenants: [ + new Tenant({name: 'projects/project_id/tenants/tenant_id1'}), + new Tenant({name: 'projects/project_id/tenants/tenant_id2'}), + ], + pageToken: 'NEXT_PAGE_TOKEN', + }; + const emptyListTenantsResponse: any = { + tenants: [], + }; + const emptyExpectedResult: any = { + tenants: [], + }; + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given an invalid page token', () => { + const invalidToken = {}; + return tenantManager.listTenants(undefined, invalidToken as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-page-token'); + }); + }); + + it('should be rejected given an invalid max result', () => { + const invalidResults = 5000; + return tenantManager.listTenants(invalidResults) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.listTenants(maxResult) + .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 rejectedPromiseAccessTokenTenantManager.listTenants(maxResult) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve on listTenants request success with tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should resolve on listTenants request success with default options', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(listTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants() + .then((response) => { + expect(response).to.deep.equal(expectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(undefined, undefined); + }); + }); + + it('should resolve on listTenants request success with no tenants in response', () => { + // Stub listTenants to return expected response. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.resolve(emptyListTenantsResponse)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((response) => { + expect(response).to.deep.equal(emptyExpectedResult); + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + }); + }); + + it('should throw an error when listTenants returns an error', () => { + // Stub listTenants to throw a backend error. + const listTenantsStub = sinon + .stub(AuthRequestHandler.prototype, 'listTenants') + .returns(Promise.reject(expectedError)); + stubs.push(listTenantsStub); + return tenantManager.listTenants(maxResult, pageToken) + .then((results) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(listTenantsStub) + .to.have.been.calledOnce.and.calledWith(maxResult, pageToken); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('deleteTenant()', () => { + const tenantId = 'tenant_id'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).deleteTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return tenantManager.deleteTenant(invalidTenantId) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.deleteTenant(tenantId) + .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 rejectedPromiseAccessTokenTenantManager.deleteTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with void on success', () => { + // Stub deleteTenant to return expected result. + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteTenant') + .returns(Promise.resolve()); + stubs.push(stub); + return tenantManager.deleteTenant(tenantId) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected result is undefined. + expect(result).to.be.undefined; + }); + }); + + it('should throw an error when the backend returns an error', () => { + // Stub deleteTenant to throw a backend error. + const stub = sinon.stub(AuthRequestHandler.prototype, 'deleteTenant') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + return tenantManager.deleteTenant(tenantId) + .then((userRecord) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(stub).to.have.been.calledOnce.and.calledWith(tenantId); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('createTenant()', () => { + const tenantId = 'tenant_id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no properties', () => { + return (tenantManager as any).createTenant() + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return tenantManager.createTenant(null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid type property', () => { + // Create tenant using invalid type. This should throw an argument error. + return tenantManager.createTenant({type: 'invalid'} as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.createTenant(tenantOptions) + .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 rejectedPromiseAccessTokenTenantManager.createTenant(tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on createTenant request success', () => { + // Stub createTenant to return expected result. + const createTenantStub = sinon.stub(AuthRequestHandler.prototype, 'createTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(createTenantStub); + return tenantManager.createTenant(tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when createTenant returns an error', () => { + // Stub createTenant to throw a backend error. + const createTenantStub = sinon.stub(AuthRequestHandler.prototype, 'createTenant') + .returns(Promise.reject(expectedError)); + stubs.push(createTenantStub); + return tenantManager.createTenant(tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createTenantStub).to.have.been.calledOnce.and.calledWith(tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + + describe('updateTenant()', () => { + const tenantId = 'tenant_id'; + const tenantOptions: TenantOptions = { + displayName: 'TENANT_DISPLAY_NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: true, + }, + }; + const serverResponse: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + displayName: 'TENANT_DISPLAY_NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + const expectedTenant = new Tenant(serverResponse); + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to update the tenant provided.'); + // Stubs used to simulate underlying API calls. + let stubs: sinon.SinonStub[] = []; + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no tenant ID', () => { + return (tenantManager as any).updateTenant(undefined, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); + }); + + it('should be rejected given an invalid tenant ID', () => { + const invalidTenantId = ''; + return tenantManager.updateTenant(invalidTenantId, tenantOptions) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); + + it('should be rejected given no TenantOptions', () => { + return (tenantManager as any).updateTenant(tenantId) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('should be rejected given invalid TenantOptions', () => { + return tenantManager.updateTenant(tenantId, null) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given TenantOptions with invalid update property', () => { + // Updating the tenantId of an existing tenant will throw an error as tenantId is + // an immutable property. + return tenantManager.updateTenant(tenantId, {tenantId: 'unmodifiable'} as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/argument-error'); + }); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .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 rejectedPromiseAccessTokenTenantManager.updateTenant(tenantId, tenantOptions) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve with a Tenant on updateTenant request success', () => { + // Stub updateTenant to return expected result. + const updateTenantStub = sinon.stub(AuthRequestHandler.prototype, 'updateTenant') + .returns(Promise.resolve(serverResponse)); + stubs.push(updateTenantStub); + return tenantManager.updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected Tenant object returned. + expect(actualTenant).to.deep.equal(expectedTenant); + }); + }); + + it('should throw an error when updateTenant returns an error', () => { + // Stub updateTenant to throw a backend error. + const updateTenantStub = sinon.stub(AuthRequestHandler.prototype, 'updateTenant') + .returns(Promise.reject(expectedError)); + stubs.push(updateTenantStub); + return tenantManager.updateTenant(tenantId, tenantOptions) + .then((actualTenant) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(updateTenantStub).to.have.been.calledOnce.and.calledWith(tenantId, tenantOptions); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index bebe719b3b..a0474786d8 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -36,6 +36,7 @@ import './auth/user-import-builder.spec'; import './auth/action-code-settings-builder.spec'; import './auth/auth-config.spec'; import './auth/tenant.spec'; +import './auth/tenant-manager.spec'; // Database import './database/database.spec'; From 9cf1c8def366d9d194dd9f35661cded55618d3aa Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 7 Aug 2019 20:56:18 -0700 Subject: [PATCH 2/4] Updates tenant display names to not use underscores and only use letters, digits and hyphens. --- test/unit/auth/tenant-manager.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts index 889e256a43..f7481f6c25 100644 --- a/test/unit/auth/tenant-manager.spec.ts +++ b/test/unit/auth/tenant-manager.spec.ts @@ -95,7 +95,7 @@ describe('TenantManager', () => { const tenantId = 'tenant_id'; const serverResponse: TenantServerResponse = { name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, }; @@ -378,7 +378,7 @@ describe('TenantManager', () => { describe('createTenant()', () => { const tenantId = 'tenant_id'; const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: true, @@ -386,7 +386,7 @@ describe('TenantManager', () => { }; const serverResponse: TenantServerResponse = { name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, }; @@ -476,7 +476,7 @@ describe('TenantManager', () => { describe('updateTenant()', () => { const tenantId = 'tenant_id'; const tenantOptions: TenantOptions = { - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { enabled: true, passwordRequired: true, @@ -484,7 +484,7 @@ describe('TenantManager', () => { }; const serverResponse: TenantServerResponse = { name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT_DISPLAY_NAME', + displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, }; From 5c848f07e04210f3ac3cc4b8c449784e54c756d3 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 8 Aug 2019 16:02:57 -0700 Subject: [PATCH 3/4] Address review comments. --- src/auth/tenant-manager.ts | 2 +- test/unit/auth/tenant-manager.spec.ts | 69 +++++++++++---------------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/auth/tenant-manager.ts b/src/auth/tenant-manager.ts index 468394acae..5af985a04a 100644 --- a/src/auth/tenant-manager.ts +++ b/src/auth/tenant-manager.ts @@ -146,4 +146,4 @@ export class TenantManager { return new Tenant(response); }); } -} \ No newline at end of file +} diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts index f7481f6c25..e0f29f6553 100644 --- a/test/unit/auth/tenant-manager.spec.ts +++ b/test/unit/auth/tenant-manager.spec.ts @@ -42,9 +42,14 @@ describe('TenantManager', () => { let nullAccessTokenTenantManager: TenantManager; let malformedAccessTokenTenantManager: TenantManager; let rejectedPromiseAccessTokenTenantManager: TenantManager; - - - beforeEach(() => { + const GET_TENANT_RESPONSE: TenantServerResponse = { + name: 'projects/project_id/tenants/tenant_id', + displayName: 'TENANT-DISPLAY-NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: false, + }; + + before(() => { mockApp = mocks.app(); tenantManager = new TenantManager(mockApp); nullAccessTokenTenantManager = new TenantManager( @@ -56,7 +61,7 @@ describe('TenantManager', () => { }); - afterEach(() => { + after(() => { return mockApp.delete(); }); @@ -93,13 +98,7 @@ describe('TenantManager', () => { describe('getTenant()', () => { const tenantId = 'tenant_id'; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT-DISPLAY-NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); // Stubs used to simulate underlying API calls. let stubs: sinon.SinonStub[] = []; @@ -142,7 +141,7 @@ describe('TenantManager', () => { it('should resolve with a Tenant on success', () => { // Stub getTenant to return expected result. const stub = sinon.stub(AuthRequestHandler.prototype, 'getTenant') - .returns(Promise.resolve(serverResponse)); + .returns(Promise.resolve(GET_TENANT_RESPONSE)); stubs.push(stub); return tenantManager.getTenant(tenantId) .then((result) => { @@ -213,9 +212,9 @@ describe('TenantManager', () => { }); }); - it('should be rejected given an invalid max result', () => { - const invalidResults = 5000; - return tenantManager.listTenants(invalidResults) + it('should be rejected given a maxResults greater than the allowed max', () => { + const moreThanMax = 1000 + 1; + return tenantManager.listTenants(moreThanMax) .then(() => { throw new Error('Unexpected success'); }) @@ -318,15 +317,17 @@ describe('TenantManager', () => { .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); }); - it('should be rejected given an invalid tenant ID', () => { - const invalidTenantId = ''; - return tenantManager.deleteTenant(invalidTenantId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-tenant-id'); - }); + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + invalidTenantIds.forEach((invalidTenantId) => { + it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { + return tenantManager.deleteTenant(invalidTenantId as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-tenant-id'); + }); + }); }); it('should be rejected given an app which returns null access tokens', () => { @@ -384,13 +385,7 @@ describe('TenantManager', () => { passwordRequired: true, }, }; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT-DISPLAY-NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'Unable to create the tenant provided.'); @@ -445,7 +440,7 @@ describe('TenantManager', () => { it('should resolve with a Tenant on createTenant request success', () => { // Stub createTenant to return expected result. const createTenantStub = sinon.stub(AuthRequestHandler.prototype, 'createTenant') - .returns(Promise.resolve(serverResponse)); + .returns(Promise.resolve(GET_TENANT_RESPONSE)); stubs.push(createTenantStub); return tenantManager.createTenant(tenantOptions) .then((actualTenant) => { @@ -482,13 +477,7 @@ describe('TenantManager', () => { passwordRequired: true, }, }; - const serverResponse: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', - displayName: 'TENANT-DISPLAY-NAME', - allowPasswordSignup: true, - enableEmailLinkSignin: false, - }; - const expectedTenant = new Tenant(serverResponse); + const expectedTenant = new Tenant(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'Unable to update the tenant provided.'); @@ -560,7 +549,7 @@ describe('TenantManager', () => { it('should resolve with a Tenant on updateTenant request success', () => { // Stub updateTenant to return expected result. const updateTenantStub = sinon.stub(AuthRequestHandler.prototype, 'updateTenant') - .returns(Promise.resolve(serverResponse)); + .returns(Promise.resolve(GET_TENANT_RESPONSE)); stubs.push(updateTenantStub); return tenantManager.updateTenant(tenantId, tenantOptions) .then((actualTenant) => { From e8b74edae78511b6a8f0eeb8296cbf8d53a69879 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 8 Aug 2019 16:43:43 -0700 Subject: [PATCH 4/4] Removes usage of underscore in tenant and project IDs. --- test/unit/auth/tenant-manager.spec.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts index e0f29f6553..40144443e9 100644 --- a/test/unit/auth/tenant-manager.spec.ts +++ b/test/unit/auth/tenant-manager.spec.ts @@ -36,14 +36,14 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('TenantManager', () => { - const TENANT_ID = 'tenantId'; + const TENANT_ID = 'tenant-id'; let mockApp: FirebaseApp; let tenantManager: TenantManager; let nullAccessTokenTenantManager: TenantManager; let malformedAccessTokenTenantManager: TenantManager; let rejectedPromiseAccessTokenTenantManager: TenantManager; const GET_TENANT_RESPONSE: TenantServerResponse = { - name: 'projects/project_id/tenants/tenant_id', + name: 'projects/project-id/tenants/tenant-id', displayName: 'TENANT-DISPLAY-NAME', allowPasswordSignup: true, enableEmailLinkSignin: false, @@ -81,7 +81,7 @@ describe('TenantManager', () => { it('should return a TenantAwareAuth with read-only tenant ID', () => { expect(() => { - (tenantManager.authForTenant(TENANT_ID) as any).tenantId = 'OTHER_TENANT_ID'; + (tenantManager.authForTenant(TENANT_ID) as any).tenantId = 'OTHER-TENANT-ID'; }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); }); @@ -97,7 +97,7 @@ describe('TenantManager', () => { }); describe('getTenant()', () => { - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const expectedTenant = new Tenant(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); // Stubs used to simulate underlying API calls. @@ -175,15 +175,15 @@ describe('TenantManager', () => { const maxResult = 500; const listTenantsResponse: any = { tenants : [ - {name: 'projects/project_id/tenants/tenant_id1'}, - {name: 'projects/project_id/tenants/tenant_id2'}, + {name: 'projects/project-id/tenants/tenant-id1'}, + {name: 'projects/project-id/tenants/tenant-id2'}, ], nextPageToken: 'NEXT_PAGE_TOKEN', }; const expectedResult: ListTenantsResult = { tenants: [ - new Tenant({name: 'projects/project_id/tenants/tenant_id1'}), - new Tenant({name: 'projects/project_id/tenants/tenant_id2'}), + new Tenant({name: 'projects/project-id/tenants/tenant-id1'}), + new Tenant({name: 'projects/project-id/tenants/tenant-id2'}), ], pageToken: 'NEXT_PAGE_TOKEN', }; @@ -303,7 +303,7 @@ describe('TenantManager', () => { }); describe('deleteTenant()', () => { - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); // Stubs used to simulate underlying API calls. let stubs: sinon.SinonStub[] = []; @@ -317,7 +317,7 @@ describe('TenantManager', () => { .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-tenant-id'); }); - const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant_id'], [], {}, { a: 1 }, _.noop]; + const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop]; invalidTenantIds.forEach((invalidTenantId) => { it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => { return tenantManager.deleteTenant(invalidTenantId as any) @@ -377,7 +377,7 @@ describe('TenantManager', () => { }); describe('createTenant()', () => { - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const tenantOptions: TenantOptions = { displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { @@ -469,7 +469,7 @@ describe('TenantManager', () => { }); describe('updateTenant()', () => { - const tenantId = 'tenant_id'; + const tenantId = 'tenant-id'; const tenantOptions: TenantOptions = { displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: {