Files
phaseflow/src/app/login/page.test.tsx
Petru Paler 00d902a396 Implement OIDC authentication with Pocket-ID (P2.18)
Add OIDC/OAuth2 authentication support to the login page with automatic
provider detection and email/password fallback.

Features:
- Auto-detect OIDC provider via PocketBase listAuthMethods() API
- Display "Sign In with Pocket-ID" button when OIDC is configured
- Use PocketBase authWithOAuth2() popup-based OAuth2 flow
- Fall back to email/password form when OIDC not available
- Loading states during authentication
- Error handling with user-friendly messages

The implementation checks for available auth methods on page load and
conditionally renders either the OIDC button or the email/password form.
This allows production deployments to use OIDC while development
environments can continue using email/password.

Tests: 24 tests (10 new OIDC tests added)
- OIDC button rendering when provider configured
- OIDC authentication flow with authWithOAuth2
- Loading and error states for OIDC
- Fallback to email/password when OIDC unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:12:29 +00:00

545 lines
16 KiB
TypeScript

// ABOUTME: Unit tests for the Login page component.
// ABOUTME: Tests form rendering, validation, auth flow, and error handling.
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Mock PocketBase
const mockAuthWithPassword = vi.fn();
const mockAuthWithOAuth2 = vi.fn();
const mockListAuthMethods = vi.fn();
vi.mock("@/lib/pocketbase", () => ({
pb: {
collection: () => ({
authWithPassword: mockAuthWithPassword,
authWithOAuth2: mockAuthWithOAuth2,
listAuthMethods: mockListAuthMethods,
}),
},
}));
import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no OIDC configured, show email/password form
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
});
describe("rendering", () => {
it("renders the login form with email and password inputs", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("renders a sign in button", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in/i }),
).toBeInTheDocument();
});
});
it("renders the PhaseFlow branding", () => {
render(<LoginPage />);
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
});
it("has email input with type email", async () => {
render(<LoginPage />);
await waitFor(() => {
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute("type", "email");
});
});
it("has password input with type password", async () => {
render(<LoginPage />);
await waitFor(() => {
const passwordInput = screen.getByLabelText(/password/i);
expect(passwordInput).toHaveAttribute("type", "password");
});
});
});
describe("form submission", () => {
it("calls PocketBase auth with email and password on submit", async () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledWith(
"test@example.com",
"password123",
);
});
});
it("redirects to dashboard on successful login", async () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("shows loading state while authenticating", async () => {
// Create a promise that we can control
let resolveAuth: (value: unknown) => void = () => {};
const authPromise = new Promise((resolve) => {
resolveAuth = resolve;
});
mockAuthWithPassword.mockReturnValue(authPromise);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
// Button should show loading state
await waitFor(() => {
expect(
screen.getByRole("button", { name: /signing in/i }),
).toBeInTheDocument();
});
// Resolve the auth
resolveAuth({ token: "test-token" });
});
it("disables form inputs while loading", async () => {
let resolveAuth: (value: unknown) => void = () => {};
const authPromise = new Promise((resolve) => {
resolveAuth = resolve;
});
mockAuthWithPassword.mockReturnValue(authPromise);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(emailInput).toBeDisabled();
expect(passwordInput).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});
resolveAuth({ token: "test-token" });
});
});
describe("error handling", () => {
it("shows error message on failed login", async () => {
mockAuthWithPassword.mockRejectedValueOnce(
new Error("Invalid credentials"),
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
});
it("clears error when user starts typing again", async () => {
mockAuthWithPassword.mockRejectedValueOnce(
new Error("Invalid credentials"),
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// Start typing again
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("re-enables form after error", async () => {
mockAuthWithPassword.mockRejectedValueOnce(
new Error("Invalid credentials"),
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// Form should be re-enabled
expect(emailInput).not.toBeDisabled();
expect(passwordInput).not.toBeDisabled();
expect(
screen.getByRole("button", { name: /sign in/i }),
).not.toBeDisabled();
});
});
describe("validation", () => {
it("does not submit with empty email", async () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
// Should not call auth
expect(mockAuthWithPassword).not.toHaveBeenCalled();
});
it("does not submit with empty password", async () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
// Should not call auth
expect(mockAuthWithPassword).not.toHaveBeenCalled();
});
});
describe("OIDC authentication", () => {
beforeEach(() => {
// Configure OIDC provider available
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Pocket-ID",
state: "test-state",
codeVerifier: "test-verifier",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
authURL: "https://id.example.com/auth",
},
],
},
});
});
it("shows OIDC button when provider is configured", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
});
it("hides email/password form when OIDC is available", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
// Email/password form should not be visible
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
});
it("calls authWithOAuth2 when OIDC button is clicked", async () => {
mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: "oidc" });
});
});
it("redirects to dashboard on successful OIDC login", async () => {
mockAuthWithOAuth2.mockResolvedValueOnce({
token: "test-token",
record: { id: "user-123" },
});
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("shows loading state during OIDC authentication", async () => {
let resolveAuth: (value: unknown) => void = () => {};
const authPromise = new Promise((resolve) => {
resolveAuth = resolve;
});
mockAuthWithOAuth2.mockReturnValue(authPromise);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /signing in/i }),
).toBeInTheDocument();
expect(screen.getByRole("button")).toBeDisabled();
});
resolveAuth({ token: "test-token" });
});
it("shows error message on OIDC failure", async () => {
mockAuthWithOAuth2.mockRejectedValueOnce(
new Error("Authentication cancelled"),
);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/authentication cancelled/i),
).toBeInTheDocument();
});
});
it("re-enables OIDC button after error", async () => {
mockAuthWithOAuth2.mockRejectedValueOnce(
new Error("Authentication cancelled"),
);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// Button should be re-enabled
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).not.toBeDisabled();
});
});
describe("fallback to email/password", () => {
it("shows email/password form when OIDC is not configured", async () => {
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("shows email/password form when listAuthMethods fails", async () => {
mockListAuthMethods.mockRejectedValue(new Error("Network error"));
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("does not show OIDC button when no providers configured", async () => {
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /sign in with pocket-id/i }),
).not.toBeInTheDocument();
});
});
});