fix(#338): Log auth errors and distinguish backend down from logged out
- Add error logging for auth check failures in development mode - Distinguish network/backend errors from normal unauthenticated state - Expose authError state to UI (network | backend | null) - Add comprehensive tests for error handling scenarios Refs #338 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ describe("CallbackPage", (): void => {
|
|||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
authError: null,
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,6 +50,7 @@ describe("CallbackPage", (): void => {
|
|||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
authError: null,
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +140,7 @@ describe("CallbackPage", (): void => {
|
|||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
authError: null,
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { apiGet, apiPost } = await import("../api/client");
|
|||||||
|
|
||||||
// Test component that uses the auth context
|
// Test component that uses the auth context
|
||||||
function TestComponent(): React.JSX.Element {
|
function TestComponent(): React.JSX.Element {
|
||||||
const { user, isLoading, isAuthenticated, signOut } = useAuth();
|
const { user, isLoading, isAuthenticated, authError, signOut } = useAuth();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
@@ -22,6 +22,7 @@ function TestComponent(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="auth-status">{isAuthenticated ? "Authenticated" : "Not Authenticated"}</div>
|
<div data-testid="auth-status">{isAuthenticated ? "Authenticated" : "Not Authenticated"}</div>
|
||||||
|
<div data-testid="auth-error">{authError ?? "none"}</div>
|
||||||
{user && (
|
{user && (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="user-email">{user.email}</div>
|
<div data-testid="user-email">{user.email}</div>
|
||||||
@@ -145,4 +146,178 @@ describe("AuthContext", (): void => {
|
|||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("auth error handling", (): void => {
|
||||||
|
it("should not set authError for normal unauthenticated state (401/403)", async (): Promise<void> => {
|
||||||
|
// Normal auth error - user is just not logged in
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT have an auth error - this is expected behavior
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set authError to 'network' for fetch failures", async (): Promise<void> => {
|
||||||
|
// Network error - backend is unreachable
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have a network error
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log errors in development mode", async (): Promise<void> => {
|
||||||
|
// Temporarily set to development
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
// Intentionally empty - we're testing that errors are logged
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network error - backend is unreachable
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should log error in development
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[Auth]"),
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set authError to 'network' for connection refused", async (): Promise<void> => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set authError to 'backend' for server errors", async (): Promise<void> => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend error - 500 Internal Server Error
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Internal Server Error"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have a backend error
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("backend");
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set authError to 'backend' for service unavailable", async (): Promise<void> => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Service Unavailable"));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("backend");
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear authError after successful session refresh", async (): Promise<void> => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call fails with network error
|
||||||
|
vi.mocked(apiGet).mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("auth-error")).toHaveTextContent("network");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up successful response for refresh
|
||||||
|
const mockUser: AuthUser = {
|
||||||
|
id: "user-1",
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
};
|
||||||
|
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||||
|
user: mockUser,
|
||||||
|
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a rerender (simulating refreshSession being called)
|
||||||
|
rerender(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The initial render will have checked session once, error should still be there
|
||||||
|
// A real refresh would need to call refreshSession
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,25 +4,92 @@ import { createContext, useContext, useState, useEffect, useCallback, type React
|
|||||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||||
import { apiGet, apiPost } from "../api/client";
|
import { apiGet, apiPost } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error types for auth session checks
|
||||||
|
*/
|
||||||
|
export type AuthErrorType = "network" | "backend" | null;
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
authError: AuthErrorType;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
refreshSession: () => Promise<void>;
|
refreshSession: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error indicates a network/backend issue vs normal "not authenticated"
|
||||||
|
*/
|
||||||
|
function isBackendError(error: unknown): { isBackendDown: boolean; errorType: AuthErrorType } {
|
||||||
|
// Network errors (fetch failed, DNS, connection refused, etc.)
|
||||||
|
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||||
|
return { isBackendDown: true, errorType: "network" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific error messages that indicate backend issues
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
// Network-level errors
|
||||||
|
if (
|
||||||
|
message.includes("network") ||
|
||||||
|
message.includes("failed to fetch") ||
|
||||||
|
message.includes("connection refused") ||
|
||||||
|
message.includes("econnrefused") ||
|
||||||
|
message.includes("timeout")
|
||||||
|
) {
|
||||||
|
return { isBackendDown: true, errorType: "network" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend errors (5xx status codes typically result in these messages)
|
||||||
|
if (
|
||||||
|
message.includes("internal server error") ||
|
||||||
|
message.includes("service unavailable") ||
|
||||||
|
message.includes("bad gateway") ||
|
||||||
|
message.includes("gateway timeout")
|
||||||
|
) {
|
||||||
|
return { isBackendDown: true, errorType: "backend" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal auth errors (401, 403, etc.) - user is just not logged in
|
||||||
|
return { isBackendDown: false, errorType: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log auth errors in development mode
|
||||||
|
*/
|
||||||
|
function logAuthError(message: string, error: unknown): void {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error(`[Auth] ${message}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [authError, setAuthError] = useState<AuthErrorType>(null);
|
||||||
|
|
||||||
const checkSession = useCallback(async () => {
|
const checkSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await apiGet<AuthSession>("/auth/session");
|
const session = await apiGet<AuthSession>("/auth/session");
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
} catch {
|
setAuthError(null);
|
||||||
|
} catch (error) {
|
||||||
|
const { isBackendDown, errorType } = isBackendError(error);
|
||||||
|
|
||||||
|
if (isBackendDown) {
|
||||||
|
// Backend/network issue - log and expose error to UI
|
||||||
|
logAuthError("Session check failed due to backend/network issue", error);
|
||||||
|
setAuthError(errorType);
|
||||||
|
} else {
|
||||||
|
// Normal "not authenticated" state - no logging needed
|
||||||
|
setAuthError(null);
|
||||||
|
}
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -51,6 +118,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
|
|||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: user !== null,
|
isAuthenticated: user !== null,
|
||||||
|
authError,
|
||||||
signOut,
|
signOut,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user