Files
stack/apps/web/src/app/(auth)/login/page.test.tsx
Jason Woltje 9696e45265 fix(#411): remediate frontend review findings — wire fetchWithRetry, fix error handling
- Wire fetchWithRetry into login page config fetch (was dead code)
- Remove duplicate ERROR_CODE_MESSAGES, use parseAuthError from auth-errors.ts
- Fix OAuth sign-in fire-and-forget: add .catch() with PDA error + loading reset
- Fix credential login catch: use parseAuthError for better error messages
- Add user feedback when auth config fetch fails (was silent degradation)
- Fix sign-out failure: use logAuthError and set authError state
- Enable fetchWithRetry production logging for retry visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:33:25 -06:00

557 lines
18 KiB
TypeScript

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(),
}));
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",
}));
// 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,
}));
// Mock parseAuthError to use the real implementation
vi.mock("@/lib/auth/auth-errors", async () => {
const actual = await vi.importActual<typeof import("@/lib/auth/auth-errors")>("@/lib/auth/auth-errors");
return actual;
});
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function mockFetchConfig(config: AuthConfigResponse): void {
mockFetchWithRetry.mockResolvedValueOnce({
ok: true,
json: (): Promise<AuthConfigResponse> => 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);
});
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(<LoginPage />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.getByText("Loading authentication options")).toBeInTheDocument();
});
it("renders the page heading and description", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument();
});
it("has proper layout styling", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
const main = container.querySelector("main");
expect(main).toHaveClass("flex", "min-h-screen");
});
it("fetches /auth/config on mount using fetchWithRetry", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
await waitFor((): void => {
expect(mockFetchWithRetry).toHaveBeenCalledWith("http://localhost:3001/auth/config");
});
});
it("renders OAuth button when OIDC provider is in config", async (): Promise<void> => {
mockFetchConfig(OAUTH_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockFetchConfig(BOTH_PROVIDERS_CONFIG);
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
});
expect(screen.getByText(/or continue with email/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<void> => {
mockFetchConfig(OAUTH_ONLY_CONFIG);
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
});
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
});
it("falls back to email-only on fetch failure and shows unavailability message", async (): Promise<void> => {
mockFetchFailure();
render(<LoginPage />);
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();
// Should show the unavailability banner (fix #5)
expect(screen.getByText("Some sign-in options may be temporarily unavailable.")).toBeInTheDocument();
});
it("falls back to email-only on non-ok response", async (): Promise<void> => {
mockFetchWithRetry.mockResolvedValueOnce({
ok: false,
status: 500,
});
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
});
it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise<void> => {
mockFetchConfig(OAUTH_ONLY_CONFIG);
const user = userEvent.setup();
render(<LoginPage />);
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: "/",
});
});
it("shows error when OAuth sign-in fails", async (): Promise<void> => {
mockFetchConfig(OAUTH_ONLY_CONFIG);
mockOAuth2.mockRejectedValueOnce(new Error("Provider unavailable"));
const user = userEvent.setup();
render(<LoginPage />);
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<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } });
const user = userEvent.setup();
render(<LoginPage />);
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("shows error banner on sign-in failure", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
mockSignInEmail.mockResolvedValueOnce({
error: { message: "Invalid credentials" },
});
const user = userEvent.setup();
render(<LoginPage />);
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 }));
await waitFor((): void => {
expect(screen.getByText("Invalid credentials")).toBeInTheDocument();
});
expect(mockPush).not.toHaveBeenCalled();
});
it("shows parseAuthError message on unexpected sign-in exception", async (): Promise<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
mockSignInEmail.mockRejectedValueOnce(new TypeError("Failed to fetch"));
const user = userEvent.setup();
render(<LoginPage />);
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 mobile-first padding to main element", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
const main = container.querySelector("main");
expect(main).toHaveClass("p-4", "sm:p-8");
});
it("applies responsive text size to heading", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
});
it("applies responsive padding to card container", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
const card = container.querySelector(".bg-white");
expect(card).toHaveClass("p-4", "sm:p-8");
});
it("card container has full width with max-width constraint", (): void => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const { container } = render(<LoginPage />);
const wrapper = container.querySelector(".max-w-md");
expect(wrapper).toHaveClass("w-full", "max-w-md");
});
});
/* ------------------------------------------------------------------ */
/* 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(<LoginPage />);
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<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockSearchParams.set("error", "access_denied");
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockSearchParams.set("error", "access_denied");
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
// LoginForm auto-focuses the email input on mount
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<void> => {
mockFetchConfig(OAUTH_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockSearchParams.set("error", "access_denied");
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockSearchParams.set("error", "invalid_credentials");
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
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<void> => {
mockSearchParams.set("error", "some_unknown_code");
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
await waitFor((): void => {
expect(
screen.getByText("Authentication didn't complete. Please try again when ready.")
).toBeInTheDocument();
});
});
it("error banner is dismissible", async (): Promise<void> => {
mockSearchParams.set("error", "access_denied");
mockFetchConfig(EMAIL_ONLY_CONFIG);
const user = userEvent.setup();
render(<LoginPage />);
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<void> => {
mockFetchConfig(EMAIL_ONLY_CONFIG);
render(<LoginPage />);
await waitFor((): void => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
});