client.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. /**
  2. * Tests for the API client auth token handling.
  3. */
  4. import { describe, it, expect, afterEach, vi } from 'vitest';
  5. import { http, HttpResponse } from 'msw';
  6. import { setupServer } from 'msw/node';
  7. import { setAuthToken, getAuthToken, api } from '../../api/client';
  8. // Mock localStorage
  9. const localStorageMock = {
  10. store: {} as Record<string, string>,
  11. getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
  12. setItem: vi.fn((key: string, value: string) => {
  13. localStorageMock.store[key] = value;
  14. }),
  15. removeItem: vi.fn((key: string) => {
  16. delete localStorageMock.store[key];
  17. }),
  18. clear: vi.fn(() => {
  19. localStorageMock.store = {};
  20. }),
  21. };
  22. Object.defineProperty(window, 'localStorage', {
  23. value: localStorageMock,
  24. });
  25. // Create MSW server
  26. const server = setupServer();
  27. beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
  28. afterEach(() => {
  29. server.resetHandlers();
  30. localStorageMock.clear();
  31. setAuthToken(null);
  32. });
  33. afterAll(() => server.close());
  34. describe('Auth Token Management', () => {
  35. it('setAuthToken stores token in localStorage', () => {
  36. setAuthToken('test-token-123');
  37. expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
  38. expect(getAuthToken()).toBe('test-token-123');
  39. });
  40. it('setAuthToken removes token from localStorage when null', () => {
  41. setAuthToken('test-token-123');
  42. setAuthToken(null);
  43. expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
  44. expect(getAuthToken()).toBeNull();
  45. });
  46. });
  47. describe('API Client Auth Header', () => {
  48. it('includes Authorization header when token is set', async () => {
  49. let capturedHeaders: Headers | null = null;
  50. server.use(
  51. http.get('/api/v1/settings/spoolman', ({ request }) => {
  52. capturedHeaders = request.headers;
  53. return HttpResponse.json({
  54. spoolman_enabled: 'false',
  55. spoolman_url: '',
  56. spoolman_sync_mode: 'auto',
  57. });
  58. })
  59. );
  60. setAuthToken('test-jwt-token');
  61. await api.getSpoolmanSettings();
  62. expect(capturedHeaders).not.toBeNull();
  63. expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-jwt-token');
  64. });
  65. it('does not include Authorization header when token is not set', async () => {
  66. let capturedHeaders: Headers | null = null;
  67. server.use(
  68. http.get('/api/v1/settings/spoolman', ({ request }) => {
  69. capturedHeaders = request.headers;
  70. return HttpResponse.json({
  71. spoolman_enabled: 'false',
  72. spoolman_url: '',
  73. spoolman_sync_mode: 'auto',
  74. });
  75. })
  76. );
  77. setAuthToken(null);
  78. await api.getSpoolmanSettings();
  79. expect(capturedHeaders).not.toBeNull();
  80. expect(capturedHeaders!.get('Authorization')).toBeNull();
  81. });
  82. it('clears token on 401 with invalid token message', async () => {
  83. server.use(
  84. http.get('/api/v1/settings/spoolman', () => {
  85. return HttpResponse.json(
  86. { detail: 'Could not validate credentials' },
  87. { status: 401 }
  88. );
  89. })
  90. );
  91. setAuthToken('expired-token');
  92. expect(getAuthToken()).toBe('expired-token');
  93. try {
  94. await api.getSpoolmanSettings();
  95. } catch {
  96. // Expected to throw
  97. }
  98. expect(getAuthToken()).toBeNull();
  99. expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
  100. });
  101. it('does not clear token on 401 with generic auth error', async () => {
  102. server.use(
  103. http.get('/api/v1/settings/spoolman', () => {
  104. return HttpResponse.json(
  105. { detail: 'Authentication required' },
  106. { status: 401 }
  107. );
  108. })
  109. );
  110. setAuthToken('valid-token');
  111. expect(getAuthToken()).toBe('valid-token');
  112. try {
  113. await api.getSpoolmanSettings();
  114. } catch {
  115. // Expected to throw
  116. }
  117. // Token should NOT be cleared for generic auth errors (might be timing issue)
  118. expect(getAuthToken()).toBe('valid-token');
  119. });
  120. });
  121. describe('FormData requests include auth header', () => {
  122. it('importProjectFile includes Authorization header', async () => {
  123. // Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)
  124. const originalFetch = global.fetch;
  125. let capturedHeaders: Headers | null = null;
  126. global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
  127. if (url.includes('/projects/import/file')) {
  128. capturedHeaders = new Headers(init?.headers);
  129. return Promise.resolve(new Response(JSON.stringify({
  130. id: 1,
  131. name: 'Test Project',
  132. description: '',
  133. total_cost: 0,
  134. total_print_time_seconds: 0,
  135. total_prints: 0,
  136. total_quantity: 0,
  137. status: 'active',
  138. due_date: null,
  139. created_at: '2026-01-01T00:00:00Z',
  140. updated_at: '2026-01-01T00:00:00Z',
  141. archives: [],
  142. bom_items: [],
  143. }), { status: 200 }));
  144. }
  145. return originalFetch(url, init);
  146. });
  147. try {
  148. setAuthToken('test-token');
  149. const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
  150. await api.importProjectFile(file);
  151. expect(capturedHeaders).not.toBeNull();
  152. expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
  153. } finally {
  154. global.fetch = originalFetch;
  155. }
  156. });
  157. it('exportProjectZip includes Authorization header', async () => {
  158. let capturedHeaders: Headers | null = null;
  159. server.use(
  160. http.get('/api/v1/projects/:projectId/export', ({ request }) => {
  161. capturedHeaders = request.headers;
  162. const zipContent = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic bytes
  163. return new HttpResponse(zipContent, {
  164. status: 200,
  165. headers: {
  166. 'Content-Type': 'application/zip',
  167. 'Content-Disposition': 'attachment; filename="project.zip"',
  168. },
  169. });
  170. })
  171. );
  172. setAuthToken('test-token');
  173. await api.exportProjectZip(1);
  174. expect(capturedHeaders).not.toBeNull();
  175. expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
  176. });
  177. });