diff --git a/app/src/services/__tests__/apiClient.test.ts b/app/src/services/__tests__/apiClient.test.ts index f60f03df3..f2f0e3865 100644 --- a/app/src/services/__tests__/apiClient.test.ts +++ b/app/src/services/__tests__/apiClient.test.ts @@ -4,10 +4,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/app', () => ({ getVersion: vi.fn() })); +const configMock = vi.hoisted(() => ({ isDev: true })); + +vi.mock('../../utils/config', () => ({ + APP_VERSION: '0.0.0-test', + get IS_DEV() { + return configMock.isDev; + }, +})); + describe('apiClient version headers', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + configMock.isDev = true; vi.mocked(isTauri).mockReturnValue(false); vi.stubGlobal('fetch', vi.fn()); }); @@ -49,6 +59,54 @@ describe('apiClient version headers', () => { expect(headers).not.toHaveProperty('x-web-version'); }); + it('logs request diagnostics in dev without leaking authorization headers', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ success: true }), + } as Response); + + const { apiClient, setStoreForApiClient } = await import('../apiClient'); + setStoreForApiClient(() => 'secret-session-token'); + + await apiClient.post('/version-check', { ok: true }); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const requestHeaders = requestInit.headers as Record; + expect(requestHeaders.Authorization).toBe('Bearer secret-session-token'); + + const logMock = vi.mocked(console.log); + expect(logMock).toHaveBeenCalledWith( + 'request', + expect.objectContaining({ + method: 'POST', + headers: expect.not.objectContaining({ + Authorization: expect.any(String), + authorization: expect.any(String), + }), + }) + ); + }); + + it('does not log request diagnostics in production', async () => { + configMock.isDev = false; + + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ success: true }), + } as Response); + + const { apiClient, setStoreForApiClient } = await import('../apiClient'); + setStoreForApiClient(() => 'secret-session-token'); + + await apiClient.get('/version-check'); + + expect(console.log).not.toHaveBeenCalledWith('request', expect.anything()); + }); + it('retries tauri version lookup after a transient failure', async () => { vi.mocked(isTauri).mockReturnValue(true); vi.mocked(getVersion) diff --git a/app/src/services/apiClient.ts b/app/src/services/apiClient.ts index 608d59f38..71ff0c02d 100644 --- a/app/src/services/apiClient.ts +++ b/app/src/services/apiClient.ts @@ -1,4 +1,5 @@ import type { ApiError } from '../types/api'; +import { IS_DEV } from '../utils/config'; import { getBackendUrl } from './backendUrl'; import { getClientVersionHeaders } from './clientVersionHeaders'; @@ -37,7 +38,7 @@ class ApiClient { /** * Build headers for the request */ - private async buildHeaders(options: RequestOptions): Promise { + private async buildHeaders(options: RequestOptions): Promise> { const versionHeaders = await getClientVersionHeaders(); const headers: Record = { 'Content-Type': 'application/json', @@ -56,6 +57,13 @@ class ApiClient { return headers; } + private sanitizeRequestLogHeaders(headers: HeadersInit): Record { + const safeHeaders = { ...(headers as Record) }; + delete safeHeaders.Authorization; + delete safeHeaders.authorization; + return safeHeaders; + } + /** * Make an API request */ @@ -66,7 +74,9 @@ class ApiClient { const url = `${baseUrl}${endpoint}`; const headers = await this.buildHeaders({ ...options, requireAuth }); - console.log('request', { url, headers, body, method }); + if (IS_DEV) { + console.log('request', { url, headers: this.sanitizeRequestLogHeaders(headers), method }); + } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout);