diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 60ba987e4d..3c99e2610c 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -25,7 +25,7 @@ import { import {CreateRequest, UpdateRequest} from './user-record'; import { UserImportBuilder, UserImportOptions, UserImportRecord, - UserImportResult, + UserImportResult, AuthFactorInfo, } from './user-import-builder'; import * as utils from '../utils/index'; import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder'; @@ -151,6 +151,66 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { } +/** + * Validates an AuthFactorInfo object. All unsupported parameters + * are removed from the original request. If an invalid field is passed + * an error is thrown. + * + * @param request The AuthFactorInfo request object. + */ +function validateAuthFactorInfo(request: AuthFactorInfo) { + const validKeys = { + mfaEnrollmentId: true, + displayName: true, + phoneInfo: true, + enrolledAt: true, + }; + // Remove unsupported keys from the original request. + for (const key in request) { + if (!(key in validKeys)) { + delete request[key]; + } + } + if (!validator.isNonEmptyString(request.mfaEnrollmentId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The second factor "uid" must be a valid non-empty string.`, + ); + } + if (typeof request.displayName !== 'undefined' && + !validator.isString(request.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The second factor "displayName" for "${request.mfaEnrollmentId}" must be a valid string.`, + ); + } + // enrolledAt must be a valid UTC date string. + if (typeof request.enrolledAt !== 'undefined' && + !validator.isISODateString(request.enrolledAt)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${request.mfaEnrollmentId}" must be a valid ` + + `UTC date string.`); + } + // Validate required fields depending on second factor type. + if (typeof request.phoneInfo !== 'undefined') { + // phoneNumber should be a string and a valid phone number. + if (!validator.isPhoneNumber(request.phoneInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "${request.mfaEnrollmentId}" must be a non-empty ` + + `E.164 standard compliant identifier string.`); + } + } else { + // Invalid second factor. For example, a phone second factor may have been provided without + // a phone number. A TOTP based second factor may require a secret key, etc. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `MFAInfo object provided is invalid.`); + } +} + + /** * Validates a providerUserInfo object. All unsupported parameters * are removed from the original request. If an invalid field is passed @@ -243,6 +303,7 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = createdAt: uploadAccountRequest, lastLoginAt: uploadAccountRequest, providerUserInfo: uploadAccountRequest, + mfaInfo: uploadAccountRequest, }; // Remove invalid keys from original request. for (const key in request) { @@ -381,6 +442,15 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = validateProviderUserInfo(providerUserInfoEntry); }); } + // mfaInfo has to be an array of valid AuthFactorInfo requests. + if (request.mfaInfo) { + if (!validator.isArray(request.mfaInfo)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS); + } + request.mfaInfo.forEach((authFactorInfoEntry: AuthFactorInfo) => { + validateAuthFactorInfo(authFactorInfoEntry); + }); + } } diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts old mode 100644 new mode 100755 index f538bb6489..b6eb2ebd84 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -60,12 +60,30 @@ export interface UserImportRecord { photoURL?: string, providerId: string, }>; + multiFactor?: { + enrolledFactors: Array<{ + uid: string; + phoneNumber: string; + displayName?: string; + enrollmentTime?: string; + factorId: string; + }>; + }; customClaims?: object; passwordHash?: Buffer; passwordSalt?: Buffer; tenantId?: string; } +/** Interface representing an Auth second factor in Auth server format. */ +export interface AuthFactorInfo { + mfaEnrollmentId: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + /** UploadAccount endpoint request user interface. */ interface UploadAccountUser { @@ -83,6 +101,7 @@ interface UploadAccountUser { displayName?: string; photoUrl?: string; }>; + mfaInfo?: AuthFactorInfo[]; passwordHash?: string; salt?: string; lastLoginAt?: number; @@ -155,6 +174,7 @@ function populateUploadAccountUser( photoUrl: user.photoURL, phoneNumber: user.phoneNumber, providerUserInfo: [], + mfaInfo: [], tenantId: user.tenantId, customAttributes: user.customClaims && JSON.stringify(user.customClaims), }; @@ -193,6 +213,48 @@ function populateUploadAccountUser( }); }); } + + // Convert user.multiFactor.enrolledFactors to server format. + if (validator.isNonNullObject(user.multiFactor) && + validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { + user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + let enrolledAt; + if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { + if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { + // Convert from UTC date string (client side format) to ISO date string (server side format). + enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + + `UTC date string.`); + } + } + // Currently only phone second factors are supported. + if (multiFactorInfo.factorId === 'phone') { + // If any required field is missing or invalid, validation will still fail later. + const authFactorInfo: AuthFactorInfo = { + mfaEnrollmentId: multiFactorInfo.uid, + displayName: multiFactorInfo.displayName, + // Required for all phone second factors. + phoneInfo: multiFactorInfo.phoneNumber, + enrolledAt, + }; + for (const objKey in authFactorInfo) { + if (typeof authFactorInfo[objKey] === 'undefined') { + delete authFactorInfo[objKey]; + } + } + result.mfaInfo.push(authFactorInfo); + } else { + // Unsupported second factor. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); + } + }); + } + // Remove blank fields. let key: keyof UploadAccountUser; for (key in result) { @@ -203,6 +265,9 @@ function populateUploadAccountUser( if (result.providerUserInfo.length === 0) { delete result.providerUserInfo; } + if (result.mfaInfo.length === 0) { + delete result.mfaInfo; + } // Validate the constructured user individual request. This will throw if an error // is detected. if (typeof userValidator === 'function') { diff --git a/src/utils/error.ts b/src/utils/error.ts index 0949dff13d..6baff7a80c 100755 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -427,6 +427,14 @@ export class AuthClientErrorCode { code: 'invalid-email', message: 'The email address is improperly formatted.', }; + public static INVALID_ENROLLED_FACTORS = { + code: 'invalid-enrolled-factors', + message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.', + }; + public static INVALID_ENROLLMENT_TIME = { + code: 'invalid-enrollment-time', + message: 'The second factor enrollment time must be a valid UTC date string.', + }; public static INVALID_HASH_ALGORITHM = { code: 'invalid-hash-algorithm', message: 'The hash algorithm must match one of the strings in the list of ' + diff --git a/src/utils/validator.ts b/src/utils/validator.ts old mode 100644 new mode 100755 index 771c2270f7..b53ba9f8cc --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -186,6 +186,37 @@ export function isPhoneNumber(phoneNumber: any): boolean { } +/** + * Validates that a string is a valid ISO date string. + * + * @param dateString The string to validate. + * @return Whether the string is a valid ISO date string. + */ +export function isISODateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toISOString() === dateString); + } catch (e) { + return false; + } +} + + +/** + * Validates that a string is a valid UTC date string. + * + * @param dateString The string to validate. + * @return Whether the string is a valid UTC date string. + */ +export function isUTCDateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toUTCString() === dateString); + } catch (e) { + return false; + } +} + /** * Validates that a string is a valid web URL. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 58c45023a9..ffe9551500 100755 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1497,6 +1497,64 @@ describe('admin.auth', () => { }).should.eventually.be.fulfilled; }); + it('successfully imports users with enrolled second factors', () => { + const uid = generateRandomString(20).toLowerCase(); + const email = uid + '@example.com'; + const now = new Date(1476235905000).toUTCString(); + importUserRecord = { + uid, + email, + emailVerified: true, + displayName: 'Test User', + disabled: false, + metadata: { + lastSignInTime: now, + creationTime: now, + }, + providerData: [ + { + uid: uid + '-facebook', + displayName: 'Facebook User', + email, + providerId: 'facebook.com', + }, + ], + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid1', + phoneNumber: '+16505550001', + displayName: 'Work phone number', + factorId: 'phone', + enrollmentTime: now, + }, + { + uid: 'mfaUid2', + phoneNumber: '+16505550002', + displayName: 'Personal phone number', + factorId: 'phone', + enrollmentTime: now, + }, + ], + }, + }; + uids.push(importUserRecord.uid); + + return admin.auth().importUsers([importUserRecord]) + .then((result) => { + expect(result.failureCount).to.equal(0); + expect(result.successCount).to.equal(1); + expect(result.errors.length).to.equal(0); + return admin.auth().getUser(uid); + }).then((userRecord) => { + // Confirm second factors added to user. + const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); + expect(actualUserRecord.multiFactor.enrolledFactors) + .to.deep.equal(importUserRecord.multiFactor.enrolledFactors); + }).should.eventually.be.fulfilled; + }); + it('fails when invalid users are provided', () => { const users = [ {uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error'}, diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index ffcf7d556f..50baaa4600 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -1138,6 +1138,23 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { providerId: 'google.com', }, ], + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid1', + phoneNumber: '+16505550001', + displayName: 'Corp phone number', + factorId: 'phone', + enrollmentTime: new Date().toUTCString(), + }, + { + uid: 'mfaUid2', + phoneNumber: '+16505550002', + displayName: 'Personal phone number', + factorId: 'phone', + }, + ], + }, customClaims: {admin: true}, // Tenant ID accepted on user batch upload. tenantId, @@ -1343,6 +1360,80 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }, {uid: 'user16', providerData: [{}]}, {email: 'user17@example.com'}, + { + uid: 'user18', + email: 'user18@example.com', + multiFactor: { + enrolledFactors: [ + { + // Invalid mfa enrollment ID. + uid: '', + factorId: 'phone', + phoneNumber: '+16505550001', + }, + ], + }, + }, + { + uid: 'user19', + email: 'user19@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid1', + factorId: 'phone', + // Invalid display name. + displayName: false, + phoneNumber: '+16505550002', + }, + ], + }, + }, + { + uid: 'user20', + email: 'user20@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid2', + factorId: 'phone', + // Invalid enrollment time. + enrollmentTime: 'invalid', + phoneNumber: '+16505550003', + }, + ], + }, + }, + { + uid: 'user21', + email: 'user21@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid3', + factorId: 'phone', + // Invalid phone number + phoneNumber: 'invalid', + enrollmentTime: new Date().toUTCString(), + }, + ], + }, + }, + { + uid: 'user22', + email: 'user22@example.com', + multiFactor: { + enrolledFactors: [ + { + uid: 'mfaUid3', + // Invalid factor ID. + factorId: 'invalid', + phoneNumber: '+16505550003', + enrollmentTime: new Date().toUTCString(), + }, + ], + }, + }, ] as any; const validOptions = { hash: { @@ -1401,6 +1492,42 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { }, {index: 16, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)}, {index: 17, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)}, + { + index: 18, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The second factor "uid" must be a valid non-empty string.`, + ), + }, + { + index: 19, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The second factor "displayName" for "mfaUid1" must be a valid string.`, + ), + }, + { + index: 20, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "mfaUid2" must be a valid UTC date string.`, + ), + }, + { + index: 21, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "mfaUid3" must be a non-empty ` + + `E.164 standard compliant identifier string.`, + ), + }, + { + index: 22, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `Unsupported second factor "${JSON.stringify(testUsers[22].multiFactor.enrolledFactors[0])}" provided.`, + ), + }, ], }; const stub = sinon.stub(HttpClient.prototype, 'send'); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 8793caf5bc..70ec695393 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -101,6 +101,18 @@ function getValidGetAccountInfoResponse(tenantId?: string) { rawId: '+11234567890', }, ], + mfaInfo: [ + { + mfaEnrollmentId: 'enrolledSecondFactor1', + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + enrolledAt: new Date().toISOString(), + }, + { + mfaEnrollmentId: 'enrolledSecondFactor2', + phoneInfo: '+16505551000', + }, + ], photoUrl: 'https://lh3.googleusercontent.com/1234567890/photo.jpg', validSince: '1476136676', lastLoginAt: '1476235905000', diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts old mode 100644 new mode 100755 index 54861f3652..806a6f3c3e --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.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 {UserImportBuilder, ValidatorFunction, UserImportResult} from '../../../src/auth/user-import-builder'; +import { + UserImportBuilder, ValidatorFunction, UserImportResult, UserImportRecord, + UploadAccountRequest, +} from '../../../src/auth/user-import-builder'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {toWebSafeBase64} from '../../../src/utils'; @@ -31,7 +34,8 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('UserImportBuilder', () => { - const nowString = new Date().toUTCString(); + const now = new Date('2019-10-25T04:30:52.000Z'); + const nowString = now.toUTCString(); const userRequestValidator: ValidatorFunction = (request) => { // Do not throw an error. }; @@ -75,6 +79,28 @@ describe('UserImportBuilder', () => { passwordSalt: Buffer.from('NaCl'), }, {uid: '5678', phoneNumber: '+16505550101'}, + { + uid: '3456', + email: 'janedoe@example.com', + passwordHash: Buffer.from('password'), + passwordSalt: Buffer.from('NaCl'), + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: now.toUTCString(), + }, + { + uid: 'enrolledSecondFactor2', + phoneNumber: '+16505551000', + factorId: 'phone', + }, + ], + }, + }, ]; const expectedUsersRequest = [ { @@ -109,6 +135,24 @@ describe('UserImportBuilder', () => { localId: '5678', phoneNumber: '+16505550101', }, + { + localId: '3456', + email: 'janedoe@example.com', + passwordHash: toWebSafeBase64(Buffer.from('password')), + salt: toWebSafeBase64(Buffer.from('NaCl')), + mfaInfo: [ + { + mfaEnrollmentId: 'enrolledSecondFactor1', + phoneInfo: '+16505557348', + displayName: 'Spouse\'s phone number', + enrolledAt: now.toISOString(), + }, + { + mfaEnrollmentId: 'enrolledSecondFactor2', + phoneInfo: '+16505551000', + }, + ], + }, ]; const hmacAlgorithms = ['HMAC_SHA512', 'HMAC_SHA256', 'HMAC_SHA1', 'HMAC_MD5']; @@ -533,7 +577,9 @@ describe('UserImportBuilder', () => { const expectedRequest = { hashAlgorithm: algorithm, // The third user will be removed due to client side error. - users: [expectedUsersRequest[0], expectedUsersRequest[1]], + users: [ + expectedUsersRequest[0], expectedUsersRequest[1], expectedUsersRequest[3], + ], }; const userImportBuilder = new UserImportBuilder(users, validOptions as any, userRequestValidatorWithError); @@ -555,6 +601,77 @@ describe('UserImportBuilder', () => { new UserImportBuilder(noHashUsers, validOptions as any, userRequestValidator); expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); }); + + it('should return expected request with no multi-factor fields when not available', () => { + const noMultiFactorUsers: UserImportRecord[] = [ + {uid: '1234', email: 'user@example.com', multiFactor: null}, + {uid: '5678', phoneNumber: '+16505550101', multiFactor: {enrolledFactors: []}}, + ]; + const expectedRequest = { + users: [ + {localId: '1234', email: 'user@example.com'}, + {localId: '5678', phoneNumber: '+16505550101'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(noMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); + + it('should ignore users with invalid second factor enrollment time', () => { + const invalidMultiFactorUsers: UserImportRecord[] = [ + { + uid: '1234', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + phoneNumber: '+16505557348', + displayName: 'Spouse\'s phone number', + factorId: 'phone', + enrollmentTime: 'invalid', + }, + ], + }, + }, + {uid: '5678', phoneNumber: '+16505550102'}, + ]; + const expectedRequest: UploadAccountRequest = { + users: [ + {localId: '5678', phoneNumber: '+16505550102'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(invalidMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); + + it('should ignore users with unsupported second factors', () => { + const invalidMultiFactorUsers: any = [ + { + uid: '1234', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrolledSecondFactor1', + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + factorId: 'totp', + }, + ], + }, + }, + {uid: '5678', phoneNumber: '+16505550102'}, + ]; + const expectedRequest: UploadAccountRequest = { + users: [ + {localId: '5678', phoneNumber: '+16505550102'}, + ], + }; + const userImportBuilder = + new UserImportBuilder(invalidMultiFactorUsers, validOptions as any, userRequestValidator); + expect(userImportBuilder.buildRequest()).to.deep.equal(expectedRequest); + }); }); describe('buildResponse()', () => { @@ -564,10 +681,11 @@ describe('UserImportBuilder', () => { algorithm, }, }; + it('should return the expected response for successful import', () => { const successfulServerResponse: any = []; const successfulUserImportResponse: UserImportResult = { - successCount: 3, + successCount: 4, failureCount: 0, errors: [], }; @@ -582,7 +700,7 @@ describe('UserImportBuilder', () => { {index: 1, message: 'Some error occurred!'}, ]; const serverErrorUserImportResponse = { - successCount: 2, + successCount: 3, failureCount: 1, errors: [ { @@ -604,7 +722,7 @@ describe('UserImportBuilder', () => { it('should return the expected response for import with client side errors', () => { const successfulServerResponse: any = []; const clientErrorUserImportResponse: UserImportResult = { - successCount: 2, + successCount: 3, failureCount: 1, errors: [ {index: 2, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)}, @@ -634,7 +752,8 @@ describe('UserImportBuilder', () => { // The second and fourth users will throw a client side error. // The third and sixth user will throw a server side error. - // Seventh and eighth user will throw a client side error due to invalid type provided. + // Seventh, eighth and nineth user will throw a client side error due to invalid type provided. + // Tenth user will throw a client side error due to an unsupported second factor. const testUsers = [ {uid: 'USER1'}, {uid: 'USER2', email: 'invalid', passwordHash: Buffer.from('userpass')}, @@ -649,10 +768,36 @@ describe('UserImportBuilder', () => { passwordHash: Buffer.from('password'), passwordSalt: 'not a buffer' as any, }, + { + uid: 'USER9', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId1', + phoneNumber: '+16505551111', + factorId: 'phone', + enrollmentTime: 'invalid', + }, + ], + }, + }, + { + uid: 'USER10', + multiFactor: { + enrolledFactors: [ + { + uid: 'enrollmentId2', + secret: 'SECRET', + displayName: 'Google Authenticator on personal phone', + factorId: 'totp', + } as any, + ], + }, + }, ]; const mixedErrorUserImportResponse = { successCount: 2, - failureCount: 6, + failureCount: 8, errors: [ // Client side detected error. {index: 1, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)}, @@ -677,6 +822,19 @@ describe('UserImportBuilder', () => { // Client side errors. {index: 6, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH)}, {index: 7, error: new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT)}, + { + index: 8, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "enrollmentId1" must be a valid ` + + `UTC date string.`), + }, + { + index: 9, + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + `Unsupported second factor "${JSON.stringify(testUsers[9].multiFactor.enrolledFactors[0])}" provided.`), + }, ], }; const userImportBuilder = new UserImportBuilder( diff --git a/test/unit/utils/validator.spec.ts b/test/unit/utils/validator.spec.ts old mode 100644 new mode 100755 index 00a0ea6c2b..4ad510dd0b --- a/test/unit/utils/validator.spec.ts +++ b/test/unit/utils/validator.spec.ts @@ -22,6 +22,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import { isArray, isNonEmptyArray, isBoolean, isNumber, isString, isNonEmptyString, isNonNullObject, isEmail, isPassword, isURL, isUid, isPhoneNumber, isObject, isBuffer, + isUTCDateString, isISODateString, } from '../../../src/utils/validator'; @@ -470,3 +471,57 @@ describe('isBuffer()', () => { expect(isBuffer(Buffer.from('I am a buffer'))).to.be.true; }); }); + +describe('isUTCDateString()', () => { + const validUTCDateString = 'Fri, 25 Oct 2019 04:01:21 GMT'; + it('should return false given no argument', () => { + expect(isUTCDateString(undefined as any)).to.be.false; + }); + + const nonUTCDateStrings = [ + null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, _.noop, + new Date().getTime(), + new Date().getTime().toString(), + new Date().toISOString(), + 'Fri, 25 Oct 2019 04:01:21', + '25 Oct 2019', 'Fri, 25 Oct 2019', + '2019-10-25', '2019-10-25T04:07:34.036', + new Date().toDateString(), + ]; + nonUTCDateStrings.forEach((nonUTCDateString) => { + it('should return false given an invalid UTC date string: ' + JSON.stringify(nonUTCDateString), () => { + expect(isUTCDateString(nonUTCDateString as any)).to.be.false; + }); + }); + + it('should return true given a valid UTC date string', () => { + expect(isUTCDateString(validUTCDateString)).to.be.true; + }); +}); + +describe('isISODateString()', () => { + const validISODateString = '2019-10-25T04:07:34.036Z'; + it('should return false given no argument', () => { + expect(isISODateString(undefined as any)).to.be.false; + }); + + const nonISODateStrings = [ + null, NaN, 0, 1, true, false, [], ['a'], {}, { a: 1 }, _.noop, + new Date().getTime(), + new Date().getTime().toString(), + new Date().toUTCString(), + 'Fri, 25 Oct 2019 04:01:21', + '25 Oct 2019', 'Fri, 25 Oct 2019', + '2019-10-25', '2019-10-25T04:07:34.036', + new Date().toDateString(), + ]; + nonISODateStrings.forEach((nonISODateString) => { + it('should return false given an invalid ISO date string: ' + JSON.stringify(nonISODateString), () => { + expect(isISODateString(nonISODateString as any)).to.be.false; + }); + }); + + it('should return true given a valid ISO date string', () => { + expect(isISODateString(validISODateString)).to.be.true; + }); +});