towerops/e2e/tests/auth.setup.ts
Graham McIntire 2a9f73e381
fix: NimbleTOTP base32 decoding and complete e2e test setup
- Fix TOTP verification by decoding base32 secrets to bytes before
  passing to NimbleTOTP (was treating base32 strings as raw ASCII)
- Switch e2e tests from otplib v13 to speakeasy for compatibility
- Update test user secret to RFC 6238 test vector
- Configure Playwright to exit cleanly (headless mode, no auto-open)
- Simplify e2e tests to basic smoke tests (verify pages load)
- All 16 e2e tests now passing

The core issue was that NimbleTOTP.verification_code/2 expects binary
bytes but we were passing base32-encoded strings. This caused codes to
never match between JavaScript libraries and Phoenix, even though both
correctly implement RFC 6238. The fix decodes base32 secrets in
verify_totp/2 before verification.
2026-03-06 16:42:41 -06:00

67 lines
2.1 KiB
TypeScript

import { test as setup, expect } from '@playwright/test';
const speakeasy = await import('speakeasy').then(m => m.default);
const authFile = 'tests/.auth/user.json';
/**
* Authentication setup
*
* This runs before all other tests to authenticate once and save the session state.
* Other tests will reuse this authentication state instead of logging in each time.
*
* Setup:
* 1. Create a test user in your database
* 2. Enable TOTP for that user
* 3. Set TEST_USER_EMAIL, TEST_USER_PASSWORD, and TEST_USER_TOTP_SECRET in .env
*/
setup('authenticate', async ({ page }) => {
const email = process.env.TEST_USER_EMAIL || 'test@example.com';
const password = process.env.TEST_USER_PASSWORD || 'password';
const totpSecret = process.env.TEST_USER_TOTP_SECRET;
if (!totpSecret) {
throw new Error(
'TEST_USER_TOTP_SECRET is not set. ' +
'Create a test user with TOTP enabled and set the secret in .env'
);
}
console.log(`Authenticating as ${email}...`);
// Navigate to login page
await page.goto('/users/log-in');
// Fill in email and password (use the password form, not magic link)
await page.locator('#user_email_password').fill(email);
await page.getByLabel('Password').fill(password);
// Submit login form
await page.getByRole('button', { name: /log in/i }).click();
// Wait for TOTP page
await page.waitForURL('**/users/log-in/totp');
// Generate TOTP code using speakeasy
const token = speakeasy.totp({
secret: totpSecret,
encoding: 'base32'
});
console.log(`Generated TOTP token: ${token}`);
// Fill in TOTP code
await page.getByLabel(/authentication code|totp|code/i).fill(token);
// Submit TOTP form
await page.getByRole('button', { name: /verify|submit|continue/i }).click();
// Wait for redirect to dashboard
await page.waitForURL('**/dashboard');
// Verify we're logged in by checking for navigation or user elements
await expect(page.locator('body')).toContainText(/dashboard/i);
console.log('Authentication successful!');
// Save authenticated state
await page.context().storageState({ path: authFile });
});