test(e2e): take the TOTP parameters and the field hooks from the source

The spec re-declared the server's algorithm, digit count and period as
literals, so a change to any of them would leave the spec generating codes the
server no longer verifies and blaming the product. It imports the three
exported constants instead; the recovery-batch size stays a literal, because
its module pulls in the database client and would drag the generated client
into the Playwright process.

The two credential fields on the login form are addressed by test id now, like
the submit button beside them, and the recovery toggle at the challenge is
renamed to the house's noun-verb order.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:52:53 +02:00
1 parent 78789e090e
commit 01fe007f35
3 files changed
+15 -8

No files matched your search

+12 -7
View File
@@ -6,6 +6,12 @@ import type {
} from "@playwright/test";
import * as OTPAuth from "otpauth";
import {
TOTP_ALGORITHM,
TOTP_DIGITS,
TOTP_PERIOD_SECONDS,
} from "@/lib/auth/mfa/totp";
import { expect, test } from "./setup/test";
import {
clearAuthRateLimits,
@@ -77,7 +83,6 @@ import {
* current step has too little left — instead of hoping the wall clock is kind.
*/
const TOTP_PERIOD_SECONDS = 30;
const RECOVERY_CODE_COUNT = 10;
const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate";
/** `/api/auth/login`, per IP per 15 minutes (`src/lib/rate-limit.ts`). */
@@ -96,8 +101,8 @@ function totpCodeAt(secretBase32: string, atMs: number): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
}).generate({ timestamp: atMs });
@@ -141,8 +146,8 @@ async function submitPassword(
await clearAuthRateLimits();
await page.goto("/auth/login");
await page.getByTestId("login-use-password").click();
await page.locator("#email").fill(account.username);
await page.locator("#password").fill(account.password);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
const answer = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/login") &&
@@ -394,7 +399,7 @@ test.describe("second factor", () => {
});
await test.step("spends a recovery code once", async () => {
await page.getByTestId("mfa-toggle-recovery").click();
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
@@ -406,7 +411,7 @@ test.describe("second factor", () => {
await submitPassword(page, account);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
await page.getByTestId("mfa-toggle-recovery").click();
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
+2
View File
@@ -438,6 +438,7 @@ export default function LoginPage() {
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
data-testid="login-email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -459,6 +460,7 @@ export default function LoginPage() {
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
data-testid="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
+1 -1
View File
@@ -198,7 +198,7 @@ export function MfaLoginStep({
{hasTotp && (
<button
type="button"
data-testid="mfa-toggle-recovery"
data-testid="mfa-recovery-toggle"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");