diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index 3110264b13..e31e155486 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -15,6 +15,7 @@ */ import {deepCopy} from '../utils/deep-copy'; +import {isNonNullObject} from '../utils/validator'; import * as utils from '../utils'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; @@ -26,8 +27,8 @@ const B64_REDACTED = Buffer.from('REDACTED').toString('base64'); /** * Parses a time stamp string or number and returns the corresponding date if valid. * - * @param {any} time The unix timestamp string or number in milliseconds. - * @return {string} The corresponding date as a UTC string, if valid. + * @param time The unix timestamp string or number in milliseconds. + * @return The corresponding date as a UTC string, if valid. */ function parseDate(time: any): string { try { @@ -57,11 +58,211 @@ export interface CreateRequest extends UpdateRequest { uid?: string; } +export interface AuthFactorInfo { + mfaEnrollmentId: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +export interface ProviderUserInfo { + rawId: string; + displayName?: string; + email?: string; + photoUrl?: string; + phoneNumber?: string; + providerId: string; + federatedId?: string; +} + +export interface GetAccountInfoUserResponse { + localId: string; + email?: string; + emailVerified?: boolean; + phoneNumber?: string; + displayName?: string; + photoUrl?: string; + disabled?: boolean; + passwordHash?: string; + salt?: string; + customAttributes?: string; + validSince?: string; + tenantId?: string; + providerUserInfo?: ProviderUserInfo[]; + mfaInfo?: AuthFactorInfo[]; + createdAt?: string; + lastLoginAt?: string; + [key: string]: any; +} + +/** Enums for multi-factor identifiers. */ +export enum MultiFactorId { + Phone = 'phone', +} + +/** + * Abstract class representing a multi-factor info interface. + */ +export abstract class MultiFactorInfo { + public readonly uid: string; + public readonly displayName: string | null; + public readonly factorId: MultiFactorId; + public readonly enrollmentTime: string; + + /** + * Initializes the MultiFactorInfo associated subclass using the server side. + * If no MultiFactorInfo is associated with the response, null is returned. + * + * @param response The server side response. + * @constructor + */ + public static initMultiFactorInfo(response: AuthFactorInfo): MultiFactorInfo | null { + let multiFactorInfo: MultiFactorInfo | null = null; + // Only PhoneMultiFactorInfo currently available. + try { + multiFactorInfo = new PhoneMultiFactorInfo(response); + } catch (e) { + // Ignore error. + } + return multiFactorInfo; + } + + /** + * Initializes the MultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: AuthFactorInfo) { + this.initFromServerResponse(response); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + uid: this.uid, + displayName: this.displayName, + factorId: this.factorId, + enrollmentTime: this.enrollmentTime, + }; + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected abstract getFactorId(response: AuthFactorInfo): MultiFactorId | null; + + /** + * Initializes the MultiFactorInfo object using the provided server response. + * + * @param response The server side response. + */ + private initFromServerResponse(response: AuthFactorInfo) { + const factorId = response && this.getFactorId(response); + if (!factorId || !response || !response.mfaEnrollmentId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + } + utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); + utils.addReadonlyGetter(this, 'factorId', factorId); + utils.addReadonlyGetter(this, 'displayName', response.displayName || null); + // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. + // For example, "2017-01-15T01:30:15.01Z". + // This can be parsed directly via Date constructor. + // This can be computed using Data.prototype.toISOString. + if (response.enrolledAt) { + utils.addReadonlyGetter( + this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); + } else { + utils.addReadonlyGetter(this, 'enrollmentTime', null); + } + } +} + +/** Class representing a phone MultiFactorInfo object. */ +export class PhoneMultiFactorInfo extends MultiFactorInfo { + public readonly phoneNumber: string; + + /** + * Initializes the PhoneMultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: AuthFactorInfo) { + super(response); + utils.addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return Object.assign( + super.toJSON(), + { + phoneNumber: this.phoneNumber, + }); + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected getFactorId(response: AuthFactorInfo): MultiFactorId | null { + return !!(response && response.phoneInfo) ? MultiFactorId.Phone : null; + } +} + +/** Class representing multi-factor related properties of a user. */ +export class MultiFactor { + public readonly enrolledFactors: ReadonlyArray; + + /** + * Initializes the MultiFactor object using the server side or JWT format response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: GetAccountInfoUserResponse) { + const parsedEnrolledFactors: MultiFactorInfo[] = []; + if (!isNonNullObject(response)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor response'); + } else if (response.mfaInfo) { + response.mfaInfo.forEach((factorResponse) => { + const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse); + if (multiFactorInfo) { + parsedEnrolledFactors.push(multiFactorInfo); + } + }); + } + // Make enrolled factors immutable. + utils.addReadonlyGetter( + this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors)); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()), + }; + } +} + /** * User metadata class that provides metadata information like user account creation * and last sign in time. * - * @param {object} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -69,7 +270,7 @@ export class UserMetadata { public readonly creationTime: string; public readonly lastSignInTime: string; - constructor(response: any) { + constructor(response: GetAccountInfoUserResponse) { // Creation date should always be available but due to some backend bugs there // were cases in the past where users did not have creation date properly set. // This included legacy Firebase migrating project users and some anonymous users. @@ -78,7 +279,7 @@ export class UserMetadata { utils.addReadonlyGetter(this, 'lastSignInTime', parseDate(response.lastLoginAt)); } - /** @return {object} The plain object representation of the user's metadata. */ + /** @return The plain object representation of the user's metadata. */ public toJSON(): object { return { lastSignInTime: this.lastSignInTime, @@ -91,7 +292,7 @@ export class UserMetadata { * User info class that provides provider user information for different * Firebase providers like google.com, facebook.com, password, etc. * - * @param {object} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -103,7 +304,7 @@ export class UserInfo { public readonly providerId: string; public readonly phoneNumber: string; - constructor(response: any) { + constructor(response: ProviderUserInfo) { // Provider user id and provider id are required. if (!response.rawId || !response.providerId) { throw new FirebaseAuthError( @@ -119,7 +320,7 @@ export class UserInfo { utils.addReadonlyGetter(this, 'phoneNumber', response.phoneNumber); } - /** @return {object} The plain object representation of the current provider data. */ + /** @return The plain object representation of the current provider data. */ public toJSON(): object { return { uid: this.uid, @@ -136,7 +337,7 @@ export class UserInfo { * User record class that defines the Firebase user object populated from * the Firebase Auth getAccountInfo response. * - * @param {any} response The server side response returned from the getAccountInfo + * @param response The server side response returned from the getAccountInfo * endpoint. * @constructor */ @@ -155,8 +356,9 @@ export class UserRecord { public readonly customClaims: object; public readonly tenantId?: string | null; public readonly tokensValidAfterTime?: string; + public readonly multiFactor?: MultiFactor; - constructor(response: any) { + constructor(response: GetAccountInfoUserResponse) { // The Firebase user id is required. if (!response.localId) { throw new FirebaseAuthError( @@ -199,13 +401,17 @@ export class UserRecord { let validAfterTime: string = null; // Convert validSince first to UTC milliseconds and then to UTC date string. if (typeof response.validSince !== 'undefined') { - validAfterTime = parseDate(response.validSince * 1000); + validAfterTime = parseDate(parseInt(response.validSince, 10) * 1000); } utils.addReadonlyGetter(this, 'tokensValidAfterTime', validAfterTime || undefined); utils.addReadonlyGetter(this, 'tenantId', response.tenantId); + const multiFactor = new MultiFactor(response); + if (multiFactor.enrolledFactors.length > 0) { + utils.addReadonlyGetter(this, 'multiFactor', multiFactor); + } } - /** @return {object} The plain object representation of the user record. */ + /** @return The plain object representation of the user record. */ public toJSON(): object { const json: any = { uid: this.uid, @@ -223,6 +429,9 @@ export class UserRecord { tokensValidAfterTime: this.tokensValidAfterTime, tenantId: this.tenantId, }; + if (this.multiFactor) { + json.multiFactor = this.multiFactor.toJSON(); + } json.providerData = []; for (const entry of this.providerData) { // Convert each provider data to json. diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index 7b81d83a2a..8a8b6fd25e 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -19,7 +19,10 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {UserInfo, UserMetadata, UserRecord} from '../../../src/auth/user-record'; +import { + UserInfo, UserMetadata, UserRecord, GetAccountInfoUserResponse, ProviderUserInfo, + MultiFactor, PhoneMultiFactorInfo, MultiFactorInfo, AuthFactorInfo, +} from '../../../src/auth/user-record'; chai.should(); @@ -27,13 +30,14 @@ chai.use(sinonChai); chai.use(chaiAsPromised); const expect = chai.expect; +const now = new Date(); /** - * @param {string=} tenantId The optional tenant ID to add to the response. - * @return {object} A sample valid user response as returned from getAccountInfo + * @param tenantId The optional tenant ID to add to the response. + * @return A sample valid user response as returned from getAccountInfo * endpoint. */ -function getValidUserResponse(tenantId?: string): {[key: string]: any} { +function getValidUserResponse(tenantId?: string): GetAccountInfoUserResponse { const response: any = { localId: 'abcdefghijklmnopqrstuvwxyz', email: 'user@gmail.com', @@ -79,6 +83,19 @@ function getValidUserResponse(tenantId?: string): {[key: string]: any} { customAttributes: JSON.stringify({ admin: true, }), + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + ], }; if (typeof tenantId !== 'undefined') { response.tenantId = tenantId; @@ -87,8 +104,8 @@ function getValidUserResponse(tenantId?: string): {[key: string]: any} { } /** - * @param {string=} tenantId The optional tenant ID to add to the user. - * @return {object} The expected user JSON representation for the above user + * @param tenantId The optional tenant ID to add to the user. + * @return The expected user JSON representation for the above user * server response. */ function getUserJSON(tenantId?: string): object { @@ -145,14 +162,32 @@ function getUserJSON(tenantId?: string): object { }, tokensValidAfterTime: new Date(1476136676000).toUTCString(), tenantId, + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }, + { + uid: 'enrollmentId2', + displayName: null, + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505556789', + factorId: 'phone', + }, + ], + }, }; } /** - * @return {object} A sample user info response as returned from getAccountInfo + * @return A sample user info response as returned from getAccountInfo * endpoint. */ -function getUserInfoResponse(): object { +function getUserInfoResponse(): ProviderUserInfo { return { providerId: 'google.com', displayName: 'John Doe', @@ -164,7 +199,7 @@ function getUserInfoResponse(): object { } /** - * @return {object} The JSON representation of the above user info response. + * @return The JSON representation of the above user info response. */ function getUserInfoJSON(): object { return { @@ -178,10 +213,10 @@ function getUserInfoJSON(): object { } /** - * @return {object} A sample user info response with phone number as returned + * @return A sample user info response with phone number as returned * from getAccountInfo endpoint. */ -function getUserInfoWithPhoneNumberResponse(): object { +function getUserInfoWithPhoneNumberResponse(): ProviderUserInfo { return { providerId: 'phone', phoneNumber: '+11234567890', @@ -190,7 +225,7 @@ function getUserInfoWithPhoneNumberResponse(): object { } /** - * @return {object} The JSON representation of the above user info response + * @return The JSON representation of the above user info response * with a phone number. */ function getUserInfoWithPhoneNumberJSON(): object { @@ -204,11 +239,277 @@ function getUserInfoWithPhoneNumberJSON(): object { }; } +describe('PhoneMultiFactorInfo', () => { + const serverResponse: AuthFactorInfo = { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }; + const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse); + const phoneMultiFactorInfoMissingFields = new PhoneMultiFactorInfo({ + mfaEnrollmentId: serverResponse.mfaEnrollmentId, + phoneInfo: serverResponse.phoneInfo, + }); + + describe('constructor', () => { + it('should throw when an empty object is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({} as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should throw when an undefined response is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo(undefined as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should succeed when mfaEnrollmentId and phoneInfo are both provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + phoneInfo: '+16505551234', + }); + }).not.to.throw(Error); + }); + + it('should throw when only mfaEnrollmentId is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + } as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + + it('should throw when only phoneInfo is provided', () => { + expect(() => { + return new PhoneMultiFactorInfo({ + phoneInfo: '+16505551234', + } as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + }); + }); + + describe('getters', () => { + it('should set missing optional fields to null', () => { + expect(phoneMultiFactorInfoMissingFields.uid).to.equal(serverResponse.mfaEnrollmentId); + expect(phoneMultiFactorInfoMissingFields.displayName).to.be.null; + expect(phoneMultiFactorInfoMissingFields.phoneNumber).to.equal(serverResponse.phoneInfo); + expect(phoneMultiFactorInfoMissingFields.enrollmentTime).to.be.null; + expect(phoneMultiFactorInfoMissingFields.factorId).to.equal('phone'); + }); + + it('should return expected factorId', () => { + expect(phoneMultiFactorInfo.factorId).to.equal('phone'); + }); + + it('should throw when modifying readonly factorId property', () => { + expect(() => { + (phoneMultiFactorInfo as any).factorId = 'other'; + }).to.throw(Error); + }); + + it('should return expected displayName', () => { + expect(phoneMultiFactorInfo.displayName).to.equal(serverResponse.displayName); + }); + + it('should throw when modifying readonly displayName property', () => { + expect(() => { + (phoneMultiFactorInfo as any).displayName = 'Modified'; + }).to.throw(Error); + }); + + it('should return expected phoneNumber', () => { + expect(phoneMultiFactorInfo.phoneNumber).to.equal(serverResponse.phoneInfo); + }); + + it('should throw when modifying readonly phoneNumber property', () => { + expect(() => { + (phoneMultiFactorInfo as any).phoneNumber = '+16505551111'; + }).to.throw(Error); + }); + + it('should return expected uid', () => { + expect(phoneMultiFactorInfo.uid).to.equal(serverResponse.mfaEnrollmentId); + }); + + it('should throw when modifying readonly uid property', () => { + expect(() => { + (phoneMultiFactorInfo as any).uid = 'modifiedEnrollmentId'; + }).to.throw(Error); + }); + + it('should return expected enrollmentTime', () => { + expect(phoneMultiFactorInfo.enrollmentTime).to.equal(now.toUTCString()); + }); + + it('should throw when modifying readonly uid property', () => { + expect(() => { + (phoneMultiFactorInfo as any).enrollmentTime = new Date().toISOString(); + }).to.throw(Error); + }); + }); + + describe('toJSON', () => { + it('should return expected JSON object', () => { + expect(phoneMultiFactorInfo.toJSON()).to.deep.equal({ + uid: 'enrollmentId1', + displayName: 'displayName1', + enrollmentTime: now.toUTCString(), + phoneNumber: '+16505551234', + factorId: 'phone', + }); + }); + + it('should return expected JSON object with missing fields set to null', () => { + expect(phoneMultiFactorInfoMissingFields.toJSON()).to.deep.equal({ + uid: 'enrollmentId1', + displayName: null, + enrollmentTime: null, + phoneNumber: '+16505551234', + factorId: 'phone', + }); + }); + }); +}); + +describe('MultiFactorInfo', () => { + const serverResponse: AuthFactorInfo = { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }; + const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse); + + describe('initMultiFactorInfo', () => { + it('should return expected PhoneMultiFactorInfo', () => { + expect(MultiFactorInfo.initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo); + }); + + it('should return null for invalid MultiFactorInfo', () => { + expect(MultiFactorInfo.initMultiFactorInfo(undefined as any)).to.be.null; + }); + }); +}); + +describe('MultiFactor', () => { + const serverResponse = { + localId: 'uid123', + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + { + // Invalid factor. + mfaEnrollmentId: 'enrollmentId3', + }, + { + // Unsupported factor. + mfaEnrollmentId: 'enrollmentId4', + displayName: 'Backup second factor', + enrolledAt: now.toISOString(), + secretKey: 'SECRET_KEY', + }, + ], + }; + const expectedMultiFactorInfo = [ + new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }), + new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }), + ]; + + describe('constructor', () => { + it('should throw when a non object is provided', () => { + expect(() => { + return new MultiFactor(undefined as any); + }).to.throw('INTERNAL ASSERT FAILED: Invalid multi-factor response'); + }); + + it('should populate an empty enrolledFactors array when given an empty object', () => { + const multiFactor = new MultiFactor({} as any); + + expect(multiFactor.enrolledFactors.length).to.equal(0); + }); + + it('should populate expected enrolledFactors', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(multiFactor.enrolledFactors.length).to.equal(2); + expect(multiFactor.enrolledFactors[0]).to.deep.equal(expectedMultiFactorInfo[0]); + expect(multiFactor.enrolledFactors[1]).to.deep.equal(expectedMultiFactorInfo[1]); + }); + }); + + describe('getter', () => { + it('should throw when modifying readonly enrolledFactors property', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(() => { + (multiFactor as any).enrolledFactors = [ + expectedMultiFactorInfo[0], + ]; + }).to.throw(Error); + }); + + it('should throw when modifying readonly enrolledFactors internals', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(() => { + (multiFactor.enrolledFactors as any)[0] = new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505559999', + }); + }).to.throw(Error); + }); + }); + + describe('toJSON', () => { + it('should return expected JSON object when given an empty response', () => { + const multiFactor = new MultiFactor({} as any); + + expect(multiFactor.toJSON()).to.deep.equal({ + enrolledFactors: [], + }); + }); + + it('should return expected JSON object when given a populated response', () => { + const multiFactor = new MultiFactor(serverResponse); + + expect(multiFactor.toJSON()).to.deep.equal({ + enrolledFactors: [ + expectedMultiFactorInfo[0].toJSON(), + expectedMultiFactorInfo[1].toJSON(), + ], + }); + }); + }); +}); + describe('UserInfo', () => { describe('constructor', () => { it('should throw when an empty object is provided', () => { expect(() => { - return new UserInfo({}); + return new UserInfo({} as any); }).to.throw(Error); }); @@ -220,13 +521,13 @@ describe('UserInfo', () => { it('should throw when only rawId is provided', () => { expect(() => { - return new UserInfo({rawId: '1234567890'}); + return new UserInfo({rawId: '1234567890'} as any); }).to.throw(Error); }); it('should throw when only providerId is provided', () => { expect(() => { - return new UserInfo({providerId: 'google.com'}); + return new UserInfo({providerId: 'google.com'} as any); }).to.throw(Error); }); }); @@ -316,8 +617,9 @@ describe('UserMetadata', () => { const expectedLastLoginAt = 1476235905000; const expectedCreatedAt = 1476136676000; const actualMetadata: UserMetadata = new UserMetadata({ - lastLoginAt: expectedLastLoginAt, - createdAt: expectedCreatedAt, + localId: 'uid123', + lastLoginAt: expectedLastLoginAt.toString(), + createdAt: expectedCreatedAt.toString(), }); const expectedMetadataJSON = { lastSignInTime: new Date(expectedLastLoginAt).toUTCString(), @@ -327,12 +629,12 @@ describe('UserMetadata', () => { describe('constructor', () => { it('should initialize as expected when a valid creationTime is provided', () => { expect(() => { - return new UserMetadata({createdAt: '1476136676000'}); + return new UserMetadata({createdAt: '1476136676000'} as any); }).not.to.throw(Error); }); it('should set creationTime and lastSignInTime to null when not provided', () => { - const metadata = new UserMetadata({}); + const metadata = new UserMetadata({} as any); expect(metadata.creationTime).to.be.null; expect(metadata.lastSignInTime).to.be.null; }); @@ -340,7 +642,7 @@ describe('UserMetadata', () => { it('should set creationTime to null when creationTime value is invalid', () => { const metadata = new UserMetadata({ createdAt: 'invalid', - }); + } as any); expect(metadata.creationTime).to.be.null; expect(metadata.lastSignInTime).to.be.null; }); @@ -349,7 +651,7 @@ describe('UserMetadata', () => { const metadata = new UserMetadata({ createdAt: '1476235905000', lastLoginAt: 'invalid', - }); + } as any); expect(metadata.lastSignInTime).to.be.null; }); }); @@ -387,7 +689,7 @@ describe('UserRecord', () => { describe('constructor', () => { it('should throw when no localId is provided', () => { expect(() => { - return new UserRecord({}); + return new UserRecord({} as any); }).to.throw(Error); }); @@ -570,7 +872,7 @@ describe('UserRecord', () => { const metadata = new UserMetadata({ createdAt: '1476136676000', lastLoginAt: '1476235905000', - }); + } as any); expect(userRecord.metadata).to.deep.equal(metadata); }); @@ -579,7 +881,7 @@ describe('UserRecord', () => { (userRecord as any).metadata = new UserMetadata({ createdAt: new Date().toUTCString(), lastLoginAt: new Date().toUTCString(), - }); + } as any); }).to.throw(Error); }); @@ -648,18 +950,80 @@ describe('UserRecord', () => { }); it('should return expected tenantId', () => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')) as GetAccountInfoUserResponse; const tenantUserRecord = new UserRecord(resp); expect(tenantUserRecord.tenantId).to.equal('TENANT-ID'); }); it('should throw when modifying readonly tenantId property', () => { expect(() => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID')) as GetAccountInfoUserResponse; const tenantUserRecord = new UserRecord(resp); (tenantUserRecord as any).tenantId = 'OTHER-TENANT-ID'; }).to.throw(Error); }); + + it('should return expected multiFactor', () => { + const multiFactor = new MultiFactor({ + localId: 'uid123', + mfaInfo: [ + { + mfaEnrollmentId: 'enrollmentId1', + displayName: 'displayName1', + enrolledAt: now.toISOString(), + phoneInfo: '+16505551234', + }, + { + mfaEnrollmentId: 'enrollmentId2', + enrolledAt: now.toISOString(), + phoneInfo: '+16505556789', + }, + ], + }); + expect(userRecord.multiFactor).to.deep.equal(multiFactor); + expect(userRecord.multiFactor.enrolledFactors.length).to.equal(2); + }); + + it('should return undefined multiFactor when not available', () => { + const validUserResponseWithoutMultiFactor = deepCopy(validUserResponse); + delete validUserResponseWithoutMultiFactor.mfaInfo; + const userRecordWithoutMultiFactor = new UserRecord(validUserResponseWithoutMultiFactor); + + expect(userRecordWithoutMultiFactor.multiFactor).to.be.undefined; + }); + + it('should throw when modifying readonly multiFactor property', () => { + expect(() => { + (userRecord as any).multiFactor = new MultiFactor({ + localId: 'uid123', + mfaInfo: [{ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505550000', + }], + }); + }).to.throw(Error); + }); + + it('should throw when modifying readonly multiFactor internals', () => { + expect(() => { + (userRecord.multiFactor.enrolledFactors[0] as any).displayName = 'Modified'; + }).to.throw(Error); + + expect(() => { + (userRecord.multiFactor.enrolledFactors as any)[0] = new PhoneMultiFactorInfo({ + mfaEnrollmentId: 'enrollmentId3', + displayName: 'displayName3', + enrolledAt: now.toISOString(), + phoneInfo: '+16505550000', + }); + }).to.throw(Error); + + expect(() => { + (userRecord.multiFactor as any).enrolledFactors = []; + }).to.throw(Error); + }); }); describe('toJSON', () => { @@ -676,7 +1040,7 @@ describe('UserRecord', () => { }); it('should return expected JSON object with tenant ID when available', () => { - const resp = deepCopy(getValidUserResponse('TENANT-ID')); + const resp = deepCopy(getValidUserResponse('TENANT-ID') as GetAccountInfoUserResponse); const tenantUserRecord = new UserRecord(resp); expect(tenantUserRecord.toJSON()).to.deep.equal(getUserJSON('TENANT-ID')); });