All files / src/services/api apiClient.ts

92.72% Statements 51/55
84% Branches 21/25
77.77% Functions 7/9
92.72% Lines 51/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214                                  414x                         413x 400x   13x 13x 13x 13x 13x   11x               107x             3x                 107x                                         1x 1x           107x     414x     414x       414x 414x 1x       413x 413x 1x       414x                                         107x 59x       227x         227x 29x   29x     5x         5x 5x 1x     4x 4x   5x                   3x 1x         1x   1x     1x 1x 1x           2x   3x     8x 8x     13x       198x     227x          
/**
 * API Client Configuration
 * Story 1.2.1: HTTP Client with i18n Support
 */
 
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import i18n from '@/i18n/config';
import { hasCognitoSession } from '@/utils/auth/cognitoSession';
import { ensureAmplifyConfigured } from '@/config/amplify';
import { authService } from '@/services/auth/authService';
import { setLogoutReason } from '@/services/auth/logoutReason';
 
/**
 * Generate a unique correlation ID for request tracing
 * @returns UUID v4 format correlation ID
 */
function generateCorrelationId(): string {
  return crypto.randomUUID();
}
 
/**
 * Get ID token from AWS Amplify (secure token management)
 * Uses ID token (not access token) to include custom Cognito attributes (custom:role)
 * @returns ID token string or null if not authenticated
 */
async function getIdToken(): Promise<string | null> {
  // Anonymous visitors (public homepage / archive / event discovery) have no Cognito
  // tokens in storage. Short-circuit BEFORE importing aws-amplify so the ~426 KB dependency
  // never loads for them (perf/public-homepage-followup #2). Authenticated requests fall
  // through to lazily configure + query Amplify.
  if (!hasCognitoSession()) {
    return null;
  }
  try {
    await ensureAmplifyConfigured();
    const { fetchAuthSession } = await import('aws-amplify/auth');
    const session = await fetchAuthSession();
    return session.tokens?.idToken?.toString() || null;
  } catch {
    return null;
  }
}
 
/**
 * Navigation callback for handling redirects from API interceptor
 * Set this from App.tsx after router initialization
 */
let navigateCallback: ((path: string) => void) | null = null;
 
/**
 * Set the navigation callback for API client redirects
 * Call this from App.tsx: setNavigationCallback(navigate)
 */
export function setNavigationCallback(navigate: (path: string) => void): void {
  navigateCallback = navigate;
}
 
/**
 * Create axios instance with default configuration
 *
 * Note: baseURL is set to '/api' initially, but will be updated
 * with runtime config from backend via updateApiClientConfig()
 */
const apiClient: AxiosInstance = axios.create({
  baseURL: '/api',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
  // Configure params serialization for Spring compatibility
  // Arrays are sent as repeated parameters: status=CONFIRMED&status=REGISTERED
  // instead of bracket notation: status[]=CONFIRMED&status[]=REGISTERED
  paramsSerializer: {
    indexes: null, // This removes brackets from array parameters
  },
});
 
/**
 * Update API client configuration with runtime config from backend
 * Called after runtime config is loaded in main.tsx
 *
 * @param apiBaseUrl - Base URL from runtime config (e.g., "http://localhost:8080/api/v1")
 */
export function updateApiClientConfig(apiBaseUrl: string): void {
  apiClient.defaults.baseURL = apiBaseUrl;
  console.log(`[ApiClient] Updated baseURL to: ${apiBaseUrl}`);
}
 
/**
 * Request interceptor to add headers (Accept-Language, X-Correlation-ID, Authorization)
 */
