test(e2e): cover the second-factor journey end to end

Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.

Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.

Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.

The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.

Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:53:11 +02:00
1 parent 1d372df3a0
commit 50a65b6f80
5 files changed
+553 -1

No files matched your search

+2
View File
@@ -424,6 +424,7 @@ export default function LoginPage() {
{mode === "passkey" ? (
<Button
variant="outline"
data-testid="login-use-password"
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
@@ -472,6 +473,7 @@ export default function LoginPage() {
</div>
<Button
type="submit"
data-testid="login-password-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
+4
View File
@@ -129,6 +129,7 @@ export function MfaLoginStep({
</Label>
<Input
id={codeFieldId}
data-testid="mfa-code-input"
value={code}
onChange={(e) =>
setCode(
@@ -148,6 +149,7 @@ export function MfaLoginStep({
</div>
<Button
type="submit"
data-testid="mfa-code-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading || code.trim().length < 6}
@@ -196,6 +198,7 @@ export function MfaLoginStep({
{hasTotp && (
<button
type="button"
data-testid="mfa-toggle-recovery"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");
@@ -212,6 +215,7 @@ export function MfaLoginStep({
{error && (
<div
id={errorId}
data-testid="mfa-error"
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -197,7 +197,7 @@ export function TotpCard({
}
return (
<SettingsCard>
<SettingsCard data-testid="totp-card">
<SettingsCardHeader
icon={Smartphone}
title={t("settings.security.totp.title")}
@@ -216,6 +216,7 @@ export function TotpCard({
{!enabled && !setup && !freshCodes && (
<Button
type="button"
data-testid="totp-setup-start"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate()}
disabled={beginSetup.isPending}
@@ -291,6 +292,7 @@ export function TotpCard({
<div className="flex gap-2">
<Input
id={codeFieldId}
data-testid="totp-confirm-code"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
@@ -303,6 +305,7 @@ export function TotpCard({
/>
<Button
type="submit"
data-testid="totp-confirm-submit"
className="min-h-11 sm:min-h-9"
disabled={confirm.isPending || code.length !== 6}
>
@@ -330,6 +333,7 @@ export function TotpCard({
<SettingsCardActions>
<Button
type="button"
data-testid="recovery-codes-dismiss"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setFreshCodes(null)}
@@ -356,6 +360,7 @@ export function TotpCard({
<AlertDialogTrigger asChild>
<Button
type="button"
data-testid="recovery-regenerate"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={regenerate.isPending}
@@ -378,6 +383,7 @@ export function TotpCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-testid="recovery-regenerate-confirm"
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
onClick={(e) => {
@@ -479,6 +485,7 @@ export function TotpCard({
{error && (
<div
role="alert"
data-testid="totp-error"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />