Co-authored-by: Jason Woltje <[email protected]> Co-committed-by: Jason Woltje <[email protected]>
705 lines
24 KiB
TypeScript
705 lines
24 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(),
|
|
}));
|
|
|
|
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<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);
|
|
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(<LoginPage />);
|
|
|
|
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
|
expect(screen.getByText("Loading authentication options")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the page heading and description", async (): Promise<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
render(<LoginPage />);
|
|
|
|
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<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
const { container } = render(<LoginPage />);
|
|
|
|
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<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/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();
|
|
});
|
|
|
|
// 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<void> => {
|
|
mockFetchFailure();
|
|
|
|
render(<LoginPage />);
|
|
|
|
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<void> => {
|
|
mockFetchWithRetry.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
});
|
|
|
|
render(<LoginPage />);
|
|
|
|
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<void> => {
|
|
// First attempt: failure
|
|
mockFetchFailure();
|
|
|
|
render(<LoginPage />);
|
|
|
|
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<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: "http://localhost:3000/",
|
|
});
|
|
});
|
|
|
|
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), "[email protected]");
|
|
await user.type(screen.getByLabelText(/password/i), "password123");
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
|
|
await waitFor((): void => {
|
|
expect(mockSignInEmail).toHaveBeenCalledWith({
|
|
email: "[email protected]",
|
|
password: "password123",
|
|
});
|
|
});
|
|
|
|
await waitFor((): void => {
|
|
expect(mockPush).toHaveBeenCalledWith("/tasks");
|
|
});
|
|
});
|
|
|
|
it("sanitizes BetterAuth error messages through parseAuthError", 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), "[email protected]");
|
|
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<void> => {
|
|
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(<LoginPage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
});
|
|
|
|
await user.type(screen.getByLabelText(/email/i), "[email protected]");
|
|
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<void> => {
|
|
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(<LoginPage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
});
|
|
|
|
await user.type(screen.getByLabelText(/email/i), "[email protected]");
|
|
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<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), "[email protected]");
|
|
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<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
const { container } = render(<LoginPage />);
|
|
|
|
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<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
render(<LoginPage />);
|
|
|
|
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<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
const { container } = render(<LoginPage />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// AuthCard uses rounded-b-2xl and p-6 sm:p-10
|
|
const card = container.querySelector(".rounded-b-2xl");
|
|
expect(card).toHaveClass("p-6", "sm:p-10");
|
|
});
|
|
|
|
it("AuthShell constrains card width", async (): Promise<void> => {
|
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
|
|
|
const { container } = render(<LoginPage />);
|
|
|
|
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(<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
|
|
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<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();
|
|
});
|
|
});
|
|
});
|