apiClient.interceptors.request.use(
  async (config: InternalAxiosRequestConfig) => {
    // Add Accept-Language header based on current i18n language
    config.headers['Accept-Language'] = i18n.language;
 
    // Add X-Correlation-ID for distributed tracing (Story 1.17 AC6)
    config.headers['X-Correlation-ID'] = generateCorrelationId();
 
    // Skip authentication for public endpoints (e.g., partner showcase, event discovery)
    // This prevents 401 errors when expired tokens exist in browser storage
    const skipAuth = config.headers['Skip-Auth'] === 'true';
    if (skipAuth) {
      delete config.headers['Skip-Auth']; // Remove marker header
    } else {
      // Add authentication token from AWS Amplify (secure storage)
      // SEC-001 Fix: Use AWS Amplify's secure token management instead of localStorage
      const token = await getIdToken();
      if (token) {
        config.headers['Authorization'] = `Bearer ${token}`;
      }
    }
 
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);
 
// Archive robustness (Story 7.5 follow-up): requests for PUBLIC, optional reads — e.g. the
// per-session Q&A thread rendered on public archive pages — set `skipAuthRedirect` so a 401
// does NOT force-logout the page. The caller's query handles the error and renders empty.
// Without this, an optional read that 401s (an endpoint not yet deployed, or a transient auth
// blip) would bounce an anonymous visitor on a public archive page to /login.
declare module 'axios' {
  interface AxiosRequestConfig {
    skipAuthRedirect?: boolean;
  }
}
 
/**
 * Response interceptor for error handling with correlation ID tracking
 */
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    // Extract correlation ID from request or response headers
    const correlationId =
      error.config?.headers?.['X-Correlation-ID'] ||
      error.response?.headers?.['x-correlation-id'] ||
      'unknown';
 
    // Handle common HTTP errors
    if (error.response) {
      const { status } = error.response;
 
      switch (status) {
        case 401:
          // Public/optional read (e.g. archive Q&A): a 401 must NOT force-logout the page.
          Iif (error.config?.skipAuthRedirect) {
            console.warn(`[${correlationId}] 401 on skipAuthRedirect request — not redirecting`);
            break;
          }
          // Unauthorized - redirect to login using React Router navigate
          console.error(`[${correlationId}] Unauthorized - session expired`);
          if (navigateCallback) {
            navigateCallback('/login');
          } else {
            // Fallback to window.location if navigate not configured (shouldn't happen in production)
            console.warn('Navigate callback not set - falling back to window.location');
            window.location.href = '/login';
          }
          break;
        case 403:
          // Story 12.7 / G1 (from Story 12.2 is_active gate): the gateway emits
          //   403 { "error": "ACCOUNT_DEACTIVATED", "message": "..." }
          // when a user's account is deactivated — for BOTH password and federated
          // sessions (the gate is provider-agnostic). Force a clean logout + route to
          // the login surface with a deactivated indicator so the user sees a clear
          // message rather than a raw 403.
          // CRITICAL: this is a 403, NOT a 401 — it must NOT go through the
          // token-refresh path (a refresh loop would result).
          if (error.response.data?.error === 'ACCOUNT_DEACTIVATED') {
            console.error(`[${correlationId}] Account deactivated - forcing logout`);
            // Story 12.8 F5: for a FEDERATED session, Amplify signOut() performs a
            // full-page redirect to the Cognito hosted-UI /logout — which clobbers the
            // in-app navigation below (and its ?reason= param). Persist the reason in
            // sessionStorage so it survives the round-trip; LogoutPage/LoginForm consume it.
            setLogoutReason('account_deactivated');
            // Best-effort sign-out; clears the Amplify session (native or federated).
            void authService.signOut().catch(() => {
              /* ignore — we redirect regardless */
            });
            const target = '/login?reason=account_deactivated';
            if (navigateCallback) {
              navigateCallback(target);
            } else E{
              window.location.href = target;
            }
          } else {
            // Forbidden - insufficient permissions
            console.error(`[${correlationId}] Forbidden: Insufficient permissions`);
          }
          break;
        case 500:
          // Server error
          console.error(`[${correlationId}] Server error occurred`);
          break;
        default:
          // Log other errors with correlation ID
          console.error(`[${correlationId}] API error: ${status}`);
      }
    } else {
      // Network error or timeout
      console.error(`[${correlationId}] Network error: ${error.message}`);
    }
 
    return Promise.reject(error);
  }
);
 
export default apiClient;