debug(auth): log session cookie source
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

This commit is contained in:
2026-02-18 21:30:00 -06:00
parent fa9f173f8e
commit af299abdaf
21 changed files with 1502 additions and 78 deletions

View File

@@ -0,0 +1,87 @@
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 LoginPage from "./page";
const { mockPush, mockReplace, mockSearchParams, authState } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockReplace: vi.fn(),
mockSearchParams: new URLSearchParams(),
authState: {
isAuthenticated: false,
refreshSession: vi.fn(),
},
}));
const { mockFetchWithRetry } = vi.hoisted(() => ({
mockFetchWithRetry: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: (): { push: Mock; replace: Mock } => ({
push: mockPush,
replace: mockReplace,
}),
useSearchParams: (): URLSearchParams => mockSearchParams,
}));
vi.mock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3001",
IS_MOCK_AUTH_MODE: true,
}));
vi.mock("@/lib/auth-client", () => ({
signIn: {
oauth2: vi.fn(),
email: vi.fn(),
},
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: (): { isAuthenticated: boolean; refreshSession: Mock } => ({
isAuthenticated: authState.isAuthenticated,
refreshSession: authState.refreshSession,
}),
}));
vi.mock("@/lib/auth/fetch-with-retry", () => ({
fetchWithRetry: mockFetchWithRetry,
}));
describe("LoginPage (mock auth mode)", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
mockSearchParams.delete("error");
authState.isAuthenticated = false;
authState.refreshSession.mockResolvedValue(undefined);
});
it("should render mock auth controls", (): void => {
render(<LoginPage />);
expect(screen.getByText(/local mock auth mode is active/i)).toBeInTheDocument();
expect(screen.getByTestId("mock-auth-login")).toBeInTheDocument();
expect(mockFetchWithRetry).not.toHaveBeenCalled();
});
it("should continue with mock session and navigate to tasks", async (): Promise<void> => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByTestId("mock-auth-login"));
await waitFor(() => {
expect(authState.refreshSession).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith("/tasks");
});
});
it("should auto-redirect authenticated mock users to tasks", async (): Promise<void> => {
authState.isAuthenticated = true;
render(<LoginPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith("/tasks");
});
});
});

View File

@@ -16,6 +16,11 @@ const { mockOAuth2, mockSignInEmail, mockPush, mockReplace, mockSearchParams } =
mockSearchParams: new URLSearchParams(),
}));
const { mockRefreshSession, mockIsAuthenticated } = vi.hoisted(() => ({
mockRefreshSession: vi.fn(),
mockIsAuthenticated: false,
}));
vi.mock("next/navigation", () => ({
useRouter: (): { push: Mock; replace: Mock } => ({
push: mockPush,
@@ -33,6 +38,14 @@ vi.mock("@/lib/auth-client", () => ({
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
@@ -91,6 +104,7 @@ describe("LoginPage", (): void => {
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 => {

View File

@@ -5,10 +5,11 @@ import type { ReactElement } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
import { API_BASE_URL } from "@/lib/config";
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
import { signIn } from "@/lib/auth-client";
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
import { parseAuthError } from "@/lib/auth/auth-errors";
import { useAuth } from "@/lib/auth/auth-context";
import { OAuthButton } from "@/components/auth/OAuthButton";
import { LoginForm } from "@/components/auth/LoginForm";
import { AuthDivider } from "@/components/auth/AuthDivider";
@@ -45,6 +46,7 @@ export default function LoginPage(): ReactElement {
function LoginPageContent(): ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, refreshSession } = useAuth();
const [config, setConfig] = useState<AuthConfigResponse | null | undefined>(undefined);
const [loadingConfig, setLoadingConfig] = useState(true);
const [retryCount, setRetryCount] = useState(0);
@@ -68,6 +70,18 @@ function LoginPageContent(): ReactElement {
}, [searchParams, router]);
useEffect(() => {
if (IS_MOCK_AUTH_MODE && isAuthenticated) {
router.replace("/tasks");
}
}, [isAuthenticated, router]);
useEffect(() => {
if (IS_MOCK_AUTH_MODE) {
setConfig({ providers: [] });
setLoadingConfig(false);
return;
}
let cancelled = false;
async function fetchConfig(): Promise<void> {
@@ -158,6 +172,48 @@ function LoginPageContent(): ReactElement {
setRetryCount((c) => c + 1);
}, []);
const handleMockLogin = useCallback(async (): Promise<void> => {
setError(null);
try {
await refreshSession();
router.push("/tasks");
} catch (err: unknown) {
const parsed = parseAuthError(err);
setError(parsed.message);
}
}, [refreshSession, router]);
if (IS_MOCK_AUTH_MODE) {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
<p className="text-base sm:text-lg text-gray-600">
Local mock auth mode is active. Real sign-in is bypassed for frontend development.
</p>
</div>
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md space-y-4">
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Mock auth mode is local-only and blocked outside development.
</div>
{error && <AuthErrorBanner message={error} />}
<button
type="button"
onClick={() => {
void handleMockLogin();
}}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
data-testid="mock-auth-login"
>
Continue with Mock Session
</button>
</div>
</div>
</main>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
<div className="w-full max-w-md space-y-8">

View File

@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context";
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
import { Navigation } from "@/components/layout/Navigation";
import { ChatOverlay } from "@/components/chat";
import type { ReactNode } from "react";
@@ -36,8 +37,18 @@ export default function AuthenticatedLayout({
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="pt-16">{children}</div>
<ChatOverlay />
<div className="pt-16">
{IS_MOCK_AUTH_MODE && (
<div
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900"
data-testid="mock-auth-banner"
>
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
</div>
)}
{children}
</div>
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
</div>
);
}