veritatisdoloremque
Version:
Monorepo for the Firebase JavaScript SDK
461 lines (404 loc) • 15.1 kB
text/typescript
/**
* @license
* Copyright 2020 Google LLC
*
* 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 { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import { OperationType, ProviderId } from '../../model/public_types';
import { FirebaseError } from '@firebase/util';
import { mockEndpoint } from '../../../test/helpers/api/helper';
import { makeJWT } from '../../../test/helpers/jwt';
import { testAuth, testUser, TestAuth } from '../../../test/helpers/mock_auth';
import * as fetch from '../../../test/helpers/mock_fetch';
import { Endpoint } from '../../api';
import { MultiFactorInfoImpl } from '../../mfa/mfa_info';
import { MultiFactorSessionImpl } from '../../mfa/mfa_session';
import { multiFactor, MultiFactorUserImpl } from '../../mfa/mfa_user';
import { ApplicationVerifierInternal } from '../../model/application_verifier';
import { IdTokenResponse, IdTokenResponseKind } from '../../model/id_token';
import { UserInternal } from '../../model/user';
import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier';
import { PhoneAuthCredential } from '../../core/credentials/phone';
import {
_verifyPhoneNumber,
linkWithPhoneNumber,
reauthenticateWithPhoneNumber,
signInWithPhoneNumber,
updatePhoneNumber
} from './phone';
use(chaiAsPromised);
use(sinonChai);
describe('platform_browser/strategies/phone', () => {
let auth: TestAuth;
let verifier: ApplicationVerifierInternal;
let sendCodeEndpoint: fetch.Route;
beforeEach(async () => {
auth = await testAuth();
fetch.setUp();
sendCodeEndpoint = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, {
sessionInfo: 'session-info'
});
verifier = new RecaptchaVerifier(document.createElement('div'), {}, auth);
sinon.stub(verifier, 'verify').returns(Promise.resolve('recaptcha-token'));
});
afterEach(() => {
fetch.tearDown();
sinon.restore();
});
describe('signInWithPhoneNumber', () => {
it('calls verify phone number', async () => {
await signInWithPhoneNumber(auth, '+15105550000', verifier);
expect(sendCodeEndpoint.calls[0].request).to.eql({
recaptchaToken: 'recaptcha-token',
phoneNumber: '+15105550000'
});
});
context('ConfirmationResult', () => {
it('result contains verification id baked in', async () => {
const result = await signInWithPhoneNumber(auth, 'number', verifier);
expect(result.verificationId).to.eq('session-info');
});
it('calling #confirm finishes the sign in flow', async () => {
const idTokenResponse: IdTokenResponse = {
idToken: 'my-id-token',
refreshToken: 'my-refresh-token',
expiresIn: '1234',
localId: 'uid',
kind: IdTokenResponseKind.CreateAuthUri
};
// This endpoint is called from within the callback, in
// signInWithCredential
const signInEndpoint = mockEndpoint(
Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
idTokenResponse
);
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ localId: 'uid' }]
});
const result = await signInWithPhoneNumber(auth, 'number', verifier);
const userCred = await result.confirm('6789');
expect(userCred.user.uid).to.eq('uid');
expect(userCred.operationType).to.eq(OperationType.SIGN_IN);
expect(signInEndpoint.calls[0].request).to.eql({
sessionInfo: 'session-info',
code: '6789'
});
});
});
});
describe('linkWithPhoneNumber', () => {
let getAccountInfoEndpoint: fetch.Route;
let user: UserInternal;
beforeEach(() => {
getAccountInfoEndpoint = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ uid: 'uid' }]
});
user = testUser(auth, 'uid', 'email', true);
});
it('rejects if a phone provider is already linked', async () => {
getAccountInfoEndpoint.response = {
users: [
{
uid: 'uid',
providerUserInfo: [{ providerId: ProviderId.PHONE }]
}
]
};
await expect(
linkWithPhoneNumber(user, 'number', verifier)
).to.be.rejectedWith(
FirebaseError,
'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).'
);
});
it('calls verify phone number', async () => {
await linkWithPhoneNumber(user, '+15105550000', verifier);
expect(sendCodeEndpoint.calls[0].request).to.eql({
recaptchaToken: 'recaptcha-token',
phoneNumber: '+15105550000'
});
});
context('ConfirmationResult', () => {
it('result contains verification id baked in', async () => {
const result = await linkWithPhoneNumber(user, 'number', verifier);
expect(result.verificationId).to.eq('session-info');
});
it('calling #confirm finishes the sign in flow', async () => {
const idTokenResponse: IdTokenResponse = {
idToken: 'my-id-token',
refreshToken: 'my-refresh-token',
expiresIn: '1234',
localId: 'uid',
kind: IdTokenResponseKind.CreateAuthUri
};
// This endpoint is called from within the callback, in
// signInWithCredential
const signInEndpoint = mockEndpoint(
Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
idTokenResponse
);
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ localId: 'uid' }]
});
const initialIdToken = await user.getIdToken();
const result = await linkWithPhoneNumber(user, 'number', verifier);
const userCred = await result.confirm('6789');
expect(userCred.user.uid).to.eq('uid');
expect(userCred.operationType).to.eq(OperationType.LINK);
expect(signInEndpoint.calls[0].request).to.eql({
sessionInfo: 'session-info',
code: '6789',
idToken: initialIdToken
});
});
});
});
describe('reauthenticateWithPhoneNumber', () => {
let user: UserInternal;
beforeEach(() => {
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ uid: 'uid' }]
});
user = testUser(auth, 'uid', 'email', true);
});
it('calls verify phone number', async () => {
await reauthenticateWithPhoneNumber(user, '+15105550000', verifier);
expect(sendCodeEndpoint.calls[0].request).to.eql({
recaptchaToken: 'recaptcha-token',
phoneNumber: '+15105550000'
});
});
context('ConfirmationResult', () => {
it('result contains verification id baked in', async () => {
const result = await reauthenticateWithPhoneNumber(
user,
'number',
verifier
);
expect(result.verificationId).to.eq('session-info');
});
it('calling #confirm finishes the sign in flow', async () => {
const idTokenResponse: IdTokenResponse = {
idToken: makeJWT({ 'sub': 'uid' }),
refreshToken: 'my-refresh-token',
expiresIn: '1234',
localId: 'uid',
kind: IdTokenResponseKind.CreateAuthUri
};
// This endpoint is called from within the callback, in
// signInWithCredential
const signInEndpoint = mockEndpoint(
Endpoint.SIGN_IN_WITH_PHONE_NUMBER,
idTokenResponse
);
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ localId: 'uid' }]
});
const result = await reauthenticateWithPhoneNumber(
user,
'number',
verifier
);
const userCred = await result.confirm('6789');
expect(userCred.user.uid).to.eq('uid');
expect(userCred.operationType).to.eq(OperationType.REAUTHENTICATE);
expect(signInEndpoint.calls[0].request).to.eql({
sessionInfo: 'session-info',
code: '6789',
operation: 'REAUTH'
});
});
it('rejects if the uid mismatches', async () => {
const idTokenResponse: IdTokenResponse = {
idToken: makeJWT({ 'sub': 'different-uid' }),
refreshToken: 'my-refresh-token',
expiresIn: '1234',
localId: 'uid',
kind: IdTokenResponseKind.CreateAuthUri
};
// This endpoint is called from within the callback, in
// signInWithCredential
mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, idTokenResponse);
const result = await reauthenticateWithPhoneNumber(
user,
'number',
verifier
);
await expect(result.confirm('code')).to.be.rejectedWith(
FirebaseError,
'Firebase: The supplied credentials do not correspond to the previously signed in user. (auth/user-mismatch)'
);
});
});
});
describe('_verifyPhoneNumber', () => {
it('works with a string phone number', async () => {
const sessionInfo = await _verifyPhoneNumber(auth, 'number', verifier);
expect(sessionInfo).to.eq('session-info');
expect(sendCodeEndpoint.calls[0].request).to.eql({
recaptchaToken: 'recaptcha-token',
phoneNumber: 'number'
});
});
it('works with an options object', async () => {
const sessionInfo = await _verifyPhoneNumber(
auth,
{
phoneNumber: 'number'
},
verifier
);
expect(sessionInfo).to.eq('session-info');
expect(sendCodeEndpoint.calls[0].request).to.eql({
recaptchaToken: 'recaptcha-token',
phoneNumber: 'number'
});
});
context('MFA', () => {
let user: UserInternal;
let mfaUser: MultiFactorUserImpl;
beforeEach(() => {
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ uid: 'uid' }]
});
user = testUser(auth, 'uid', 'email', true);
mfaUser = multiFactor(user) as MultiFactorUserImpl;
});
it('works with an enrollment flow', async () => {
const endpoint = mockEndpoint(Endpoint.START_PHONE_MFA_ENROLLMENT, {
phoneSessionInfo: {
sessionInfo: 'session-info'
}
});
const session = (await mfaUser.getSession()) as MultiFactorSessionImpl;
const sessionInfo = await _verifyPhoneNumber(
auth,
{ phoneNumber: 'number', session },
verifier
);
expect(sessionInfo).to.eq('session-info');
expect(endpoint.calls[0].request).to.eql({
tenantId: auth.tenantId,
idToken: session.credential,
phoneEnrollmentInfo: {
phoneNumber: 'number',
recaptchaToken: 'recaptcha-token'
}
});
});
it('works when completing the sign in flow', async () => {
const endpoint = mockEndpoint(Endpoint.START_PHONE_MFA_SIGN_IN, {
phoneResponseInfo: {
sessionInfo: 'session-info'
}
});
const session = MultiFactorSessionImpl._fromMfaPendingCredential(
'mfa-pending-credential'
);
const mfaInfo = MultiFactorInfoImpl._fromServerResponse(auth, {
mfaEnrollmentId: 'mfa-enrollment-id',
enrolledAt: Date.now(),
phoneInfo: 'phone-number-from-enrollment'
});
const sessionInfo = await _verifyPhoneNumber(
auth,
{
session,
multiFactorHint: mfaInfo
},
verifier
);
expect(sessionInfo).to.eq('session-info');
expect(endpoint.calls[0].request).to.eql({
tenantId: auth.tenantId,
mfaPendingCredential: 'mfa-pending-credential',
mfaEnrollmentId: 'mfa-enrollment-id',
phoneSignInInfo: {
recaptchaToken: 'recaptcha-token'
}
});
});
});
it('throws if the verifier does not return a string', async () => {
(verifier.verify as sinon.SinonStub).returns(Promise.resolve(123));
await expect(
_verifyPhoneNumber(auth, 'number', verifier)
).to.be.rejectedWith(FirebaseError, 'auth/argument-error');
});
it('throws if the verifier type is not recaptcha', async () => {
const mutVerifier: {
-readonly [K in keyof ApplicationVerifierInternal]: ApplicationVerifierInternal[K];
} = verifier;
mutVerifier.type = 'not-recaptcha-thats-for-sure';
await expect(
_verifyPhoneNumber(auth, 'number', mutVerifier)
).to.be.rejectedWith(FirebaseError, 'auth/argument-error');
});
it('resets the verifer after successful verification', async () => {
sinon.spy(verifier, '_reset');
expect(await _verifyPhoneNumber(auth, 'number', verifier)).to.eq(
'session-info'
);
expect(verifier._reset).to.have.been.called;
});
it('resets the verifer after a failed verification', async () => {
sinon.spy(verifier, '_reset');
(verifier.verify as sinon.SinonStub).returns(Promise.resolve(123));
await expect(_verifyPhoneNumber(auth, 'number', verifier)).to.be.rejected;
expect(verifier._reset).to.have.been.called;
});
});
describe('updatePhoneNumber', () => {
let user: UserInternal;
let reloadMock: fetch.Route;
let signInMock: fetch.Route;
let credential: PhoneAuthCredential;
let idToken: string;
beforeEach(() => {
idToken = makeJWT({ exp: '200', iat: '100' });
reloadMock = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [{ uid: 'uid' }]
});
signInMock = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, {
idToken,
refreshToken: 'refresh-token'
});
credential = PhoneAuthCredential._fromVerification(
'session-info',
'code'
);
user = testUser(auth, 'uid', 'email', true);
});
it('should link the phone number to the user', async () => {
await updatePhoneNumber(user, credential);
expect(signInMock.calls[0].request).to.eql({
idToken: 'access-token',
sessionInfo: 'session-info',
code: 'code'
});
});
it('should update the access token', async () => {
await updatePhoneNumber(user, credential);
const idToken = await user.getIdToken();
expect(idToken).to.eq(idToken);
});
it('should reload the user', async () => {
await updatePhoneNumber(user, credential);
expect(reloadMock.calls.length).to.eq(1);
});
});
});