diff --git a/packages-exp/auth-exp/karma.conf.js b/packages-exp/auth-exp/karma.conf.js index 3b6355af507..9d64f08da1b 100644 --- a/packages-exp/auth-exp/karma.conf.js +++ b/packages-exp/auth-exp/karma.conf.js @@ -36,7 +36,9 @@ function getTestFiles(argv) { if (argv.unit) { return ['src/**/*.test.ts', 'test/helpers/**/*.test.ts']; } else if (argv.integration) { - return ['test/integration/**/*.test.ts']; + return argv.local + ? ['test/integration/**/*.test.ts'] + : ['test/integration/**/*!(local).test.ts']; } else if (argv.cordova) { return ['src/platform_cordova/**/*.test.ts']; } else { diff --git a/packages-exp/auth-exp/scripts/run-node-tests.js b/packages-exp/auth-exp/scripts/run-node-tests.js index c83ddee1a20..ddef213a88e 100644 --- a/packages-exp/auth-exp/scripts/run-node-tests.js +++ b/packages-exp/auth-exp/scripts/run-node-tests.js @@ -1,3 +1,4 @@ +'use strict'; /** * @license * Copyright 2020 Google LLC @@ -13,10 +14,7 @@ * 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'; -var __spreadArrays = + */ var __spreadArrays = (this && this.__spreadArrays) || function () { for (var s = 0, i = 0, il = arguments.length; i < il; i++) @@ -44,6 +42,9 @@ var testConfig = [ ]; if (argv.integration) { testConfig = ['test/integration/flows/{email,anonymous}.test.ts']; + if (argv.local) { + testConfig.push('test/integration/flows/*.local.test.ts'); + } } var args = __spreadArrays(['--reporter', 'lcovonly', mocha], testConfig, [ '--config', diff --git a/packages-exp/auth-exp/scripts/run-node-tests.ts b/packages-exp/auth-exp/scripts/run-node-tests.ts index f3a5eba5a3c..316f6ac7d3c 100644 --- a/packages-exp/auth-exp/scripts/run-node-tests.ts +++ b/packages-exp/auth-exp/scripts/run-node-tests.ts @@ -42,6 +42,9 @@ let testConfig = [ if (argv.integration) { testConfig = ['test/integration/flows/{email,anonymous}.test.ts']; + if (argv.local) { + testConfig.push('test/integration/flows/*.local.test.ts'); + } } let args = [ diff --git a/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts b/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts new file mode 100644 index 00000000000..7cbc4393ae3 --- /dev/null +++ b/packages-exp/auth-exp/test/helpers/integration/emulator_rest_helpers.ts @@ -0,0 +1,59 @@ +/** + * @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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { Auth } from '@firebase/auth-exp'; +import { getApps } from '@firebase/app-exp'; + +interface VerificationSession { + code: string; + phoneNumber: string; + sessionInfo: string; +} + +interface VerificationCodesResponse { + verificationCodes: VerificationSession[]; +} + +export async function getPhoneVerificationCodes( + auth: Auth +): Promise> { + assertEmulator(auth); + const url = getEmulatorUrl(auth, 'verificationCodes'); + const response: VerificationCodesResponse = await (await fetch(url)).json(); + + return response.verificationCodes.reduce((accum, session) => { + accum[session.sessionInfo] = session; + return accum; + }, {} as Record); +} + +function getEmulatorUrl(auth: Auth, endpoint: string): string { + const { host, port, protocol } = auth.emulatorConfig!; + const projectId = getProjectId(auth); + return `${protocol}://${host}:${port}/emulator/v1/projects/${projectId}/${endpoint}`; +} + +function getProjectId(auth: Auth): string { + return getApps().find(app => app.name === auth.name)!.options.projectId!; +} + +function assertEmulator(auth: Auth): void { + if (!auth.emulatorConfig) { + throw new Error("Can't fetch OOB codes against prod API"); + } +} diff --git a/packages-exp/auth-exp/test/helpers/integration/helpers.ts b/packages-exp/auth-exp/test/helpers/integration/helpers.ts index 831179c58b5..4b0b091483d 100644 --- a/packages-exp/auth-exp/test/helpers/integration/helpers.ts +++ b/packages-exp/auth-exp/test/helpers/integration/helpers.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import * as sinon from 'sinon'; import { deleteApp, initializeApp } from '@firebase/app-exp'; import { Auth, User } from '../../../src/model/public_types'; @@ -39,7 +40,9 @@ export function getTestInstance(): Auth { const emulatorUrl = getEmulatorUrl(); if (emulatorUrl) { + const stub = stubConsoleToSilenceEmulatorWarnings(); useAuthEmulator(auth, emulatorUrl, { disableWarnings: true }); + stub.restore(); } auth.onAuthStateChanged(user => { @@ -68,3 +71,16 @@ export async function cleanUpTestInstance(auth: Auth): Promise { await auth.signOut(); await (auth as IntegrationTestAuth).cleanUp(); } + +function stubConsoleToSilenceEmulatorWarnings(): sinon.SinonStub { + const originalConsoleInfo = console.info.bind(console); + return sinon.stub(console, 'info').callsFake((...args: unknown[]) => { + if ( + !JSON.stringify(args[0]).includes( + 'WARNING: You are using the Auth Emulator' + ) + ) { + originalConsoleInfo(...args); + } + }); +} diff --git a/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts b/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts new file mode 100644 index 00000000000..08c7439fb6c --- /dev/null +++ b/packages-exp/auth-exp/test/integration/flows/custom.local.test.ts @@ -0,0 +1,214 @@ +/** + * @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 { + Auth, + createUserWithEmailAndPassword, + EmailAuthProvider, + linkWithCredential, + OperationType, + reload, + signInAnonymously, + signInWithCustomToken, + signInWithEmailAndPassword, + updateEmail, + updatePassword, + updateProfile + // eslint-disable-next-line import/no-extraneous-dependencies +} from '@firebase/auth-exp'; +import { FirebaseError } from '@firebase/util'; +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { + cleanUpTestInstance, + getTestInstance, + randomEmail +} from '../../helpers/integration/helpers'; + +use(chaiAsPromised); + +describe('Integration test: custom auth', () => { + let auth: Auth; + let customToken: string; + let uid: string; + + beforeEach(() => { + auth = getTestInstance(); + uid = randomEmail(); + customToken = JSON.stringify({ + uid, + claims: { + customClaim: 'some-claim' + } + }); + + if (!auth.emulatorConfig) { + throw new Error('Test can only be run against the emulator!'); + } + }); + + afterEach(async () => { + await cleanUpTestInstance(auth); + }); + + it('signs in with custom token', async () => { + const cred = await signInWithCustomToken(auth, customToken); + expect(auth.currentUser).to.eq(cred.user); + expect(cred.operationType).to.eq(OperationType.SIGN_IN); + + const { user } = cred; + expect(user.isAnonymous).to.be.false; + expect(user.uid).to.eq(uid); + expect((await user.getIdTokenResult(false)).claims.customClaim).to.eq( + 'some-claim' + ); + expect(user.providerId).to.eq('firebase'); + }); + + it('uid will overwrite existing user, joining accounts', async () => { + const { user: anonUser } = await signInAnonymously(auth); + const customCred = await signInWithCustomToken( + auth, + JSON.stringify({ + uid: anonUser.uid + }) + ); + + expect(auth.currentUser).to.eq(customCred.user); + expect(customCred.user.uid).to.eq(anonUser.uid); + expect(customCred.user.isAnonymous).to.be.false; + }); + + it('allows the user to delete the account', async () => { + let { user } = await signInWithCustomToken(auth, customToken); + await updateProfile(user, { displayName: 'Display Name' }); + expect(user.displayName).to.eq('Display Name'); + + await user.delete(); + await expect(reload(user)).to.be.rejectedWith( + FirebaseError, + 'auth/user-token-expired' + ); + expect(auth.currentUser).to.be.null; + + ({ user } = await signInWithCustomToken(auth, customToken)); + // New user in the system: the display name should be missing + expect(user.displayName).to.be.null; + }); + + it('sign in can be called twice successively', async () => { + const { user: userA } = await signInWithCustomToken(auth, customToken); + const { user: userB } = await signInWithCustomToken(auth, customToken); + expect(userA.uid).to.eq(userB.uid); + }); + + it('allows user to update profile', async () => { + let { user } = await signInWithCustomToken(auth, customToken); + await updateProfile(user, { + displayName: 'Display Name', + photoURL: 'photo-url' + }); + expect(user.displayName).to.eq('Display Name'); + expect(user.photoURL).to.eq('photo-url'); + + await auth.signOut(); + + user = (await signInWithCustomToken(auth, customToken)).user; + expect(user.displayName).to.eq('Display Name'); + expect(user.photoURL).to.eq('photo-url'); + }); + + context('email/password interaction', () => { + let email: string; + let customToken: string; + + beforeEach(() => { + email = randomEmail(); + customToken = JSON.stringify({ + uid: email + }); + }); + + it('custom / email-password accounts remain independent', async () => { + let customCred = await signInWithCustomToken(auth, customToken); + const emailCred = await createUserWithEmailAndPassword( + auth, + email, + 'password' + ); + expect(emailCred.user.uid).not.to.eql(customCred.user.uid); + + await auth.signOut(); + customCred = await signInWithCustomToken(auth, customToken); + const emailSignIn = await signInWithEmailAndPassword( + auth, + email, + 'password' + ); + expect(emailCred.user.uid).to.eql(emailSignIn.user.uid); + expect(emailSignIn.user.uid).not.to.eql(customCred.user.uid); + }); + + it('account can have email / password attached', async () => { + const { user: customUser } = await signInWithCustomToken( + auth, + customToken + ); + await updateEmail(customUser, email); + await updatePassword(customUser, 'password'); + + await auth.signOut(); + + const { user: emailPassUser } = await signInWithEmailAndPassword( + auth, + email, + 'password' + ); + expect(emailPassUser.uid).to.eq(customUser.uid); + }); + + it('account can be linked using email and password', async () => { + const { user: customUser } = await signInWithCustomToken( + auth, + customToken + ); + const cred = EmailAuthProvider.credential(email, 'password'); + await linkWithCredential(customUser, cred); + await auth.signOut(); + + const { user: emailPassUser } = await signInWithEmailAndPassword( + auth, + email, + 'password' + ); + expect(emailPassUser.uid).to.eq(customUser.uid); + }); + + it('account cannot be linked with existing email/password', async () => { + await createUserWithEmailAndPassword(auth, email, 'password'); + const { user: customUser } = await signInWithCustomToken( + auth, + customToken + ); + const cred = EmailAuthProvider.credential(email, 'password'); + await expect(linkWithCredential(customUser, cred)).to.be.rejectedWith( + FirebaseError, + 'auth/email-already-in-use' + ); + }); + }); +}); diff --git a/packages-exp/auth-exp/test/integration/flows/phone.test.ts b/packages-exp/auth-exp/test/integration/flows/phone.test.ts index 07faeb78477..2fea0a848b2 100644 --- a/packages-exp/auth-exp/test/integration/flows/phone.test.ts +++ b/packages-exp/auth-exp/test/integration/flows/phone.test.ts @@ -30,7 +30,8 @@ import { Auth, OperationType, ProviderId, - UserCredential + UserCredential, + ConfirmationResult // eslint-disable-next-line import/no-extraneous-dependencies } from '@firebase/auth-exp'; import { FirebaseError } from '@firebase/util'; @@ -39,6 +40,7 @@ import { cleanUpTestInstance, getTestInstance } from '../../helpers/integration/helpers'; +import { getPhoneVerificationCodes } from '../../helpers/integration/emulator_rest_helpers'; use(chaiAsPromised); @@ -80,9 +82,23 @@ describe('Integration test: phone auth', () => { document.body.removeChild(fakeRecaptchaContainer); }); + /** If in the emulator, search for the code in the API */ + async function code( + crOrId: ConfirmationResult | string, + fallback: string + ): Promise { + if (auth.emulatorConfig) { + const codes = await getPhoneVerificationCodes(auth); + const vid = typeof crOrId === 'string' ? crOrId : crOrId.verificationId; + return codes[vid].code; + } + + return fallback; + } + it('allows user to sign up', async () => { const cr = await signInWithPhoneNumber(auth, PHONE_A.phoneNumber, verifier); - const userCred = await cr.confirm(PHONE_A.code); + const userCred = await cr.confirm(await code(cr, PHONE_A.code)); expect(auth.currentUser).to.eq(userCred.user); expect(userCred.operationType).to.eq(OperationType.SIGN_IN); @@ -98,7 +114,7 @@ describe('Integration test: phone auth', () => { const { uid: anonId } = user; const cr = await linkWithPhoneNumber(user, PHONE_A.phoneNumber, verifier); - const linkResult = await cr.confirm(PHONE_A.code); + const linkResult = await cr.confirm(await code(cr, PHONE_A.code)); expect(linkResult.operationType).to.eq(OperationType.LINK); expect(linkResult.user.uid).to.eq(user.uid); expect(linkResult.user.phoneNumber).to.eq(PHONE_A.phoneNumber); @@ -128,7 +144,7 @@ describe('Integration test: phone auth', () => { PHONE_A.phoneNumber, verifier ); - signUpCred = await cr.confirm(PHONE_A.code); + signUpCred = await cr.confirm(await code(cr, PHONE_A.code)); resetVerifier(); await auth.signOut(); }); @@ -139,14 +155,14 @@ describe('Integration test: phone auth', () => { PHONE_A.phoneNumber, verifier ); - const signInCred = await cr.confirm(PHONE_A.code); + const signInCred = await cr.confirm(await code(cr, PHONE_A.code)); expect(signInCred.user.uid).to.eq(signUpCred.user.uid); }); it('allows the user to update their phone number', async () => { let cr = await signInWithPhoneNumber(auth, PHONE_A.phoneNumber, verifier); - const { user } = await cr.confirm(PHONE_A.code); + const { user } = await cr.confirm(await code(cr, PHONE_A.code)); resetVerifier(); @@ -158,7 +174,10 @@ describe('Integration test: phone auth', () => { await updatePhoneNumber( user, - PhoneAuthProvider.credential(verificationId, PHONE_B.code) + PhoneAuthProvider.credential( + verificationId, + await code(verificationId, PHONE_B.code) + ) ); expect(user.phoneNumber).to.eq(PHONE_B.phoneNumber); @@ -166,30 +185,37 @@ describe('Integration test: phone auth', () => { resetVerifier(); cr = await signInWithPhoneNumber(auth, PHONE_B.phoneNumber, verifier); - const { user: secondSignIn } = await cr.confirm(PHONE_B.code); + const { user: secondSignIn } = await cr.confirm( + await code(cr, PHONE_B.code) + ); expect(secondSignIn.uid).to.eq(user.uid); }); it('allows the user to reauthenticate with phone number', async () => { let cr = await signInWithPhoneNumber(auth, PHONE_A.phoneNumber, verifier); - const { user } = await cr.confirm(PHONE_A.code); + const { user } = await cr.confirm(await code(cr, PHONE_A.code)); const oldToken = await user.getIdToken(); resetVerifier(); + // Wait a bit to ensure the sign in time is different in the token + await new Promise((resolve): void => { + setTimeout(resolve, 1500); + }); + cr = await reauthenticateWithPhoneNumber( user, PHONE_A.phoneNumber, verifier ); - await cr.confirm(PHONE_A.code); + await cr.confirm(await code(cr, PHONE_A.code)); expect(await user.getIdToken()).not.to.eq(oldToken); }); it('prevents reauthentication with wrong phone number', async () => { let cr = await signInWithPhoneNumber(auth, PHONE_A.phoneNumber, verifier); - const { user } = await cr.confirm(PHONE_A.code); + const { user } = await cr.confirm(await code(cr, PHONE_A.code)); resetVerifier(); @@ -198,7 +224,7 @@ describe('Integration test: phone auth', () => { PHONE_B.phoneNumber, verifier ); - await expect(cr.confirm(PHONE_B.code)).to.be.rejectedWith( + await expect(cr.confirm(await code(cr, PHONE_B.code))).to.be.rejectedWith( FirebaseError, 'auth/user-mismatch' ); @@ -207,7 +233,9 @@ describe('Integration test: phone auth', () => { // reauthenticateWithPhoneNumber does not trigger a state change resetVerifier(); cr = await signInWithPhoneNumber(auth, PHONE_B.phoneNumber, verifier); - const { user: otherUser } = await cr.confirm(PHONE_B.code); + const { user: otherUser } = await cr.confirm( + await code(cr, PHONE_B.code) + ); await otherUser.delete(); }); });