0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

test(experience): add tests for totp experience flow (#4619)

This commit is contained in:
Xiao Yijun 2023-10-09 11:44:03 +08:00 committed by GitHub
parent 04ec524e67
commit b68588c0f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 945 additions and 3 deletions

View file

@ -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';

View file

@ -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,

View file

@ -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]);
});
});

View file

@ -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);
});
});
});

View file

@ -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();
});
});
});

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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', 'Cant 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)) ?? '';
}
}