mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
// @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();
|
|
});
|
|
});
|