mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Add 2FA persist e2e test (#1707)
This commit is contained in:
committed by
Leendert de Borst
parent
6823c20533
commit
0d8aebd3c5
156
apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts
Normal file
156
apps/browser-extension/tests/e2e/10-two-factor-state.spec.ts
Normal file
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<string> {
|
||||
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<TestUser> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user