logAuthError now always logs (not dev-only). Replaced isBackendError with parseAuthError-based classification. signOut uses proper error type. Session expiry sets explicit session_expired state. Login page logs in prod. Fixed pre-existing lint violations in auth package (campsite rule). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import CallbackPage from "./page";
|
|
|
|
// Mock next/navigation
|
|
const mockPush = vi.fn();
|
|
const mockSearchParams = new Map<string, string>();
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: (): { push: typeof mockPush } => ({
|
|
push: mockPush,
|
|
}),
|
|
useSearchParams: (): { get: (key: string) => string | undefined } => ({
|
|
get: (key: string): string | undefined => mockSearchParams.get(key),
|
|
}),
|
|
}));
|
|
|
|
// Mock auth context
|
|
vi.mock("@/lib/auth/auth-context", () => ({
|
|
useAuth: vi.fn(() => ({
|
|
refreshSession: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
const { useAuth } = await import("@/lib/auth/auth-context");
|
|
|
|
describe("CallbackPage", (): void => {
|
|
beforeEach((): void => {
|
|
mockPush.mockClear();
|
|
mockSearchParams.clear();
|
|
vi.mocked(useAuth).mockReturnValue({
|
|
refreshSession: vi.fn(),
|
|
user: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
authError: null,
|
|
sessionExpiring: false,
|
|
sessionMinutesRemaining: 0,
|
|
signOut: vi.fn(),
|
|
});
|
|
});
|
|
|
|
it("should render processing message", (): void => {
|
|
render(<CallbackPage />);
|
|
expect(screen.getByText(/completing authentication/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("should redirect to tasks page on success", async (): Promise<void> => {
|
|
const mockRefreshSession = vi.fn().mockResolvedValue(undefined);
|
|
vi.mocked(useAuth).mockReturnValue({
|
|
refreshSession: mockRefreshSession,
|
|
user: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
authError: null,
|
|
sessionExpiring: false,
|
|
sessionMinutesRemaining: 0,
|
|
signOut: vi.fn(),
|
|
});
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRefreshSession).toHaveBeenCalled();
|
|
expect(mockPush).toHaveBeenCalledWith("/tasks");
|
|
});
|
|
});
|
|
|
|
it("should redirect to login on error parameter", async (): Promise<void> => {
|
|
mockSearchParams.set("error", "access_denied");
|
|
mockSearchParams.set("error_description", "User cancelled");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/login?error=access_denied");
|
|
});
|
|
});
|
|
|
|
it("should sanitize unknown error codes to prevent open redirect", async (): Promise<void> => {
|
|
// Malicious error parameter that could be used for XSS or redirect attacks
|
|
mockSearchParams.set("error", "<script>alert('xss')</script>");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
// Should replace unknown error with generic authentication_error
|
|
expect(mockPush).toHaveBeenCalledWith("/login?error=authentication_error");
|
|
});
|
|
});
|
|
|
|
it("should sanitize URL-like error codes to prevent open redirect", async (): Promise<void> => {
|
|
// Attacker tries to inject a URL-like value
|
|
mockSearchParams.set("error", "https://evil.com/phishing");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith("/login?error=authentication_error");
|
|
});
|
|
});
|
|
|
|
it("should allow valid OAuth 2.0 error codes", async (): Promise<void> => {
|
|
const validErrors = [
|
|
"access_denied",
|
|
"invalid_request",
|
|
"unauthorized_client",
|
|
"server_error",
|
|
"login_required",
|
|
"consent_required",
|
|
];
|
|
|
|
for (const errorCode of validErrors) {
|
|
mockPush.mockClear();
|
|
mockSearchParams.clear();
|
|
mockSearchParams.set("error", errorCode);
|
|
|
|
const { unmount } = render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockPush).toHaveBeenCalledWith(`/login?error=${errorCode}`);
|
|
});
|
|
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it("should encode special characters in error parameter", async (): Promise<void> => {
|
|
// Even valid errors should be encoded in the URL
|
|
mockSearchParams.set("error", "session_failed");
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
// session_failed doesn't need encoding but the function should still call encodeURIComponent
|
|
expect(mockPush).toHaveBeenCalledWith("/login?error=session_failed");
|
|
});
|
|
});
|
|
|
|
it("should handle refresh session errors gracefully", async (): Promise<void> => {
|
|
const mockRefreshSession = vi.fn().mockRejectedValue(new Error("Session error"));
|
|
vi.mocked(useAuth).mockReturnValue({
|
|
refreshSession: mockRefreshSession,
|
|
user: null,
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
authError: null,
|
|
sessionExpiring: false,
|
|
sessionMinutesRemaining: 0,
|
|
signOut: vi.fn(),
|
|
});
|
|
|
|
render(<CallbackPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRefreshSession).toHaveBeenCalled();
|
|
expect(mockPush).toHaveBeenCalledWith("/login?error=session_failed");
|
|
});
|
|
});
|
|
});
|