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
This commit is contained in:
maziggy
2026-02-03 10:19:28 +01:00
parent 9e44692765
commit 158fa88b7a
12 changed files with 330 additions and 8843 deletions
+178
View File
@@ -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');
});
});
@@ -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 () => {
@@ -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
View File
@@ -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/'),
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>