From 0d8aebd3c5d62472cc7ed8eff5e3eb90ad3e4b94 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 15 Feb 2026 11:11:21 +0100 Subject: [PATCH] Add 2FA persist e2e test (#1707) --- .../tests/e2e/10-two-factor-state.spec.ts | 156 ++++++++++++++++++ .../tests/helpers/test-api.ts | 100 +++++++++++ 2 files changed, 256 insertions(+) create mode 100644 apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts diff --git a/apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts b/apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts new file mode 100644 index 000000000..83cee88a4 --- /dev/null +++ b/apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts @@ -0,0 +1,156 @@ +/** + * Category 10: Two-Factor Authentication State Persistence + * + * These tests verify that the 2FA login state persists when the popup is closed + * and reopened, allowing users to switch to their authenticator app without + * losing their login progress. + * + * Test scenarios: + * 1. 2FA prompt appears after entering valid credentials for 2FA-enabled user + * 2. State persists across popup close/reopen (simulated via page navigation) + * 3. Cancel button clears the state and returns to login form + * 4. Successfully complete login with valid 2FA code + */ +import { test, expect, TestClient } from '../fixtures'; +import { createTestUserWith2FA, generateTotpCode, type TestUser } from '../helpers/test-api'; + +/** + * Helper to check if the 2FA form is visible. + */ +async function isTwoFactorFormVisible(client: TestClient): Promise { + return client.popup.locator('input#twoFactorCode').isVisible({ timeout: 2000 }).catch(() => false); +} + +/** + * Helper to check if the login form is visible. + */ +async function isLoginFormVisible(client: TestClient): Promise { + return client.popup.locator('input#username').isVisible({ timeout: 2000 }).catch(() => false); +} + +/** + * Helper to close and reopen the popup by navigating away and back. + */ +async function reopenPopup(client: TestClient): Promise { + await client.popup.evaluate(() => { + window.location.href = '/popup.html'; + }); + await client.popup.waitForLoadState('networkidle'); + await client.popup.waitForTimeout(500); +} + +test.describe.serial('10. Two-Factor Authentication State', () => { + let client: TestClient; + let twoFactorUser: TestUser; + + test.afterAll(async () => { + await client?.cleanup(); + }); + + test('10.1 should show 2FA form after entering credentials for 2FA-enabled user', async ({ apiUrl }) => { + // Create a test user with 2FA enabled + twoFactorUser = await createTestUserWith2FA(apiUrl); + + client = await TestClient.create(); + await client.configureApiUrl(apiUrl); + + // Verify we start on the login form + const loginVisible = await isLoginFormVisible(client); + expect(loginVisible).toBe(true); + + await client.screenshot('10.1-initial-login-form.png'); + + // Enter credentials for 2FA-enabled user and submit + await client.popup.fill('input#username', twoFactorUser.username); + await client.popup.fill('input#password', twoFactorUser.password); + await client.popup.click('button:has-text("Log in")'); + + // Wait for 2FA form to appear + await client.popup.waitForSelector('input#twoFactorCode', { state: 'visible', timeout: 15000 }); + + // Verify 2FA form is visible + const twoFactorVisible = await isTwoFactorFormVisible(client); + expect(twoFactorVisible).toBe(true); + + await client.screenshot('10.1-2fa-form-visible.png'); + }); + + test('10.2 should persist 2FA state across popup close/reopen', async () => { + // Reopen the popup (simulates closing and reopening) + await reopenPopup(client); + + // Wait for the page to load and check for 2FA form + await client.popup.waitForTimeout(500); + + // Verify 2FA form is still visible (state was restored) + const twoFactorVisible = await isTwoFactorFormVisible(client); + expect(twoFactorVisible).toBe(true); + + // Verify the 2FA code input is present and functional + const codeInput = client.popup.locator('input#twoFactorCode'); + await expect(codeInput).toBeVisible(); + + // Verify the Verify button is present + const verifyButton = client.popup.locator('button:has-text("Verify")'); + await expect(verifyButton).toBeVisible(); + + // Verify the Cancel button is present + const cancelButton = client.popup.locator('button:has-text("Cancel")'); + await expect(cancelButton).toBeVisible(); + + await client.screenshot('10.2-2fa-persisted.png'); + }); + + test('10.3 should clear state when Cancel button is clicked', async () => { + // Ensure we're on the 2FA form + const twoFactorVisible = await isTwoFactorFormVisible(client); + expect(twoFactorVisible).toBe(true); + + // Click the Cancel button + await client.popup.locator('button:has-text("Cancel")').click(); + + // Wait for the form to reset + await client.popup.waitForTimeout(300); + + // Verify we're back on the login form + const loginVisible = await isLoginFormVisible(client); + expect(loginVisible).toBe(true); + + await client.screenshot('10.3-after-cancel.png'); + + // Reopen popup and verify state was cleared + await reopenPopup(client); + + // Should still be on login form (state was cleared) + const stillOnLogin = await isLoginFormVisible(client); + expect(stillOnLogin).toBe(true); + + await client.screenshot('10.3-state-cleared.png'); + }); + + test('10.4 should complete login with valid 2FA code', async () => { + // Enter credentials again + await client.popup.fill('input#username', twoFactorUser.username); + await client.popup.fill('input#password', twoFactorUser.password); + await client.popup.click('button:has-text("Log in")'); + + // Wait for 2FA form + await client.popup.waitForSelector('input#twoFactorCode', { state: 'visible', timeout: 15000 }); + + // Generate a valid TOTP code + const totpCode = generateTotpCode(twoFactorUser.totpSecret!); + + // Enter the 2FA code + await client.popup.fill('input#twoFactorCode', totpCode); + + await client.screenshot('10.4-2fa-code-entered.png'); + + // Submit the 2FA code + await client.popup.click('button:has-text("Verify")'); + + // Wait for successful login (vault should be visible) + await client.popup.getByRole('button', { name: 'Vault' }).waitFor({ state: 'visible', timeout: 15000 }); + + await client.screenshot('10.4-login-successful.png'); + }); +}); diff --git a/apps/browser-extension/tests/helpers/test-api.ts b/apps/browser-extension/tests/helpers/test-api.ts index 487c314fe..eae72ab87 100644 --- a/apps/browser-extension/tests/helpers/test-api.ts +++ b/apps/browser-extension/tests/helpers/test-api.ts @@ -17,6 +17,7 @@ import { join } from 'path'; import argon2 from 'argon2'; import Database from 'better-sqlite3'; +import * as OTPAuth from 'otpauth'; import * as srp from 'secure-remote-password/client.js'; // Get the vault schema SQL from the core vault package @@ -37,6 +38,8 @@ export type TestUser = { username: string; password: string; token?: TokenModel; + /** TOTP secret if 2FA is enabled */ + totpSecret?: string; }; /** @@ -425,3 +428,100 @@ export async function isApiAvailable(apiBaseUrl: string): Promise { return false; } } + +/** + * Enables two-factor authentication for a user. + * + * @param apiBaseUrl - The base URL of the API + * @param token - The authentication token + * @returns The TOTP secret that can be used to generate codes + */ +export async function enableTwoFactor(apiBaseUrl: string, token: string): Promise { + const baseUrl = apiBaseUrl.replace(/\/$/, '') + '/v1/'; + + // Step 1: Enable 2FA to get the secret + const enableResponse = await fetch(`${baseUrl}TwoFactorAuth/enable`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + if (!enableResponse.ok) { + const errorText = await enableResponse.text(); + throw new Error(`Failed to enable 2FA: ${enableResponse.status} ${errorText}`); + } + + const responseJson = await enableResponse.json(); + // Handle both PascalCase (C#) and camelCase (serialization might vary) + const secret = responseJson.Secret || responseJson.secret; + if (!secret) { + throw new Error(`2FA enable response missing secret. Got: ${JSON.stringify(responseJson)}`); + } + + // Step 2: Generate a TOTP code and verify it to complete 2FA setup + const totp = new OTPAuth.TOTP({ + secret: secret, + algorithm: 'SHA1', + digits: 6, + period: 30, + }); + const code = totp.generate(); + + const verifyResponse = await fetch(`${baseUrl}TwoFactorAuth/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(code), + }); + + if (!verifyResponse.ok) { + const errorText = await verifyResponse.text(); + throw new Error(`Failed to verify 2FA: ${verifyResponse.status} ${errorText}`); + } + + return secret; +} + +/** + * Generates a TOTP code from a secret. + * Uses the same otpauth library as the browser extension. + * + * @param secret - The TOTP secret + * @returns A 6-digit TOTP code + */ +export function generateTotpCode(secret: string): string { + const totp = new OTPAuth.TOTP({ + secret: secret, + algorithm: 'SHA1', + digits: 6, + period: 30, + }); + return totp.generate(); +} + +/** + * Creates a test user with 2FA enabled. + * + * @param apiBaseUrl - The base URL of the API + * @returns A TestUser object with credentials, token, and TOTP secret + */ +export async function createTestUserWith2FA(apiBaseUrl: string): Promise { + const username = generateTestUsername(); + const password = generateTestPassword(); + + const token = await registerTestUser(apiBaseUrl, username, password); + + // Enable 2FA for the user + const totpSecret = await enableTwoFactor(apiBaseUrl, token.token); + + return { + username, + password, + token, + totpSecret, + }; +}