From 744f2a1dbf240fc6d5bf5846d53e648781254425 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 8 Sep 2026 22:53:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20URL-Normalisierung=20gegen=20D?= =?UTF-8?q?oppel-Pr=C3=A4fix=20=E2=80=94=20Workspace-UI=20&=20Permission-R?= =?UTF-8?q?efresh=20in=20Produktion=20repariert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Axios baseURL '/api/v1' + 16 apiX-Calls mit vollem Präfix ('/api/v1/workspaces/...') ergaben '/api/v1/api/v1/...' → 404 live (bewiesen per curl + Node). Betroffen: komplette Workspace-UI (Switcher, Manager, Phase-N-Scope-Editor) + Permission-Refresh. Fix (Defense-in-Depth): - normalizeApiUrl() in client.ts: alle apiX-Wrapper strippen redundanten '/api/v1'-Präfix — deckt auch DYNAMISCHE Backend-Contract-Endpoints (N2-Scope-Editor-Wertquellen) ab, die Call-Sites nicht umschreiben können - workspaces.ts + useUserPermissions.ts auf relative Pfade gesäubert (16 Calls) Tests: clientUrl 8/8 neu (Normalisierung + Wrapper-Beweis), tsc clean, Build OK, Workspace-Regression 3 Dateien grün --- frontend/src/__tests__/api/clientUrl.test.ts | 79 ++++++++++++++++++++ frontend/src/api/client.ts | 30 ++++++-- frontend/src/api/hooks/workspaces.ts | 30 ++++---- frontend/src/hooks/useUserPermissions.ts | 2 +- 4 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 frontend/src/__tests__/api/clientUrl.test.ts diff --git a/frontend/src/__tests__/api/clientUrl.test.ts b/frontend/src/__tests__/api/clientUrl.test.ts new file mode 100644 index 0000000..c51b71d --- /dev/null +++ b/frontend/src/__tests__/api/clientUrl.test.ts @@ -0,0 +1,79 @@ +/** + * Runtime-Bug fix test: URL normalization in the API client (Phase N hotfix). + * + * The axios client has baseURL '/api/v1'. Calls that pass a FULL prefix + * ('/api/v1/workspaces') would resolve to '/api/v1/api/v1/...' → 404 in + * production (proven via curl + node). The client must normalize URLs by + * stripping a redundant leading '/api/v1' — this also covers DYNAMIC + * endpoints from backend contracts (scope value sources like + * '/api/v1/contact-folders') that the frontend cannot rewrite. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + normalizeApiUrl, + apiGet, + apiClient, +} from '@/api/client'; + +vi.mock('@/utils/errorLogger', () => ({ + logError: vi.fn(), +})); + +describe('normalizeApiUrl', () => { + it('strips a redundant leading /api/v1 (baseURL is already /api/v1)', () => { + expect(normalizeApiUrl('/api/v1/workspaces')).toBe('/workspaces'); + expect(normalizeApiUrl('/api/v1/auth/me/permissions')).toBe('/auth/me/permissions'); + expect(normalizeApiUrl('/api/v1/workspaces/scope-definitions')).toBe( + '/workspaces/scope-definitions', + ); + }); + + it('keeps query strings intact', () => { + expect(normalizeApiUrl('/api/v1/miniapps?host=dashboard')).toBe( + '/miniapps?host=dashboard', + ); + expect(normalizeApiUrl('/api/v1/saved-views?entity_type=contact')).toBe( + '/saved-views?entity_type=contact', + ); + }); + + it('leaves relative paths untouched', () => { + expect(normalizeApiUrl('/workspaces')).toBe('/workspaces'); + expect(normalizeApiUrl('/contacts?page=1')).toBe('/contacts?page=1'); + }); + + it('leaves other api versions untouched (only v1 is the baseURL)', () => { + expect(normalizeApiUrl('/api/v2/other')).toBe('/api/v2/other'); + }); + + it('does not strip /api/v1 in the middle of a path', () => { + expect(normalizeApiUrl('/workspaces/api/v1')).toBe('/workspaces/api/v1'); + }); + + it('handles empty and root paths', () => { + expect(normalizeApiUrl('/')).toBe('/'); + expect(normalizeApiUrl('')).toBe(''); + }); +}); + +describe('apiX wrappers normalize full-prefix URLs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('apiGet strips the double prefix before axios sees it', async () => { + const spy = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } }); + await apiGet('/api/v1/workspaces'); + expect(spy).toHaveBeenCalledWith('/workspaces', undefined); + expect(spy.mock.calls[0][0]).not.toContain('api/v1/api'); + spy.mockRestore(); + }); + + it('dynamic contract endpoints (scope value sources) are normalized too', async () => { + const spy = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] }); + // This is what useScopeValues does: endpoint comes from the backend contract + await apiGet('/api/v1/contact-folders'); + expect(spy).toHaveBeenCalledWith('/contact-folders', undefined); + spy.mockRestore(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e3d4545..ae5515f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -152,28 +152,48 @@ function extractValidationErrors(data: any): Record | undefine return Object.keys(errors).length > 0 ? errors : undefined; } +/** + * Normalize an API URL against the axios baseURL ('/api/v1'). + * + * Runtime-bug fix (Phase N hotfix): calls that pass a FULL prefix + * ('/api/v1/workspaces') resolved to '/api/v1/api/v1/...' → 404 in + * production (proven via curl + node). This also covers DYNAMIC endpoints + * from backend contracts (e.g. scope value sources like + * '/api/v1/contact-folders') that call sites cannot rewrite. + */ +export function normalizeApiUrl(url: string): string { + if (url.startsWith('/api/v1/')) { + return url.slice('/api/v1'.length); + } + // exact '/api/v1' without trailing path → root + if (url === '/api/v1') { + return '/'; + } + return url; +} + export async function apiGet(url: string, config?: AxiosRequestConfig): Promise { - const response = await apiClient.get(url, config); + const response = await apiClient.get(normalizeApiUrl(url), config); return response.data; } export async function apiPost(url: string, data?: any, config?: AxiosRequestConfig): Promise { - const response = await apiClient.post(url, data, config); + const response = await apiClient.post(normalizeApiUrl(url), data, config); return response.data; } export async function apiPut(url: string, data?: any, config?: AxiosRequestConfig): Promise { - const response = await apiClient.put(url, data, config); + const response = await apiClient.put(normalizeApiUrl(url), data, config); return response.data; } export async function apiPatch(url: string, data?: any, config?: AxiosRequestConfig): Promise { - const response = await apiClient.patch(url, data, config); + const response = await apiClient.patch(normalizeApiUrl(url), data, config); return response.data; } export async function apiDelete(url: string, config?: AxiosRequestConfig): Promise { - const response = await apiClient.delete(url, config); + const response = await apiClient.delete(normalizeApiUrl(url), config); return response.data; } diff --git a/frontend/src/api/hooks/workspaces.ts b/frontend/src/api/hooks/workspaces.ts index 610a21a..8dc3f0c 100644 --- a/frontend/src/api/hooks/workspaces.ts +++ b/frontend/src/api/hooks/workspaces.ts @@ -41,21 +41,21 @@ export interface WorkspaceContext { export function useWorkspaces() { return useQuery<{ items: Workspace[]; total: number }>({ queryKey: ['workspaces'], - queryFn: () => apiGet('/api/v1/workspaces'), + queryFn: () => apiGet('/workspaces'), }); } export function useMyWorkspaces() { return useQuery<{ items: MyWorkspace[]; total: number }>({ queryKey: ['my-workspaces'], - queryFn: () => apiGet('/api/v1/workspaces/my'), + queryFn: () => apiGet('/workspaces/my'), }); } export function useWorkspaceContext(workspaceId: string | null) { return useQuery({ queryKey: ['workspace-context', workspaceId], - queryFn: () => apiGet('/api/v1/workspaces/context', { + queryFn: () => apiGet('/workspaces/context', { headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {}, }), enabled: !!workspaceId, @@ -66,7 +66,7 @@ export function useCreateWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) => - apiPost('/api/v1/workspaces', data), + apiPost('/workspaces', data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -78,7 +78,7 @@ export function useUpdateWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) => - apiPut(`/api/v1/workspaces/${id}`, data), + apiPut(`/workspaces/${id}`, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -89,7 +89,7 @@ export function useUpdateWorkspace() { export function useDeleteWorkspace() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => apiDelete(`/api/v1/workspaces/${id}`), + mutationFn: (id: string) => apiDelete(`/workspaces/${id}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -101,7 +101,7 @@ export function useSetWorkspaceModules() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) => - apiPost(`/api/v1/workspaces/${workspaceId}/modules`, { modules }), + apiPost(`/workspaces/${workspaceId}/modules`, { modules }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -113,7 +113,7 @@ export function useAssignWorkspaceUser() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) => - apiPost(`/api/v1/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }), + apiPost(`/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -125,7 +125,7 @@ export function useRemoveWorkspaceUser() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) => - apiDelete(`/api/v1/workspaces/${workspaceId}/users/${userId}`), + apiDelete(`/workspaces/${workspaceId}/users/${userId}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] }); @@ -149,7 +149,7 @@ export interface WorkspaceWidget { export function useWorkspaceWidgets(workspaceId: string | null) { return useQuery<{ items: WorkspaceWidget[]; total: number }>({ queryKey: ['workspace-widgets', workspaceId], - queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`), + queryFn: () => apiGet(`/workspaces/${workspaceId}/widgets`), enabled: !!workspaceId, }); } @@ -165,7 +165,7 @@ export function useCreateWorkspaceWidget() { width?: number; height?: number; config?: Record; - }) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data), + }) => apiPost(`/workspaces/${workspaceId}/widgets`, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] }); @@ -184,7 +184,7 @@ export function useUpdateWorkspaceWidget() { width?: number; height?: number; config?: Record; - }) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data), + }) => apiPut(`/workspaces/${workspaceId}/widgets/${widgetId}`, data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] }); @@ -196,7 +196,7 @@ export function useDeleteWorkspaceWidget() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) => - apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`), + apiDelete(`/workspaces/${workspaceId}/widgets/${widgetId}`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] }); @@ -210,7 +210,7 @@ export function useSetDefaultWorkspace() { const qc = useQueryClient(); return useMutation({ mutationFn: (workspaceId: string) => - apiPost(`/api/v1/workspaces/${workspaceId}/set-default`), + apiPost(`/workspaces/${workspaceId}/set-default`), onSuccess: () => { qc.invalidateQueries({ queryKey: ['my-workspaces'] }); }, @@ -247,7 +247,7 @@ export interface WorkspaceScopeDefinitions { export function useWorkspaceScopeDefinitions() { return useQuery({ queryKey: ['workspace-scope-definitions'], - queryFn: () => apiGet('/api/v1/workspaces/scope-definitions'), + queryFn: () => apiGet('/workspaces/scope-definitions'), }); } diff --git a/frontend/src/hooks/useUserPermissions.ts b/frontend/src/hooks/useUserPermissions.ts index 8077658..7309316 100644 --- a/frontend/src/hooks/useUserPermissions.ts +++ b/frontend/src/hooks/useUserPermissions.ts @@ -19,7 +19,7 @@ export function useUserPermissions() { const { data, isSuccess } = useQuery({ queryKey: ['user-permissions'], - queryFn: () => apiGet('/api/v1/auth/me/permissions'), + queryFn: () => apiGet('/auth/me/permissions'), enabled: isAuthenticated, staleTime: 5 * 60 * 1000, retry: 1,