Files
SnapOtter/tests/unit/web/login-page-mfa-error.test.tsx
T
SnapOtterandGitHub 190d4c2a00 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
2026-07-16 18:07:27 +08:00

82 lines
2.2 KiB
TypeScript

// @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();
});
});
});