fix: prevent silent auth error swallowing in fetch error utility
- Add setAuthErrorHandler() registration mechanism to utils/fetchError.ts - Implement fallback logging when auth errors (401/403) occur without registered handler - Update AuthProvider to register both API client and fetch error handlers - Ensure auth errors are handled deterministically at multiple layers - Add comprehensive tests for auth error handler registration and fallback logging - Update Web-Development.md documentation with auth error handling contract Fixes issue #21: Silent auth errors are now caught and logged if the handler is not registered, preventing actionable errors from being silently swallowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -20,6 +20,17 @@
|
||||
* The `isAuthenticated` state persists in `sessionStorage` to survive page
|
||||
* refreshes within the browser tab but is cleared on tab close. The session
|
||||
* cookie itself persists according to the backend's cookie settings.
|
||||
*
|
||||
* **Auth Error Handling:**
|
||||
* AuthProvider registers two auth error handlers to ensure that 401/403 errors
|
||||
* are never silently swallowed:
|
||||
* - `setUnauthorizedHandler()` in `api/client.ts` — handles auth errors from the
|
||||
* API client layer before they reach hooks
|
||||
* - `setAuthErrorHandler()` in `utils/fetchError.ts` — handles auth errors that
|
||||
* reach hooks and ensures deterministic error handling with fallback logging
|
||||
*
|
||||
* This dual-handler approach ensures auth errors are caught and handled at
|
||||
* multiple layers, preventing silent error loss regardless of where the error occurs.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
@@ -32,6 +43,7 @@ import React, {
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as authApi from "../api/auth";
|
||||
import { setUnauthorizedHandler } from "../api/client";
|
||||
import { setAuthErrorHandler } from "../utils/fetchError";
|
||||
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
||||
import { useSessionValidation } from "../hooks/useSessionValidation";
|
||||
|
||||
@@ -103,8 +115,12 @@ export function AuthProvider({
|
||||
setUnauthorizedHandler((): void => {
|
||||
handleSessionExpired();
|
||||
});
|
||||
setAuthErrorHandler((): void => {
|
||||
handleSessionExpired();
|
||||
});
|
||||
return (): void => {
|
||||
setUnauthorizedHandler(null);
|
||||
setAuthErrorHandler(null);
|
||||
};
|
||||
}, [handleSessionExpired]);
|
||||
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter, setAuthErrorHandler } from "../fetchError";
|
||||
|
||||
describe("utils/fetchError", () => {
|
||||
beforeEach(() => {
|
||||
// Clear handler before each test
|
||||
setAuthErrorHandler(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
setAuthErrorHandler(null);
|
||||
});
|
||||
|
||||
it("ignores AbortError errors", () => {
|
||||
const setError = vi.fn();
|
||||
handleFetchError(new DOMException("Aborted", "AbortError"), setError, "fallback");
|
||||
expect(setError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores auth errors", () => {
|
||||
it("calls registered auth error handler on 401", () => {
|
||||
const authHandler = vi.fn();
|
||||
setAuthErrorHandler(authHandler);
|
||||
const setError = vi.fn();
|
||||
handleFetchError(new ApiError(401, "Unauthorized"), setError, "fallback");
|
||||
expect(authHandler).toHaveBeenCalledOnce();
|
||||
expect(setError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls registered auth error handler on 403", () => {
|
||||
const authHandler = vi.fn();
|
||||
setAuthErrorHandler(authHandler);
|
||||
const setError = vi.fn();
|
||||
handleFetchError(new ApiError(403, "Forbidden"), setError, "fallback");
|
||||
expect(authHandler).toHaveBeenCalledOnce();
|
||||
expect(setError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs warning when auth error occurs without registered handler", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const setError = vi.fn();
|
||||
handleFetchError(new ApiError(401, "Unauthorized"), setError, "fallback");
|
||||
expect(consoleWarnSpy).toHaveBeenCalledOnce();
|
||||
const firstCall = consoleWarnSpy.mock.calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
expect(String(firstCall?.[0])).toContain("Auth error (401) without registered handler");
|
||||
expect(setError).not.toHaveBeenCalled();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets fallback for normal errors", () => {
|
||||
const setError = vi.fn();
|
||||
handleFetchError(new Error("Oops"), createStringErrorAdapter(setError), "fallback");
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import type { FetchError } from "../types/api";
|
||||
import { isAuthError, isAbortError } from "../types/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth error handler registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Module-level callback invoked when an auth error (401/403) is encountered
|
||||
* in handleFetchError and no other error handler has processed it.
|
||||
*
|
||||
* Set via setAuthErrorHandler() and called to ensure auth errors are never
|
||||
* silently swallowed. If no handler is registered, auth errors are logged as
|
||||
* warnings to ensure they remain actionable.
|
||||
*
|
||||
* @see setAuthErrorHandler
|
||||
*/
|
||||
let authErrorHandler: ((error: FetchError) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked when an auth error (401/403) is detected.
|
||||
* Typically called by the AuthProvider on mount.
|
||||
*
|
||||
* If a handler is registered, it will be called when handleFetchError detects
|
||||
* a 401/403 error. If no handler is registered, auth errors will be logged
|
||||
* as warnings to the console.
|
||||
*
|
||||
* @param handler - Callback to invoke on auth error. Pass `null` to clear.
|
||||
*
|
||||
* @example
|
||||
* // In AuthProvider:
|
||||
* useEffect(() => {
|
||||
* setAuthErrorHandler((error) => {
|
||||
* handleSessionExpired();
|
||||
* });
|
||||
* return () => setAuthErrorHandler(null);
|
||||
* }, [handleSessionExpired]);
|
||||
*/
|
||||
export function setAuthErrorHandler(handler: ((error: FetchError) => void) | null): void {
|
||||
authErrorHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user-friendly message from a FetchError.
|
||||
*
|
||||
@@ -27,14 +66,24 @@ export function getErrorMessage(error: FetchError): string {
|
||||
*
|
||||
* Handles three error cases:
|
||||
* 1. Request was aborted — silently ignored (expected cleanup)
|
||||
* 2. Auth error (401/403) — silently handled by AuthProvider (do not display)
|
||||
* 2. Auth error (401/403) — invokes registered auth handler with fallback logging
|
||||
* 3. Other error — stored in component state or notified via callback
|
||||
*
|
||||
* **Auth Error Handling Contract:**
|
||||
* When a 401/403 auth error is detected, handleFetchError ensures deterministic
|
||||
* error handling by invoking the registered `authErrorHandler` (typically
|
||||
* AuthProvider's session expiration callback). If no handler is registered,
|
||||
* the error is logged as a warning to ensure it remains actionable.
|
||||
*
|
||||
* This prevents silent error swallowing and ensures auth errors are never lost.
|
||||
*
|
||||
* Supports both typed FetchError and backward-compatible string error setters.
|
||||
*
|
||||
* @param err - The caught error (any value caught from Promise.catch)
|
||||
* @param setError - State setter (accepts either FetchError | null or string | null)
|
||||
* @param fallback - Default error message if err is not an Error instance
|
||||
*
|
||||
* @see setAuthErrorHandler
|
||||
*/
|
||||
export function handleFetchError(
|
||||
err: unknown,
|
||||
@@ -49,8 +98,21 @@ export function handleFetchError(
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth errors are handled globally by AuthProvider — do not display locally
|
||||
// Auth errors are handled globally with registered handler or fallback logging.
|
||||
// This ensures auth errors are never silently swallowed.
|
||||
if (isAuthError(fetchError)) {
|
||||
if (authErrorHandler) {
|
||||
authErrorHandler(fetchError);
|
||||
} else {
|
||||
// Fallback: log the auth error to ensure it remains actionable.
|
||||
// This indicates that AuthProvider either hasn't mounted yet or
|
||||
// failed to register its handler.
|
||||
console.warn(
|
||||
`Auth error (${fetchError.status}) without registered handler. ` +
|
||||
`AuthProvider may not be mounted or handler registration failed.`,
|
||||
fetchError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user