import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { AuthConfigResponse } from "@mosaic/shared"; import LoginPage from "./page"; /* ------------------------------------------------------------------ */ /* Hoisted mocks */ /* ------------------------------------------------------------------ */ const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } = vi.hoisted(() => ({ mockOAuth2: vi.fn(), mockSignInEmail: vi.fn(), mockPush: vi.fn(), mockReplace: vi.fn(), mockSearchParams: new URLSearchParams(), })); const { mockRefreshSession, mockIsAuthenticated } = vi.hoisted(() => ({ mockRefreshSession: vi.fn(), mockIsAuthenticated: false, })); vi.mock("next/navigation", () => ({ useRouter: (): { push: Mock; replace: Mock } => ({ push: mockPush, replace: mockReplace, }), useSearchParams: (): URLSearchParams => mockSearchParams, })); vi.mock("@/lib/auth-client", () => ({ signIn: { oauth2: mockOAuth2, email: mockSignInEmail, }, })); vi.mock("@/lib/config", () => ({ API_BASE_URL: "http://localhost:3001", IS_MOCK_AUTH_MODE: false, })); vi.mock("@/lib/auth/auth-context", () => ({ useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({ isAuthenticated: mockIsAuthenticated, refreshSession: mockRefreshSession, }), })); // Mock fetchWithRetry to behave like fetch for test purposes const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn(), })); vi.mock("@/lib/auth/fetch-with-retry", () => ({ fetchWithRetry: mockFetchWithRetry, })); // Use real parseAuthError implementation — vi.mock required for module resolution in vitest vi.mock("@/lib/auth/auth-errors", async () => { const actual = await import("../../../lib/auth/auth-errors"); return { ...actual }; }); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function mockFetchConfig(config: AuthConfigResponse): void { mockFetchWithRetry.mockResolvedValueOnce({ ok: true, json: (): Promise => Promise.resolve(config), }); } function mockFetchFailure(): void { mockFetchWithRetry.mockRejectedValueOnce(new Error("Network error")); } const OAUTH_ONLY_CONFIG: AuthConfigResponse = { providers: [{ id: "authentik", name: "Authentik", type: "oauth" }], }; const EMAIL_ONLY_CONFIG: AuthConfigResponse = { providers: [{ id: "email", name: "Email", type: "credentials" }], }; const BOTH_PROVIDERS_CONFIG: AuthConfigResponse = { providers: [ { id: "authentik", name: "Authentik", type: "oauth" }, { id: "email", name: "Email", type: "credentials" }, ], }; /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe("LoginPage", (): void => { beforeEach((): void => { vi.clearAllMocks(); // Reset search params to empty for each test mockSearchParams.delete("error"); // Default: OAuth2 returns a resolved promise (fire-and-forget redirect) mockOAuth2.mockResolvedValue(undefined); mockRefreshSession.mockResolvedValue(undefined); }); it("renders loading state initially", (): void => { // Never resolve fetch so it stays in loading state // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {})); render(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect(screen.getByText("Loading authentication options")).toBeInTheDocument(); }); it("renders the page heading and description", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center"); expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument(); }); it("has proper layout styling", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const main = container.querySelector("main"); expect(main).toHaveClass("flex", "min-h-screen"); }); it("fetches /auth/config on mount using fetchWithRetry", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(mockFetchWithRetry).toHaveBeenCalledWith("http://localhost:3001/auth/config"); }); }); it("renders OAuth button when OIDC provider is in config", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); }); it("renders only LoginForm when only email provider is configured", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); }); it("renders both OAuth button and LoginForm with divider when both providers present", async (): Promise => { mockFetchConfig(BOTH_PROVIDERS_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); expect(screen.getByText(/or continue with/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); it("does not render divider when only OAuth providers present", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); // The divider element should not appear (no credentials provider) const dividerTexts = screen.queryAllByText(/or continue with/i); // OAuthButton text contains "Continue with" so filter for the divider specifically const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with"); expect(dividerOnly).toHaveLength(0); }); it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise => { mockFetchFailure(); render(); await waitFor((): void => { expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); }); // Should NOT silently fall back to email form expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument(); // Should show the error banner with helpful message expect( screen.getByText( "Unable to load sign-in options. Please refresh the page or try again in a moment." ) ).toBeInTheDocument(); // Should show a retry button expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); }); it("shows error state on non-ok response", async (): Promise => { mockFetchWithRetry.mockResolvedValueOnce({ ok: false, status: 500, }); render(); await waitFor((): void => { expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); }); // Should NOT silently fall back to email form expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); // Should show retry button expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); }); it("retry button triggers re-fetch and recovers on success", async (): Promise => { // First attempt: failure mockFetchFailure(); render(); await waitFor((): void => { expect(screen.getByTestId("config-error-state")).toBeInTheDocument(); }); // Set up the second fetch to succeed mockFetchConfig(EMAIL_ONLY_CONFIG); const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: /try again/i })); // Should eventually load the config and show the login form await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); // Error state should be gone expect(screen.queryByTestId("config-error-state")).not.toBeInTheDocument(); // fetchWithRetry should have been called twice (initial + retry) expect(mockFetchWithRetry).toHaveBeenCalledTimes(2); }); it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); await user.click(screen.getByRole("button", { name: /continue with authentik/i })); expect(mockOAuth2).toHaveBeenCalledWith({ providerId: "authentik", callbackURL: "http://localhost:3000/", }); }); it("shows error when OAuth sign-in fails", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); mockOAuth2.mockRejectedValueOnce(new Error("Provider unavailable")); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); await user.click(screen.getByRole("button", { name: /continue with authentik/i })); await waitFor((): void => { expect( screen.getByText("Unable to connect to the sign-in provider. Please try again in a moment.") ).toBeInTheDocument(); }); }); it("calls signIn.email and redirects on success", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { expect(mockSignInEmail).toHaveBeenCalledWith({ email: "test@example.com", password: "password123", }); }); await waitFor((): void => { expect(mockPush).toHaveBeenCalledWith("/tasks"); }); }); it("sanitizes BetterAuth error messages through parseAuthError", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ error: { message: "Invalid credentials" }, }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); // Raw "Invalid credentials" is mapped through parseAuthError to a PDA-friendly message await waitFor((): void => { expect( screen.getByText("Authentication didn't complete. Please try again when ready.") ).toBeInTheDocument(); }); expect(mockPush).not.toHaveBeenCalled(); }); it("maps raw DB/server errors to PDA-friendly messages instead of leaking details", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); // Simulate a leaked internal server error from BetterAuth mockSignInEmail.mockResolvedValueOnce({ error: { message: "Internal server error: connection to DB pool exhausted" }, }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); // parseAuthError maps "internal server" keyword to server_error PDA-friendly message await waitFor((): void => { expect( screen.getByText("The service is taking a break. Please try again in a moment.") ).toBeInTheDocument(); }); expect(mockPush).not.toHaveBeenCalled(); }); it("should show fallback PDA-friendly message when error.message is not a string", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); // Return an error object where message is NOT a string (e.g. numeric code, no message field) mockSignInEmail.mockResolvedValueOnce({ error: { code: 123 }, }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); // When error.message is falsy, parseAuthError receives the raw error object // which falls through to the "unknown" code PDA-friendly message await waitFor((): void => { expect( screen.getByText("Authentication didn't complete. Please try again when ready.") ).toBeInTheDocument(); }); expect(mockPush).not.toHaveBeenCalled(); }); it("shows parseAuthError message on unexpected sign-in exception", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockRejectedValueOnce(new TypeError("Failed to fetch")); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password"); await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { // parseAuthError maps TypeError("Failed to fetch") to network_error message expect( screen.getByText("Unable to connect. Check your network and try again.") ).toBeInTheDocument(); }); }); /* ------------------------------------------------------------------ */ /* Responsive layout tests */ /* ------------------------------------------------------------------ */ describe("responsive layout", (): void => { it("applies AuthShell layout classes to main element", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const main = container.querySelector("main"); expect(main).toHaveClass("min-h-screen", "items-center", "justify-center"); }); it("applies responsive text size to heading", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const heading = screen.getByRole("heading", { level: 1 }); expect(heading).toHaveClass("text-xl", "sm:text-2xl"); }); it("AuthCard applies card styling with padding", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); // AuthCard uses rounded-2xl and p-6 sm:p-10 const card = container.querySelector(".rounded-2xl"); expect(card).toHaveClass("p-6", "sm:p-10"); }); it("AuthShell constrains card width", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); // AuthShell wraps children in max-w-[27rem] const wrapper = container.querySelector(".max-w-\\[27rem\\]"); expect(wrapper).toHaveClass("w-full"); }); }); /* ------------------------------------------------------------------ */ /* Accessibility tests */ /* ------------------------------------------------------------------ */ describe("accessibility", (): void => { it("loading spinner has role=status", (): void => { // Never resolve fetch so it stays in loading state // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise mockFetchWithRetry.mockReturnValueOnce(new Promise(() => {})); render(); const spinner = screen.getByTestId("loading-spinner"); expect(spinner).toHaveAttribute("role", "status"); expect(spinner).toHaveAttribute("aria-label", "Loading authentication options"); }); it("form inputs have associated labels", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); expect(emailInput).toHaveAttribute("id", "login-email"); expect(passwordInput).toHaveAttribute("id", "login-password"); }); it("error banner has role=alert and aria-live", async (): Promise => { mockSearchParams.set("error", "access_denied"); mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); const alert = screen.getByRole("alert"); expect(alert).toHaveAttribute("aria-live", "polite"); }); it("dismiss button has descriptive aria-label", async (): Promise => { mockSearchParams.set("error", "access_denied"); mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); const dismissButton = screen.getByRole("button", { name: /dismiss/i }); expect(dismissButton).toHaveAttribute("aria-label", "Dismiss"); }); it("interactive elements are keyboard-accessible (tab order)", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); // LoginForm auto-focuses the email input on mount await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toHaveFocus(); }); // Tab forward through form: email -> password -> submit await user.tab(); expect(screen.getByLabelText(/password/i)).toHaveFocus(); await user.tab(); expect(screen.getByRole("button", { name: /continue/i })).toHaveFocus(); // All interactive elements are reachable via keyboard const oauthButton = screen.queryByRole("button", { name: /continue with/i }); // No OAuth button in email-only config, but verify all form elements have tabindex >= 0 expect(oauthButton).not.toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).not.toHaveAttribute("tabindex", "-1"); expect(screen.getByLabelText(/password/i)).not.toHaveAttribute("tabindex", "-1"); }); it("OAuth button has descriptive aria-label", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); render(); await waitFor((): void => { expect( screen.getByRole("button", { name: /continue with authentik/i }) ).toBeInTheDocument(); }); const oauthButton = screen.getByRole("button", { name: /continue with authentik/i }); expect(oauthButton).toHaveAttribute("aria-label", "Continue with Authentik"); }); }); /* ------------------------------------------------------------------ */ /* URL error param tests */ /* ------------------------------------------------------------------ */ describe("URL error query param", (): void => { it("shows PDA-friendly banner for ?error=access_denied", async (): Promise => { mockSearchParams.set("error", "access_denied"); mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect( screen.getByText("Authentication paused. Please try again when ready.") ).toBeInTheDocument(); }); expect(mockReplace).toHaveBeenCalledWith("/login"); }); it("shows correct message for ?error=invalid_credentials", async (): Promise => { mockSearchParams.set("error", "invalid_credentials"); mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect( screen.getByText("The email and password combination wasn't recognized.") ).toBeInTheDocument(); }); }); it("shows default message for unknown error code", async (): Promise => { mockSearchParams.set("error", "some_unknown_code"); mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect( screen.getByText("Authentication didn't complete. Please try again when ready.") ).toBeInTheDocument(); }); }); it("error banner is dismissible", async (): Promise => { mockSearchParams.set("error", "access_denied"); mockFetchConfig(EMAIL_ONLY_CONFIG); const user = userEvent.setup(); render(); await waitFor((): void => { expect( screen.getByText("Authentication paused. Please try again when ready.") ).toBeInTheDocument(); }); // Clear the mock search params so the effect doesn't re-set the error on re-render mockSearchParams.delete("error"); await user.click(screen.getByRole("button", { name: /dismiss/i })); await waitFor((): void => { expect( screen.queryByText("Authentication paused. Please try again when ready.") ).not.toBeInTheDocument(); }); }); it("does not show error banner when no ?error param is present", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); });