mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Fixes #533, found while working on #529/#531. OIDC and SAML logins hard-blocked on the MFA policy with zero check of whether the user actually enrolled TOTP, and no challenge step at all. Once an admin turned on an MFA-required policy, every SSO user was permanently locked out regardless of enrollment status. - Extract the post-auth MFA decision (challenge / enrollment-required / proceed) into a shared, unit-tested function so OIDC and SAML can't independently diverge again - An already-enrolled user now gets a real challenge (reusing the existing, auth-method-agnostic MFA completion flow) instead of being blocked - An unenrolled user under a required policy gets a distinct, correctly mapped error instead of the old generic one - Fix a real fail-open regression caught in review: a transient DB error during the enrollment-status check could have silently skipped MFA entirely for an enrolled user; now it fails closed and logs - Strip the one-time challenge token from the URL after consuming it
80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
import { MemoryRouter, useLocation } 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();
|
|
});
|
|
|
|
function LocationProbe({ onChange }: { onChange: (search: string) => void }) {
|
|
const location = useLocation();
|
|
onChange(location.search);
|
|
return null;
|
|
}
|
|
|
|
function renderLoginPage(path: string, onLocationChange?: (search: string) => void) {
|
|
useAuth.mockReturnValue({
|
|
oidcEnabled: true,
|
|
oidcProviderName: "Test IdP",
|
|
samlEnabled: false,
|
|
samlProviderName: null,
|
|
ssoEnforced: false,
|
|
});
|
|
return render(
|
|
<MemoryRouter initialEntries={[path]}>
|
|
{onLocationChange && <LocationProbe onChange={onLocationChange} />}
|
|
<LoginPage />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
describe("LoginPage OIDC/SAML MFA redirect handling", () => {
|
|
it("shows the TOTP prompt automatically when redirected back with an mfaToken", () => {
|
|
renderLoginPage("/login?mfaToken=abc-123");
|
|
expect(screen.getByText(/enter your authentication code/i)).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText("000000")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the enrollment-required message for the mfa_enrollment_required error code", () => {
|
|
renderLoginPage("/login?error=mfa_enrollment_required");
|
|
expect(screen.getByText(/multi-factor authentication/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show the TOTP prompt for an empty mfaToken param", () => {
|
|
renderLoginPage("/login?mfaToken=");
|
|
expect(screen.queryByPlaceholderText("000000")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("prioritizes an mfaToken over a simultaneous error param", () => {
|
|
renderLoginPage("/login?mfaToken=abc-123&error=oidc_auth_failed");
|
|
expect(screen.getByText(/enter your authentication code/i)).toBeInTheDocument();
|
|
expect(screen.queryByText(/authentication error/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("strips mfaToken from the URL after consuming it", async () => {
|
|
let currentSearch = "";
|
|
renderLoginPage("/login?mfaToken=abc-123", (search) => {
|
|
currentSearch = search;
|
|
});
|
|
await waitFor(() => expect(currentSearch).toBe(""));
|
|
});
|
|
|
|
it("strips the error param from the URL after consuming it", async () => {
|
|
let currentSearch = "";
|
|
renderLoginPage("/login?error=mfa_enrollment_required", (search) => {
|
|
currentSearch = search;
|
|
});
|
|
await waitFor(() => expect(currentSearch).toBe(""));
|
|
});
|
|
});
|