From b68588c0f666404c6d018e4d0aca87330b009e05 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 9 Oct 2023 11:44:03 +0800 Subject: [PATCH] test(experience): add tests for totp experience flow (#4619) --- packages/integration-tests/src/constants.ts | 1 + .../src/helpers/sign-in-experience.ts | 2 +- .../mfa/totp/add-missing-profile-flow.test.ts | 242 ++++++++++++++++++ .../mfa/totp/passcode-identifier-flow.test.ts | 186 ++++++++++++++ .../mfa/totp/password-identifier-flow.test.ts | 216 ++++++++++++++++ .../experience/mfa/totp/social-flow.test.ts | 86 +++++++ .../mfa/totp/totp-testing-context.ts | 65 +++++ .../src/ui-helpers/expect-experience.ts | 61 ++++- .../src/ui-helpers/expect-totp-experience.ts | 89 +++++++ 9 files changed, 945 insertions(+), 3 deletions(-) create mode 100644 packages/integration-tests/src/tests/experience/mfa/totp/add-missing-profile-flow.test.ts create mode 100644 packages/integration-tests/src/tests/experience/mfa/totp/passcode-identifier-flow.test.ts create mode 100644 packages/integration-tests/src/tests/experience/mfa/totp/password-identifier-flow.test.ts create mode 100644 packages/integration-tests/src/tests/experience/mfa/totp/social-flow.test.ts create mode 100644 packages/integration-tests/src/tests/experience/mfa/totp/totp-testing-context.ts create mode 100644 packages/integration-tests/src/ui-helpers/expect-totp-experience.ts diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index b3b9dbd4b..19e6b668d 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -24,3 +24,4 @@ export const signUpIdentifiers = { export const consoleUsername = 'admin'; export const consolePassword = 'some_random_password_123'; +export const mockSocialAuthPageUrl = 'http://mock.social.com'; diff --git a/packages/integration-tests/src/helpers/sign-in-experience.ts b/packages/integration-tests/src/helpers/sign-in-experience.ts index 0b744a72d..61fd26375 100644 --- a/packages/integration-tests/src/helpers/sign-in-experience.ts +++ b/packages/integration-tests/src/helpers/sign-in-experience.ts @@ -3,7 +3,7 @@ import { SignInMode, SignInIdentifier, MfaFactor, MfaPolicy } from '@logto/schem import { updateSignInExperience } from '#src/api/index.js'; -const defaultSignUpMethod = { +export const defaultSignUpMethod = { identifiers: [], password: false, verify: false, diff --git a/packages/integration-tests/src/tests/experience/mfa/totp/add-missing-profile-flow.test.ts b/packages/integration-tests/src/tests/experience/mfa/totp/add-missing-profile-flow.test.ts new file mode 100644 index 000000000..7d2d89817 --- /dev/null +++ b/packages/integration-tests/src/tests/experience/mfa/totp/add-missing-profile-flow.test.ts @@ -0,0 +1,242 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { MfaPolicy, MfaFactor, SignInIdentifier } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js'; +import { generateEmail, generatePhone, waitFor } from '#src/utils.js'; + +describe('MFA - TOTP', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]); + await updateSignInExperience({ + mfa: { + policy: MfaPolicy.Mandatory, + factors: [MfaFactor.TOTP], + }, + }); + }); + + it('should add missing password before binding missing TOTP factor', async () => { + await setEmailConnector(); + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + verificationCode: true, + password: true, + isPasswordPrimary: false, + }, + ], + }, + }); + + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', userProfile.primaryEmail, { submit: true }); + await experience.toCompleteVerification('sign-in'); + + // Add missing password + await experience.toFillInput('newPassword', 'l0gt0_T3st_P@ssw0rd', { submit: true }); + + // Bind TOTP + await experience.toBindTotp(); + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + await clearConnectorsByTypes([ConnectorType.Email]); + }); + + it('should add missing phone number before binding missing TOTP factor', async () => { + await setSmsConnector(); + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Phone], + password: true, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Phone, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', userProfile.username, { + submit: true, + }); + await experience.toFillInput('password', userProfile.password, { submit: true }); + + // Add missing phone number + await waitFor(500); + await experience.toFillInput('identifier', generatePhone(), { submit: true }); + await experience.toCompleteVerification('continue'); + + // Bind TOTP + await experience.toBindTotp(); + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + await clearConnectorsByTypes([ConnectorType.Sms]); + }); + + it('should add missing email before binding missing TOTP factor', async () => { + await setEmailConnector(); + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', userProfile.username, { + submit: true, + }); + await experience.toFillInput('password', userProfile.password, { submit: true }); + + // Add missing email number + await waitFor(500); + await experience.toFillInput('identifier', generateEmail(), { submit: true }); + await experience.toCompleteVerification('continue'); + + // Bind TOTP + await experience.toBindTotp(); + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + await clearConnectorsByTypes([ConnectorType.Email]); + }); + + it('should verify required TOTP before adding missing profile', async () => { + /* Bind TOTP */ + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + verificationCode: false, + password: true, + isPasswordPrimary: true, + }, + ], + }, + }); + + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { + identifier: userProfile.username, + password: userProfile.password, + }, + { submit: true } + ); + const bindTotpSecret = await experience.toBindTotp(); + await experience.verifyThenEnd(); + /* Bind TOTP - End */ + + // Verify TOTP before adding missing email + await setEmailConnector(); + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + verificationCode: false, + password: true, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Email, + verificationCode: true, + password: true, + isPasswordPrimary: true, + }, + ], + }, + }); + + const verificationExperience = new ExpectTotpExperience(await browser.newPage()); + await verificationExperience.startWith(demoAppUrl, 'sign-in'); + await verificationExperience.toFillInput('identifier', userProfile.username, { + submit: true, + }); + await verificationExperience.toFillInput('password', userProfile.password, { submit: true }); + + // Verify TOTP + await verificationExperience.toVerifyTotp(bindTotpSecret, false); + + // Add missing email + await verificationExperience.toFillInput('identifier', generateEmail(), { submit: true }); + await verificationExperience.toCompleteVerification('continue'); + // Wait for the page to load + await waitFor(500); + await verificationExperience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + await clearConnectorsByTypes([ConnectorType.Email]); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/mfa/totp/passcode-identifier-flow.test.ts b/packages/integration-tests/src/tests/experience/mfa/totp/passcode-identifier-flow.test.ts new file mode 100644 index 000000000..caaffb4be --- /dev/null +++ b/packages/integration-tests/src/tests/experience/mfa/totp/passcode-identifier-flow.test.ts @@ -0,0 +1,186 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { MfaPolicy, MfaFactor, SignInIdentifier } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { defaultSignUpMethod } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js'; +import { generateEmail, generatePhone } from '#src/utils.js'; + +import TotpTestingContext from './totp-testing-context.js'; + +describe('MFA - TOTP', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]); + await updateSignInExperience({ + signUp: defaultSignUpMethod, + mfa: { + policy: MfaPolicy.Mandatory, + factors: [MfaFactor.TOTP], + }, + }); + }); + + describe('email and verification code', () => { + const context = new TotpTestingContext(); + + beforeAll(async () => { + await setEmailConnector(); + await updateSignInExperience({ + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: false, + verificationCode: true, + isPasswordPrimary: false, + }, + ], + }, + }); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email]); + }); + + it('should bind TOTP when registering', async () => { + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }, + }); + context.setUpUserEmail(generateEmail()); + + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'register'); + await experience.toFillInput('identifier', context.userEmail, { submit: true }); + await experience.toCompleteVerification('register'); + context.setUpTotpSecret(await experience.toBindTotp()); + await experience.verifyThenEnd(); + + // Reset sie settings + await updateSignInExperience({ + signUp: defaultSignUpMethod, + }); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', context.userEmail, { submit: true }); + await experience.toCompleteVerification('sign-in'); + await experience.toVerifyTotp(context.totpSecret); + const userId = await experience.getUserIdFromDemoAppPage(); + await experience.verifyThenEnd(); + + // Clear + await deleteUser(userId); + }); + + it('should bind TOTP if an existing user has no TOTP', async () => { + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', userProfile.primaryEmail, { submit: true }); + await experience.toCompleteVerification('sign-in'); + await experience.toBindTotp(); + await experience.verifyThenEnd(); + // Clean up + await deleteUser(user.id); + }); + }); + + describe('phone and verification code', () => { + const context = new TotpTestingContext(); + + beforeAll(async () => { + await setSmsConnector(); + await updateSignInExperience({ + signIn: { + methods: [ + { + identifier: SignInIdentifier.Phone, + password: false, + verificationCode: true, + isPasswordPrimary: false, + }, + ], + }, + }); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Sms]); + }); + + it('should bind TOTP when registering', async () => { + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Phone], + password: false, + verify: true, + }, + }); + + context.setUpUserPhone( + /** + * Note: exclude the country code from the phone number + * since the default country code and the generated phone number are both '1'. + */ + generatePhone().slice(1) + ); + + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'register'); + await experience.toFillInput('identifier', context.userPhone, { submit: true }); + await experience.toCompleteVerification('register'); + + context.setUpTotpSecret(await experience.toBindTotp()); + + await experience.verifyThenEnd(); + + // Reset sie settings + await updateSignInExperience({ + signUp: defaultSignUpMethod, + }); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', context.userPhone, { submit: true }); + await experience.toCompleteVerification('sign-in'); + await experience.toVerifyTotp(context.totpSecret); + const userId = await experience.getUserIdFromDemoAppPage(); + await experience.verifyThenEnd(); + + // Clear + await deleteUser(userId); + }); + + it('should bind TOTP if an existing user has no TOTP', async () => { + const { userProfile, user } = await generateNewUser({ primaryPhone: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillInput('identifier', userProfile.primaryPhone.slice(1), { + submit: true, + }); + await experience.toCompleteVerification('sign-in'); + await experience.toBindTotp(); + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/mfa/totp/password-identifier-flow.test.ts b/packages/integration-tests/src/tests/experience/mfa/totp/password-identifier-flow.test.ts new file mode 100644 index 000000000..ea657fdea --- /dev/null +++ b/packages/integration-tests/src/tests/experience/mfa/totp/password-identifier-flow.test.ts @@ -0,0 +1,216 @@ +import { ConnectorType, MfaFactor, MfaPolicy, SignInIdentifier } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes } from '#src/helpers/connector.js'; +import { defaultSignUpMethod } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js'; +import { generateUsername } from '#src/utils.js'; + +import TotpTestingContext from './totp-testing-context.js'; + +describe('MFA - TOTP', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]); + await updateSignInExperience({ + signUp: defaultSignUpMethod, + mfa: { + policy: MfaPolicy.Mandatory, + factors: [MfaFactor.TOTP], + }, + }); + }); + + describe('username and password', () => { + const context = new TotpTestingContext(); + + beforeAll(async () => { + await updateSignInExperience({ + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + }); + + it('should bind TOTP when registering', async () => { + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + }); + + context.setUpUsername(generateUsername()); + context.setUpUserPassword('l0gt0_T3st_P@ssw0rd'); + + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'register'); + await experience.toFillInput('identifier', context.username, { submit: true }); + + experience.toBeAt('register/password'); + await experience.toFillNewPasswords(context.userPassword); + context.setUpTotpSecret(await experience.toBindTotp()); + await experience.verifyThenEnd(); + + // Reset sign up settings + await updateSignInExperience({ signUp: defaultSignUpMethod }); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { identifier: context.username, password: context.userPassword }, + { submit: true } + ); + await experience.toVerifyTotp(context.totpSecret); + + const userId = await experience.getUserIdFromDemoAppPage(); + + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(userId); + }); + + it('should bind TOTP if an existing user has no TOTP', async () => { + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + + await experience.toFillForm( + { + identifier: userProfile.username, + password: userProfile.password, + }, + { submit: true } + ); + + await experience.toBindTotp(); + + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(user.id); + }); + }); + + describe('email and password', () => { + const context = new TotpTestingContext(); + + beforeAll(async () => { + await updateSignInExperience({ + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + + // Generate a new user for testing + const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true }); + context.setUpUserId(user.id); + context.setUpUserEmail(userProfile.primaryEmail); + context.setUpUserPassword(userProfile.password); + }); + + afterAll(async () => { + await deleteUser(context.userId); + }); + + it('should bind TOTP if an existing user has no TOTP', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { identifier: context.userEmail, password: context.userPassword }, + { submit: true } + ); + + context.setUpTotpSecret(await experience.toBindTotp()); + await experience.verifyThenEnd(); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { identifier: context.userEmail, password: context.userPassword }, + { submit: true } + ); + await experience.toVerifyTotp(context.totpSecret); + await experience.verifyThenEnd(); + }); + }); + + describe('phone and password', () => { + const context = new TotpTestingContext(); + + beforeAll(async () => { + await updateSignInExperience({ + signIn: { + methods: [ + { + identifier: SignInIdentifier.Phone, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + + // Generate a new user for testing + const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true }); + context.setUpUserId(user.id); + context.setUpUserPhone( + /** + * Note: exclude the country code from the phone number + * since the default country code and the generated phone number are both '1'. + */ + userProfile.primaryPhone.slice(1) + ); + context.setUpUserPassword(userProfile.password); + }); + + afterAll(async () => { + await deleteUser(context.userId); + }); + + it('should bind TOTP if an existing user has no TOTP', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { identifier: context.userPhone, password: context.userPassword }, + { submit: true } + ); + context.setUpTotpSecret(await experience.toBindTotp()); + await experience.verifyThenEnd(); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { identifier: context.userPhone, password: context.userPassword }, + { submit: true } + ); + await experience.toVerifyTotp(context.totpSecret); + await experience.verifyThenEnd(); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/mfa/totp/social-flow.test.ts b/packages/integration-tests/src/tests/experience/mfa/totp/social-flow.test.ts new file mode 100644 index 000000000..5a5fbe79e --- /dev/null +++ b/packages/integration-tests/src/tests/experience/mfa/totp/social-flow.test.ts @@ -0,0 +1,86 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { MfaPolicy, MfaFactor } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; +import { defaultSignUpMethod } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js'; +import { generateUserId } from '#src/utils.js'; + +import TotpTestingContext from './totp-testing-context.js'; + +describe('MFA - TOTP', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]); + await setSocialConnector(); + await updateSignInExperience({ + signUp: defaultSignUpMethod, + mfa: { + policy: MfaPolicy.Mandatory, + factors: [MfaFactor.TOTP], + }, + signIn: { + methods: [], + }, + socialSignInConnectorTargets: ['mock-social'], + }); + }); + + describe('social flow', () => { + const context = new TotpTestingContext(); + + it('should bind TOTP when registering', async () => { + context.setUpSocialUserId(generateUserId()); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toProcessSocialSignIn({ socialUserId: context.socialUserId }); + context.setUpTotpSecret(await experience.toBindTotp()); + await experience.verifyThenEnd(); + }); + + it('should verify TOTP when signing in', async () => { + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toProcessSocialSignIn({ socialUserId: context.socialUserId }); + await experience.toVerifyTotp(context.totpSecret); + const userId = await experience.getUserIdFromDemoAppPage(); + await experience.verifyThenEnd(); + + // Clean up + await deleteUser(userId); + }); + + it('should bind TOTP if an existing user has no TOTP when linking social', async () => { + const socialUserId = generateUserId(); + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toProcessSocialSignIn({ + socialUserId, + socialEmail: userProfile.primaryEmail, + }); + await experience.toClick('button', /Link with/); + await experience.toBindTotp(); + await experience.verifyThenEnd(); + await deleteUser(user.id); + }); + + it('should bind TOTP when registering without determining to link with an existing account', async () => { + const socialUserId = generateUserId(); + const { userProfile, user } = await generateNewUser({ primaryEmail: true }); + const experience = new ExpectTotpExperience(await browser.newPage()); + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toProcessSocialSignIn({ + socialUserId, + socialEmail: userProfile.primaryEmail, + }); + await experience.toClick('button', 'Create account without linking'); + await experience.toBindTotp(); + await experience.verifyThenEnd(); + await deleteUser(user.id); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/mfa/totp/totp-testing-context.ts b/packages/integration-tests/src/tests/experience/mfa/totp/totp-testing-context.ts new file mode 100644 index 000000000..a848b99cb --- /dev/null +++ b/packages/integration-tests/src/tests/experience/mfa/totp/totp-testing-context.ts @@ -0,0 +1,65 @@ +export default class TotpTestingContext { + private _totpSecret = ''; + private _userEmail = ''; + private _userPhone = ''; + private _username = ''; + private _userPassword = ''; + private _userId = ''; + private _socialUserId = ''; + + public setUpTotpSecret(totpSecret: string) { + this._totpSecret = totpSecret; + } + + public setUpUserEmail(email: string) { + this._userEmail = email; + } + + public setUpUserPhone(email: string) { + this._userPhone = email; + } + + public setUpUsername(name: string) { + this._username = name; + } + + public setUpUserPassword(password: string) { + this._userPassword = password; + } + + public setUpUserId(id: string) { + this._userId = id; + } + + public setUpSocialUserId(id: string) { + this._socialUserId = id; + } + + public get totpSecret(): string { + return this._totpSecret; + } + + public get userEmail(): string { + return this._userEmail; + } + + public get userPhone(): string { + return this._userPhone; + } + + public get username(): string { + return this._username; + } + + public get userPassword(): string { + return this._userPassword; + } + + public get userId(): string { + return this._userId; + } + + public get socialUserId(): string { + return this._socialUserId; + } +} diff --git a/packages/integration-tests/src/ui-helpers/expect-experience.ts b/packages/integration-tests/src/ui-helpers/expect-experience.ts index 998158cab..9f1f35413 100644 --- a/packages/integration-tests/src/ui-helpers/expect-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-experience.ts @@ -1,7 +1,9 @@ +import { type MfaFactor } from '@logto/schemas'; import { appendPath } from '@silverhand/essentials'; -import { logtoUrl } from '#src/constants.js'; +import { logtoUrl, mockSocialAuthPageUrl } from '#src/constants.js'; import { readVerificationCode } from '#src/helpers/index.js'; +import { dcls } from '#src/utils.js'; import ExpectPage from './expect-page.js'; @@ -17,7 +19,9 @@ export type ExperiencePath = | `${ExperienceType}/password` | `${ExperienceType}/verify` | `${ExperienceType}/verification-code` - | `forgot-password/reset`; + | `forgot-password/reset` + | `mfa-binding/${MfaFactor.TOTP}` + | `mfa-verification/${MfaFactor.TOTP}`; export type ExpectExperienceOptions = { /** The URL of the experience endpoint. */ @@ -210,6 +214,59 @@ export default class ExpectExperience extends ExpectPage { return this.toMatchAndRemove('div[role=toast]', text); } + /** + * Assert the page is at the sign-in page with the mock social sign-in method. + * Click the 'Mock Social' sign in method and visit the mocked 3rd-party social sign-in page and redirect + * back with the given user social data. + * + * @param socialUserData The given user social data. + */ + async toProcessSocialSignIn({ + socialUserId, + socialEmail, + socialPhone, + }: { + socialUserId: string; + socialEmail?: string; + socialPhone?: string; + }) { + const authPageRequestListener = this.page.waitForRequest((request) => + request.url().startsWith(mockSocialAuthPageUrl) + ); + + await this.toClick('button', 'Continue with Mock Social'); + + const result = await authPageRequestListener; + + const { searchParams: authSearchParams } = new URL(result.url()); + const redirectUri = authSearchParams.get('redirect_uri') ?? ''; + const state = authSearchParams.get('state') ?? ''; + + // Mock social redirects + const callbackUrl = new URL(redirectUri); + callbackUrl.searchParams.set('state', state); + callbackUrl.searchParams.set('code', 'mock-code'); + callbackUrl.searchParams.set('userId', socialUserId); + + if (socialEmail) { + callbackUrl.searchParams.set('email', socialEmail); + } + + if (socialPhone) { + callbackUrl.searchParams.set('phone', socialPhone); + } + + await this.navigateTo(callbackUrl.toString()); + } + + async getUserIdFromDemoAppPage() { + const userIdDiv = await expect(this.page).toMatchElement([dcls('infoCard'), 'div'].join(' '), { + text: 'User ID: ', + }); + const userIdSpan = await expect(userIdDiv).toMatchElement('span'); + return (await userIdSpan.evaluate((element) => element.textContent)) ?? ''; + } + /** Build a full experience URL from a pathname. */ protected buildExperienceUrl(pathname = '') { return appendPath(this.options.endpoint, pathname); diff --git a/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts b/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts new file mode 100644 index 000000000..194ba5fdf --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts @@ -0,0 +1,89 @@ +import { MfaFactor } from '@logto/schemas'; +import { authenticator } from 'otplib'; + +import { demoAppUrl } from '#src/constants.js'; +import { waitFor, dcls } from '#src/utils.js'; + +import ExpectExperience from './expect-experience.js'; + +export default class ExpectTotpExperience extends ExpectExperience { + constructor(thePage = global.page) { + super(thePage); + } + + /** + * Assert the page is at the TOTP binding page and fill the TOTP code + * generated from the TOTP secret. + * + * @param [signingInAfterBinding=true] Whether the flow will continue to sign in after the binding. + * @returns The binding TOTP secret. + */ + async toBindTotp(signingInAfterBinding = true) { + // Wait for the page to load + await waitFor(500); + + this.toBeAt(`mfa-binding/${MfaFactor.TOTP}`); + // Expect the QR code rendered + await expect(this.page).toMatchElement(`${dcls('qrCode')} img[src*="data:image"]`); + + // Wait for 500ms, otherwise the click on the "Can't scan the QR code?" link will not work + await waitFor(500); + await this.toClick('a', 'Can’t scan the QR code?', false); + + const secretDiv = await expect(this.page).toMatchElement(dcls('rawSecret')); + + const secret = (await secretDiv.evaluate((element) => element.textContent)) ?? ''; + + const code = authenticator.generate(secret); + + for (const [index, char] of code.split('').entries()) { + // eslint-disable-next-line no-await-in-loop + await this.toFillInput(`totpCode_${index}`, char); + } + + if (signingInAfterBinding) { + await this.page.waitForSelector('img[alt="Congrats"]'); + } + + return secret; + } + + /** + * Assert the page is at the TOTP verification page and fill the TOTP code + * generated from the TOTP secret. + * + * @param secret The TOTP secret. + * @param [signingInAfterVerification=true] Whether the flow will continue to sign in after the verification. + */ + async toVerifyTotp(secret: string, signingInAfterVerification = true) { + // Wait for the page to load + await waitFor(500); + + this.toBeAt(`mfa-verification/${MfaFactor.TOTP}`); + + const code = authenticator.generate(secret); + + for (const [index, char] of code.split('').entries()) { + // eslint-disable-next-line no-await-in-loop + await this.toFillInput(`totpCode_${index}`, char); + } + + if (signingInAfterVerification) { + await this.page.waitForSelector('img[alt="Congrats"]'); + } + } + + /** + * Assert the page is at the demo app page and get the user ID from the page. + * @returns The user ID. + */ + async getUserIdFromDemoAppPage() { + this.toMatchUrl(demoAppUrl); + const userIdDiv = await expect(this.page).toMatchElement([dcls('infoCard'), 'div'].join(' '), { + text: 'User ID: ', + }); + const userIdSpan = await expect(userIdDiv).toMatchElement('span'); + + return (await userIdSpan.evaluate((element) => element.textContent)) ?? ''; + } +}