From 581fef33bc425844b274ed36e2fc771f9fb7715e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 1 Nov 2026 22:26:26 +1100 Subject: [PATCH] Add playwright test for MFA login flow (#13146) --- src/frontend/tests/baseFixtures.ts | 1 + src/frontend/tests/defaults.ts | 9 ++++ src/frontend/tests/pui_login.spec.ts | 64 +++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index 3d0e7bd00b..17c8b222fa 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -163,6 +163,7 @@ export const test = baseTest.extend({ !url.includes('/api/user/me/token/') && !url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/session') && + !url.includes('/api/auth/v1/auth/2fa/authenticate') && !url.includes('/api/auth/v1/auth/provider/signup') && !url.includes('/api/auth/v1/account/authenticators/totp') && !url.includes('/api/auth/v1/account/password/change') && diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index d806d86a78..07165b53a7 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -44,6 +44,15 @@ export const engineeruser: UserType = { testcred: 'partsonly' }; +/* + * Demo dataset user with a pre-configured TOTP authenticator + */ +export const mfauser: UserType & { totpSecret: string } = { + username: 'mfauser', + testcred: 'mfauserpassword', + totpSecret: 'TFFPZIB4VAOKMNMYWIYVMPGBE24RZIHF' +}; + export const mockOidcPort = 9950; export const mockOidcUrl = `http://localhost:${mockOidcPort}`; diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index 6251aa3029..51fb3b636d 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test'; import { TOTP } from 'otpauth'; import { expect, test } from './baseFixtures.js'; -import { engineeruser, logoutUrl, noaccessuser } from './defaults.js'; +import { engineeruser, logoutUrl, mfauser, noaccessuser } from './defaults.js'; import { navigate, openDetailAction } from './helpers.js'; import { doLogin } from './login.js'; @@ -502,3 +502,65 @@ test('Login - MFA - TOTP', async ({ page }) => { .getByText('No multi-factor tokens configured for this account') .waitFor(); }); + +// Test login for a user with a pre-configured TOTP authenticator +test('Login - MFA - TOTP Login', async ({ page }) => { + await page.context().clearCookies(); + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await page.getByLabel('login-username').fill(mfauser.username); + await page.getByLabel('login-password').fill(mfauser.testcred); + await page.getByRole('button', { name: 'Log In' }).click(); + + // User should be redirected to the MFA page + await page.waitForURL('**/web/mfa'); + await page.getByText('Multi-Factor Authentication').waitFor(); + + const codeInput = page.getByRole('textbox', { name: 'TOTP Code' }); + await codeInput.waitFor(); + + // Enter an incorrect code + await codeInput.fill('ABCDEF'); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.getByText('Incorrect code.').waitFor(); + + // We should still be on the MFA page + await expect(page).toHaveURL(/\/web\/mfa/); + + const totp = new TOTP({ + secret: mfauser.totpSecret, + digits: 6, + period: 30 + }); + + // Codes are single-use per period (server side), and the same user may be + // logging in from another browser project concurrently. + // So, if a code is rejected, wait for the next period and try again. + let lastToken = ''; + + for (let attempt = 0; attempt < 3; attempt++) { + while (totp.generate() == lastToken) { + await page.waitForTimeout(1000); + } + + lastToken = totp.generate(); + + await codeInput.fill(lastToken); + await page.getByRole('button', { name: 'Log in' }).click(); + + const loggedIn = await page + .getByRole('button', { name: 'navigation-menu' }) + .waitFor({ timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (loggedIn) { + break; + } + } + + await page.getByRole('tab', { name: 'Dashboard' }).waitFor(); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + await page.waitForURL(/\/web(\/home)?/); +});