Просмотр исходного кода

Fix frontend API calls missing auth tokens

Several frontend components were using raw fetch() instead of the API
client, causing 401 Unauthorized errors when authentication is enabled.

Changes:
- SpoolmanSettings: Use api.getSpoolmanSettings/updateSpoolmanSettings
- ProjectsPage: Use api.importProjectFile for ZIP imports
- ProjectDetailPage: Use api.exportProjectZip for exports
- CameraPage/EmbeddedCameraViewer: Use api.getCameraStatus

Added to API client:
- getSpoolmanSettings()
- updateSpoolmanSettings()
- importProjectFile()
- exportProjectZip()
- getCameraStatus()

Added tests for auth token handling in client.test.ts
Fixed VirtualPrinterSettings test expectations to match i18n strings

Closes #231
maziggy 3 месяцев назад
Родитель
Сommit
158fa88b7a

+ 178 - 0
frontend/src/__tests__/api/client.test.ts

@@ -0,0 +1,178 @@
+/**
+ * Tests for the API client auth token handling.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { setAuthToken, getAuthToken, api } from '../../api/client';
+
+// Mock localStorage
+const localStorageMock = {
+  store: {} as Record<string, string>,
+  getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+  setItem: vi.fn((key: string, value: string) => {
+    localStorageMock.store[key] = value;
+  }),
+  removeItem: vi.fn((key: string) => {
+    delete localStorageMock.store[key];
+  }),
+  clear: vi.fn(() => {
+    localStorageMock.store = {};
+  }),
+};
+
+Object.defineProperty(window, 'localStorage', {
+  value: localStorageMock,
+});
+
+// Create MSW server
+const server = setupServer();
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => {
+  server.resetHandlers();
+  localStorageMock.clear();
+  setAuthToken(null);
+});
+afterAll(() => server.close());
+
+describe('Auth Token Management', () => {
+  it('setAuthToken stores token in localStorage', () => {
+    setAuthToken('test-token-123');
+    expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
+    expect(getAuthToken()).toBe('test-token-123');
+  });
+
+  it('setAuthToken removes token from localStorage when null', () => {
+    setAuthToken('test-token-123');
+    setAuthToken(null);
+    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(getAuthToken()).toBeNull();
+  });
+});
+
+describe('API Client Auth Header', () => {
+  it('includes Authorization header when token is set', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/settings/spoolman', ({ request }) => {
+        capturedHeaders = request.headers;
+        return HttpResponse.json({
+          spoolman_enabled: 'false',
+          spoolman_url: '',
+          spoolman_sync_mode: 'auto',
+        });
+      })
+    );
+
+    setAuthToken('test-jwt-token');
+    await api.getSpoolmanSettings();
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-jwt-token');
+  });
+
+  it('does not include Authorization header when token is not set', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/settings/spoolman', ({ request }) => {
+        capturedHeaders = request.headers;
+        return HttpResponse.json({
+          spoolman_enabled: 'false',
+          spoolman_url: '',
+          spoolman_sync_mode: 'auto',
+        });
+      })
+    );
+
+    setAuthToken(null);
+    await api.getSpoolmanSettings();
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBeNull();
+  });
+
+  it('clears token on 401 Unauthorized response', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () => {
+        return HttpResponse.json(
+          { detail: 'Not authenticated' },
+          { status: 401 }
+        );
+      })
+    );
+
+    setAuthToken('expired-token');
+    expect(getAuthToken()).toBe('expired-token');
+
+    try {
+      await api.getSpoolmanSettings();
+    } catch {
+      // Expected to throw
+    }
+
+    expect(getAuthToken()).toBeNull();
+    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+  });
+});
+
+describe('FormData requests include auth header', () => {
+  it('importProjectFile includes Authorization header', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.post('/api/v1/projects/import/file', ({ request }) => {
+        capturedHeaders = request.headers;
+        return HttpResponse.json({
+          id: 1,
+          name: 'Test Project',
+          description: '',
+          total_cost: 0,
+          total_print_time_seconds: 0,
+          total_prints: 0,
+          total_quantity: 0,
+          status: 'active',
+          due_date: null,
+          created_at: '2026-01-01T00:00:00Z',
+          updated_at: '2026-01-01T00:00:00Z',
+          archives: [],
+          bom_items: [],
+        });
+      })
+    );
+
+    setAuthToken('test-token');
+    const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
+    await api.importProjectFile(file);
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+  });
+
+  it('exportProjectZip includes Authorization header', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/projects/:projectId/export', ({ request }) => {
+        capturedHeaders = request.headers;
+        const zipContent = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic bytes
+        return new HttpResponse(zipContent, {
+          status: 200,
+          headers: {
+            'Content-Type': 'application/zip',
+            'Content-Disposition': 'attachment; filename="project.zip"',
+          },
+        });
+      })
+    );
+
+    setAuthToken('test-token');
+    await api.exportProjectZip(1);
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+  });
+});

+ 24 - 43
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -18,6 +18,8 @@ vi.mock('../../api/client', () => ({
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    getSpoolmanSettings: vi.fn(),
+    updateSpoolmanSettings: vi.fn(),
     getSpoolmanStatus: vi.fn(),
     connectSpoolman: vi.fn(),
     disconnectSpoolman: vi.fn(),
@@ -30,17 +32,21 @@ vi.mock('../../api/client', () => ({
 // Import mocked module
 import { api } from '../../api/client';
 
-// Mock fetch for Spoolman settings endpoints
-const mockFetchResponse = (data: object) => ({
-  ok: true,
-  json: () => Promise.resolve(data),
-});
-
 describe('SpoolmanSettings', () => {
   beforeEach(() => {
     vi.clearAllMocks();
 
     // Default API mocks
+    vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
+      spoolman_enabled: 'false',
+      spoolman_url: '',
+      spoolman_sync_mode: 'auto',
+    });
+    vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
+      spoolman_enabled: 'false',
+      spoolman_url: '',
+      spoolman_sync_mode: 'auto',
+    });
     vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
       enabled: false,
       connected: false,
@@ -56,26 +62,12 @@ describe('SpoolmanSettings', () => {
       skipped: [],
       errors: [],
     });
-
-    // Default fetch mock for settings - disabled state
-    global.fetch = vi.fn().mockImplementation((url: string) => {
-      if (url.includes('/api/v1/settings/spoolman')) {
-        return Promise.resolve(
-          mockFetchResponse({
-            spoolman_enabled: 'false',
-            spoolman_url: '',
-            spoolman_sync_mode: 'auto',
-          })
-        );
-      }
-      return Promise.reject(new Error('Unknown URL'));
-    }) as any;
   });
 
   describe('rendering', () => {
     it('renders loading state initially', () => {
-      // Delay the fetch response to catch loading state
-      global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any;
+      // Delay the API response to catch loading state
+      vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
       render(<SpoolmanSettings />);
 
       // Should show loading spinner
@@ -159,27 +151,16 @@ describe('SpoolmanSettings', () => {
 
   describe('enabled state', () => {
     beforeEach(() => {
-      global.fetch = vi.fn().mockImplementation((url: string) => {
-        if (url.includes('/api/v1/settings/spoolman')) {
-          if (url.includes('PUT') || (url as any).method === 'PUT') {
-            return Promise.resolve(
-              mockFetchResponse({
-                spoolman_enabled: 'true',
-                spoolman_url: 'http://localhost:7912',
-                spoolman_sync_mode: 'auto',
-              })
-            );
-          }
-          return Promise.resolve(
-            mockFetchResponse({
-              spoolman_enabled: 'true',
-              spoolman_url: 'http://localhost:7912',
-              spoolman_sync_mode: 'auto',
-            })
-          );
-        }
-        return Promise.reject(new Error('Unknown URL'));
-      }) as any;
+      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'auto',
+      });
+      vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'auto',
+      });
     });
 
     it('URL input is enabled when Spoolman is enabled', async () => {

+ 2 - 2
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -274,7 +274,7 @@ describe('VirtualPrinterSettings', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Review')).toBeInTheDocument();
-        expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
+        expect(screen.getByText('Review before archiving')).toBeInTheDocument();
       });
     });
 
@@ -283,7 +283,7 @@ describe('VirtualPrinterSettings', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Queue')).toBeInTheDocument();
-        expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
+        expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();
       });
     });
 

+ 45 - 0
frontend/src/api/client.ts

@@ -2969,6 +2969,13 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ tray_uuid: trayUuid }),
     }),
+  getSpoolmanSettings: () =>
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman'),
+  updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string }) =>
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
 
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
@@ -3030,6 +3037,8 @@ export const api = {
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
+  getCameraStatus: (printerId: number) =>
+    request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
 
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
@@ -3234,6 +3243,42 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  importProjectFile: async (file: File): Promise<Project> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/projects/import/file`, {
+      method: 'POST',
+      headers,
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  exportProjectZip: async (projectId: number): Promise<{ blob: Blob; filename: string }> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/projects/${projectId}/export`, {
+      headers,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const contentDisposition = response.headers.get('Content-Disposition');
+    const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
+    const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
+    const blob = await response.blob();
+    return { blob, filename };
+  },
 
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),

+ 7 - 10
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -184,17 +184,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
 
     stallCheckIntervalRef.current = setInterval(async () => {
       try {
-        const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
-        if (response.ok) {
-          const status = await response.json();
-          if (status.stalled || (!status.active && !streamError)) {
-            if (stallCheckIntervalRef.current) {
-              clearInterval(stallCheckIntervalRef.current);
-              stallCheckIntervalRef.current = null;
-            }
-            setStreamLoading(false);
-            attemptReconnect();
+        const status = await api.getCameraStatus(printerId);
+        if (status.stalled || (!status.active && !streamError)) {
+          if (stallCheckIntervalRef.current) {
+            clearInterval(stallCheckIntervalRef.current);
+            stallCheckIntervalRef.current = null;
           }
+          setStreamLoading(false);
+          attemptReconnect();
         }
       } catch {
         // Ignore errors

+ 2 - 28
frontend/src/components/SpoolmanSettings.tsx

@@ -6,32 +6,6 @@ import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 
-interface SpoolmanSettingsData {
-  spoolman_enabled: string;
-  spoolman_url: string;
-  spoolman_sync_mode: string;
-}
-
-async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
-  const response = await fetch('/api/v1/settings/spoolman');
-  if (!response.ok) {
-    throw new Error('Failed to load Spoolman settings');
-  }
-  return response.json();
-}
-
-async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
-  const response = await fetch('/api/v1/settings/spoolman', {
-    method: 'PUT',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(data),
-  });
-  if (!response.ok) {
-    throw new Error('Failed to save Spoolman settings');
-  }
-  return response.json();
-}
-
 export function SpoolmanSettings() {
   const queryClient = useQueryClient();
   const [localEnabled, setLocalEnabled] = useState(false);
@@ -45,7 +19,7 @@ export function SpoolmanSettings() {
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
     queryKey: ['spoolman-settings'],
-    queryFn: getSpoolmanSettings,
+    queryFn: api.getSpoolmanSettings,
   });
 
   // Fetch Spoolman status
@@ -93,7 +67,7 @@ export function SpoolmanSettings() {
   // Save mutation
   const saveMutation = useMutation({
     mutationFn: () =>
-      updateSpoolmanSettings({
+      api.updateSpoolmanSettings({
         spoolman_enabled: localEnabled ? 'true' : 'false',
         spoolman_url: localUrl,
         spoolman_sync_mode: localSyncMode,

+ 11 - 14
frontend/src/pages/CameraPage.tsx

@@ -224,21 +224,18 @@ export function CameraPage() {
     // Start stall detection after stream has loaded
     stallCheckIntervalRef.current = setInterval(async () => {
       try {
-        const response = await fetch(`/api/v1/printers/${id}/camera/status`);
-        if (response.ok) {
-          const status = await response.json();
-          // Trigger reconnect if:
-          // 1. Backend reports stall (no frames for 10+ seconds)
-          // 2. OR stream is not active anymore (process died)
-          if (status.stalled || (!status.active && !streamError)) {
-            console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
-            if (stallCheckIntervalRef.current) {
-              clearInterval(stallCheckIntervalRef.current);
-              stallCheckIntervalRef.current = null;
-            }
-            setStreamLoading(false);
-            attemptReconnect();
+        const status = await api.getCameraStatus(id);
+        // Trigger reconnect if:
+        // 1. Backend reports stall (no frames for 10+ seconds)
+        // 2. OR stream is not active anymore (process died)
+        if (status.stalled || (!status.active && !streamError)) {
+          console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
+          if (stallCheckIntervalRef.current) {
+            clearInterval(stallCheckIntervalRef.current);
+            stallCheckIntervalRef.current = null;
           }
+          setStreamLoading(false);
+          attemptReconnect();
         }
       } catch {
         // Ignore fetch errors - server might be temporarily unavailable

+ 2 - 10
frontend/src/pages/ProjectDetailPage.tsx

@@ -404,19 +404,11 @@ export function ProjectDetailPage() {
 
   const handleExportProject = async () => {
     try {
-      // Fetch ZIP file directly
-      const response = await fetch(`/api/v1/projects/${projectId}/export`);
-      if (!response.ok) {
-        throw new Error(t('projectDetail.toast.exportFailed'));
-      }
-      const blob = await response.blob();
+      const { blob, filename } = await api.exportProjectZip(Number(projectId));
       const url = URL.createObjectURL(blob);
       const a = document.createElement('a');
       a.href = url;
-      // Get filename from Content-Disposition header or use default
-      const contentDisposition = response.headers.get('Content-Disposition');
-      const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
-      a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
+      a.download = filename || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
       a.click();
       URL.revokeObjectURL(url);
       showToast(t('projectDetail.toast.projectExported'), 'success');

+ 1 - 13
frontend/src/pages/ProjectsPage.tsx

@@ -681,19 +681,7 @@ export function ProjectsPage() {
 
       if (filename.endsWith('.zip')) {
         // ZIP file: upload via file endpoint
-        const formData = new FormData();
-        formData.append('file', file);
-
-        const response = await fetch('/api/v1/projects/import/file', {
-          method: 'POST',
-          body: formData,
-        });
-
-        if (!response.ok) {
-          const errorData = await response.json();
-          throw new Error(errorData.detail || 'Import failed');
-        }
-
+        await api.importProjectFile(file);
         queryClient.invalidateQueries({ queryKey: ['projects'] });
         showToast(t('projects.toast.imported'), 'success');
       } else {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C_9mu6sD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DYRi1tMz.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DOAYfAw5.js"></script>
+    <script type="module" crossorigin src="/assets/index-C_9mu6sD.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DME4t7XG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов