Files
stack/apps/web/src/app/(auth)/callback/page.test.tsx
Jason Woltje 752e839054 fix(#411): QA-005 — production logging, error classification, session-expired state
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>
2026-02-16 13:37:49 -06:00

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");
});
});
});