fix(auth): close the MFA policy lockout and add self-service enrollment (#531)

Fixes #529 (opened investigating #515).

Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason.

- Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed
- Surface the specific server error on login and on a failed settings save instead of a generic fallback
- Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced
- Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen
- Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED
This commit is contained in:
SnapOtter
2026-07-16 18:07:27 +08:00
committed by GitHub
parent d88999e7a9
commit 190d4c2a00
34 changed files with 1624 additions and 19 deletions
@@ -6,7 +6,7 @@ vi.resetModules();
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["mfa"]);
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
const { buildTestApp, loginAsAdmin, loginAsUser } = await import("../test-server.js");
const { db, schema } = await import("../../../apps/api/src/db/index.js");
import type { TestApp } from "../test-server.js";
@@ -112,6 +112,36 @@ describe("POST /api/auth/mfa/enroll", () => {
const body = JSON.parse(res.body);
expect(body.code).toBe("MFA_ALREADY_ENABLED");
});
it("restarting enrollment after canceling (no verify in between) issues a fresh, working secret instead of 409ing", async () => {
// First attempt: user clicks Enable, sees the QR, then cancels/abandons
// without verifying. This leaves a pending, unverified secret.
await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
// Second attempt: user clicks Enable again later. Must not be a dead
// end requiring an admin reset -- it should just issue a new secret.
const secondEnrollRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(secondEnrollRes.statusCode).toBe(200);
const { uri: secondUri } = JSON.parse(secondEnrollRes.body);
// The new secret is genuinely live: verifying with it actually works.
const code = generateTotpCode(secondUri);
const verifyRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code },
});
expect(verifyRes.statusCode).toBe(200);
});
});
describe("POST /api/auth/mfa/verify", () => {
@@ -244,6 +274,47 @@ describe("POST /api/auth/users/:id/mfa/reset", () => {
});
});
describe("GET /api/auth/session totpEnabled", () => {
afterEach(async () => {
await clearMfaState("admin");
});
it("is false when the user has not enrolled", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.user.totpEnabled).toBe(false);
});
it("is true once the user has completed enrollment", async () => {
const enrollRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const { uri } = JSON.parse(enrollRes.body);
const code = generateTotpCode(uri);
await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code },
});
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: `Bearer ${adminToken}` },
});
const body = JSON.parse(res.body);
expect(body.user.totpEnabled).toBe(true);
});
});
describe("MFA login flow", () => {
let totpUri: string;
@@ -307,3 +378,115 @@ describe("MFA login flow", () => {
expect(body.expiresAt).toBeDefined();
});
});
async function setMfaPolicy(value: "optional" | "admins_only" | "required"): Promise<void> {
await db
.insert(schema.settings)
.values({ key: "mfaPolicy", value })
.onConflictDoUpdate({ target: schema.settings.key, set: { value } });
}
// This is the actual fix for #515/#529: exercises the real /api/auth/login
// route end to end, not a mocked response. Everything else in this repo that
// covers this bug (the license gate on saving the setting, the frontend's
// handling of a stubbed 403) would still pass if this exact backend branch
// were reverted or its response code were renamed.
describe("POST /api/auth/login with mfaPolicy enforcement", () => {
afterEach(async () => {
await setMfaPolicy("optional");
await clearMfaState("admin");
});
it("blocks an unenrolled admin with 403 MFA_ENROLLMENT_REQUIRED when policy is required", async () => {
await setMfaPolicy("required");
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: "Adminpass1" },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.code).toBe("MFA_ENROLLMENT_REQUIRED");
expect(body.token).toBeUndefined();
});
it("blocks an unenrolled admin under an admins_only policy", async () => {
await setMfaPolicy("admins_only");
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: "Adminpass1" },
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).code).toBe("MFA_ENROLLMENT_REQUIRED");
});
it("does not block a non-admin user under an admins_only policy", async () => {
// Ensure the shared test user exists while policy is still permissive.
await loginAsUser(testApp.app);
await setMfaPolicy("admins_only");
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "plainuser", password: "Userpass1" },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).token).toBeDefined();
});
it("does not block anyone when policy is optional", async () => {
await setMfaPolicy("optional");
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: "Adminpass1" },
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).token).toBeDefined();
});
});
// The mirror image of tests/integration/platform/mfa-policy-license-gate.test.ts
// (which proves an UNlicensed instance can't save this policy). This file is
// already licensed (mockEnterpriseFeatures(["mfa"]) above), so it proves the
// gate doesn't also accidentally block a legitimately licensed instance.
describe("PUT /api/v1/settings mfaPolicy (licensed)", () => {
afterEach(async () => {
await setMfaPolicy("optional");
});
it("allows saving required when mfa is licensed", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "required" },
});
expect(res.statusCode).toBe(200);
const check = await testApp.app.inject({
method: "GET",
url: "/api/v1/settings/mfaPolicy",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(JSON.parse(check.body).value).toBe("required");
});
it("allows saving admins_only when mfa is licensed", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "admins_only" },
});
expect(res.statusCode).toBe(200);
});
});
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.resetModules();
const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js");
mockNoEnterprise();
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
import type { TestApp } from "../test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("PUT /api/v1/settings mfaPolicy (no mfa license)", () => {
it("rejects admins_only when mfa is not licensed", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "admins_only" },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.code).toBe("FEATURE_NOT_LICENSED");
});
it("rejects required when mfa is not licensed", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "required" },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.code).toBe("FEATURE_NOT_LICENSED");
});
it("does not persist the rejected value", async () => {
await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "required" },
});
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/settings/mfaPolicy",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
it("still allows setting mfaPolicy back to optional", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "optional" },
});
expect(res.statusCode).toBe(200);
});
it("does not block unrelated settings in the same request", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/settings",
headers: { authorization: `Bearer ${adminToken}` },
payload: { mfaPolicy: "required", sessionIdleTimeoutMinutes: "30" },
});
expect(res.statusCode).toBe(403);
const check = await testApp.app.inject({
method: "GET",
url: "/api/v1/settings/sessionIdleTimeoutMinutes",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(check.statusCode).toBe(404);
});
});
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.hoisted(() => vi.fn().mockResolvedValue({ settings: {} }));
const apiPut = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, apiGet, apiPut };
});
import { AdminSecuritySettings } from "@/components/settings/settings-dialog";
afterEach(() => {
cleanup();
apiGet.mockClear();
apiPut.mockReset();
});
describe("AdminSecuritySettings save errors", () => {
it("shows the server's specific error message when a save is rejected", async () => {
apiPut.mockRejectedValue(new Error("MFA requires an enterprise license"));
render(<AdminSecuritySettings />);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: /save/i }));
const message = await screen.findByText("MFA requires an enterprise license");
expect(message).toHaveClass("text-destructive");
});
it("falls back to a generic message when the save rejects with a non-Error value", async () => {
apiPut.mockRejectedValue("network exploded");
render(<AdminSecuritySettings />);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: /save/i }));
const message = await screen.findByText("Failed to save security settings");
expect(message).toHaveClass("text-destructive");
});
});
+51
View File
@@ -131,6 +131,57 @@ describe("useAuth anonymous happy path", () => {
});
});
describe("useAuth totpEnabled", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.resetModules();
});
it("reflects session.user.totpEnabled when authenticated", async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ authEnabled: true }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: { role: "admin", permissions: [], totpEnabled: true },
}),
});
const { renderHook, act } = await import("@testing-library/react");
const { useAuth } = await import("@/hooks/use-auth");
const { result } = renderHook(() => useAuth());
await act(async () => {});
expect(result.current.totpEnabled).toBe(true);
});
it("defaults to false when the session omits totpEnabled", async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ authEnabled: true }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ user: { role: "admin", permissions: [] } }),
});
const { renderHook, act } = await import("@testing-library/react");
const { useAuth } = await import("@/hooks/use-auth");
const { result } = renderHook(() => useAuth());
await act(async () => {});
expect(result.current.totpEnabled).toBe(false);
});
});
describe("useAuth hasPermission", () => {
beforeEach(() => {
fetchMock.mockReset();
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
import { LoginPage } from "@/pages/login-page";
afterEach(() => {
cleanup();
useAuth.mockReset();
vi.unstubAllGlobals();
});
function renderLoginPage() {
useAuth.mockReturnValue({
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
});
return render(
<MemoryRouter initialEntries={["/login"]}>
<LoginPage />
</MemoryRouter>,
);
}
async function submitLogin() {
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: "admin" } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "correct-password" } });
fireEvent.click(screen.getByRole("button", { name: /^login$/i }));
}
describe("LoginPage error messages", () => {
it("shows the MFA enrollment message when the API returns MFA_ENROLLMENT_REQUIRED", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({
error: "MFA enrollment is required before login",
code: "MFA_ENROLLMENT_REQUIRED",
}),
}),
);
renderLoginPage();
await submitLogin();
await waitFor(() => {
expect(screen.getByText(/multi-factor authentication/i)).toBeInTheDocument();
});
expect(screen.queryByText(/invalid username or password/i)).not.toBeInTheDocument();
});
it("still shows a generic message for a plain invalid-credentials response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: "Invalid credentials" }),
}),
);
renderLoginPage();
await submitLogin();
await waitFor(() => {
expect(screen.getByText(/invalid username or password/i)).toBeInTheDocument();
});
});
});
+288
View File
@@ -0,0 +1,288 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const useAuth = vi.hoisted(() => vi.fn());
const apiPost = vi.hoisted(() => vi.fn());
const copyToClipboard = vi.hoisted(() => vi.fn().mockResolvedValue(true));
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, apiPost };
});
vi.mock("@/lib/utils", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, copyToClipboard };
});
vi.mock("qr-code-styling", () => ({
default: class {
append() {}
update() {}
},
}));
import { TwoFactorSettings } from "@/components/settings/two-factor-settings";
const ENROLL_RESPONSE = {
uri: "otpauth://totp/SnapOtter:admin?secret=JBSWY3DPEHPK3PXP&issuer=SnapOtter",
recoveryCodes: ["aaaa1111", "bbbb2222"],
};
afterEach(() => {
cleanup();
useAuth.mockReset();
apiPost.mockReset();
copyToClipboard.mockClear();
});
describe("TwoFactorSettings", () => {
it("shows the enable button when not enrolled", () => {
useAuth.mockReturnValue({ totpEnabled: false });
render(<TwoFactorSettings />);
expect(
screen.getByRole("button", { name: /enable two-factor authentication/i }),
).toBeInTheDocument();
});
it("shows the disable button when already enrolled", () => {
useAuth.mockReturnValue({ totpEnabled: true });
render(<TwoFactorSettings />);
expect(
screen.getByRole("button", { name: /disable two-factor authentication/i }),
).toBeInTheDocument();
});
it("starts enrollment and shows the QR code, manual secret, and recovery codes", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => {
expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll");
});
expect(screen.getByText("JBSWY3DPEHPK3PXP")).toBeInTheDocument();
expect(screen.getByText("aaaa1111")).toBeInTheDocument();
expect(screen.getByText("bbbb2222")).toBeInTheDocument();
});
it("surfaces the server's error when enrollment is rejected (e.g. unlicensed)", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockRejectedValueOnce(new Error("MFA requires an enterprise license"));
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
expect(await screen.findByText("MFA requires an enterprise license")).toBeInTheDocument();
});
it("falls back to a generic message when enrollment rejects with a non-Error value", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockRejectedValueOnce("network exploded");
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
// Must render a real message, not "undefined" or the raw non-Error value.
expect(await screen.findByText(/failed to save/i)).toBeInTheDocument();
expect(screen.queryByText("network exploded")).not.toBeInTheDocument();
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
});
it("verifies the code and confirms enrollment", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
apiPost.mockResolvedValueOnce({ ok: true });
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "123456" } });
fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i }));
await waitFor(() => {
expect(apiPost).toHaveBeenCalledWith("/auth/mfa/verify", { code: "123456" });
});
expect(await screen.findByText(/is now enabled/i)).toBeInTheDocument();
});
it("shows the server's specific error and stays on the verify step when the code is wrong", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
apiPost.mockRejectedValueOnce(new Error("Invalid TOTP or recovery code"));
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "000000" } });
fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i }));
expect(await screen.findByText("Invalid TOTP or recovery code")).toBeInTheDocument();
// Still on the verify step, not bounced back to the idle "Enable" button.
expect(screen.getByPlaceholderText("000000")).toBeInTheDocument();
});
it("surfaces the server's specific error when verify fails for a reason other than a wrong code", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
apiPost.mockRejectedValueOnce(new Error("Failed to decrypt TOTP secret"));
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "123456" } });
fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i }));
// Must not be mislabeled as a wrong code -- a decryption/config failure
// needs its own diagnosable message, not a generic "invalid code" that
// sends the user into an unwinnable retry loop.
expect(await screen.findByText("Failed to decrypt TOTP secret")).toBeInTheDocument();
expect(screen.queryByText(/invalid code/i)).not.toBeInTheDocument();
});
it("cancels enrollment and returns to the idle view without verifying", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
expect(
screen.getByRole("button", { name: /enable two-factor authentication/i }),
).toBeInTheDocument();
expect(apiPost).toHaveBeenCalledTimes(1);
});
it("disables two-factor auth with a valid code", async () => {
useAuth.mockReturnValue({ totpEnabled: true });
apiPost.mockResolvedValueOnce({ ok: true });
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i }));
// Confirm the disable form actually rendered before reusing the same
// accessible name for the submit button below -- otherwise a broken
// idle-to-disabling transition could resolve the second query to the
// wrong element instead of failing clearly.
const codeInput = await screen.findByPlaceholderText("000000");
fireEvent.change(codeInput, { target: { value: "654321" } });
fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i }));
await waitFor(() => {
expect(apiPost).toHaveBeenCalledWith("/auth/mfa/disable", { code: "654321" });
});
expect(await screen.findByText(/has been disabled/i)).toBeInTheDocument();
});
it("surfaces the server's specific error when disable fails for a reason other than a wrong code", async () => {
useAuth.mockReturnValue({ totpEnabled: true });
apiPost.mockRejectedValueOnce(new Error("Failed to decrypt TOTP secret"));
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i }));
const codeInput = await screen.findByPlaceholderText("000000");
fireEvent.change(codeInput, { target: { value: "654321" } });
fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i }));
expect(await screen.findByText("Failed to decrypt TOTP secret")).toBeInTheDocument();
expect(screen.queryByText(/invalid code/i)).not.toBeInTheDocument();
});
it("copies recovery codes to the clipboard", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.click(screen.getByRole("button", { name: /copy codes/i }));
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith("aaaa1111\nbbbb2222");
});
expect(await screen.findByRole("button", { name: /^copied$/i })).toBeInTheDocument();
});
it("shows an error instead of silently doing nothing when the clipboard write fails", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
copyToClipboard.mockResolvedValueOnce(false);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
fireEvent.click(screen.getByRole("button", { name: /copy codes/i }));
await waitFor(() => expect(copyToClipboard).toHaveBeenCalled());
expect(await screen.findByText(/couldn't copy automatically/i)).toBeInTheDocument();
// Button must not claim success it didn't achieve.
expect(screen.queryByRole("button", { name: /^copied$/i })).not.toBeInTheDocument();
});
it("strips non-digit characters from the verify code as the user types", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
const codeInput = screen.getByPlaceholderText("000000") as HTMLInputElement;
fireEvent.change(codeInput, { target: { value: "12ab34" } });
expect(codeInput.value).toBe("1234");
});
it("keeps the confirm button disabled until the code reaches 6 digits", async () => {
useAuth.mockReturnValue({ totpEnabled: false });
apiPost.mockResolvedValueOnce(ENROLL_RESPONSE);
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i }));
await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"));
const codeInput = screen.getByPlaceholderText("000000");
const confirmButton = screen.getByRole("button", { name: /confirm and enable/i });
fireEvent.change(codeInput, { target: { value: "12345" } });
expect(confirmButton).toBeDisabled();
fireEvent.change(codeInput, { target: { value: "123456" } });
expect(confirmButton).not.toBeDisabled();
});
it("keeps the disable button disabled until the code reaches 6 digits", async () => {
useAuth.mockReturnValue({ totpEnabled: true });
render(<TwoFactorSettings />);
fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i }));
const codeInput = await screen.findByPlaceholderText("000000");
const submitButtons = screen.getAllByRole("button", {
name: /disable two-factor authentication/i,
});
const submitButton = submitButtons[submitButtons.length - 1];
fireEvent.change(codeInput, { target: { value: "9999" } });
expect(submitButton).toBeDisabled();
fireEvent.change(codeInput, { target: { value: "999999" } });
expect(submitButton).not.toBeDisabled();
});
});