|
@@ -2,7 +2,7 @@
|
|
|
* Tests for the AuthContext permission helpers.
|
|
* Tests for the AuthContext permission helpers.
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
|
|
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
@@ -11,7 +11,7 @@ import { server } from '../mocks/server';
|
|
|
import { AuthProvider, useAuth } from '../../contexts/AuthContext';
|
|
import { AuthProvider, useAuth } from '../../contexts/AuthContext';
|
|
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
|
|
import { ToastProvider } from '../../contexts/ToastContext';
|
|
import { ToastProvider } from '../../contexts/ToastContext';
|
|
|
-import { setAuthToken, type Permission } from '../../api/client';
|
|
|
|
|
|
|
+import { getAuthToken, setAuthToken, type Permission } from '../../api/client';
|
|
|
|
|
|
|
|
function createWrapper() {
|
|
function createWrapper() {
|
|
|
const queryClient = new QueryClient({
|
|
const queryClient = new QueryClient({
|
|
@@ -325,4 +325,76 @@ describe('AuthContext', () => {
|
|
|
}).not.toThrow();
|
|
}).not.toThrow();
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+ describe('token validation on mount (#1889)', () => {
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ // Persisted "Remember Me" token — lives in localStorage.
|
|
|
|
|
+ setAuthToken('valid-token', 'persistent');
|
|
|
|
|
+ server.use(
|
|
|
|
|
+ http.get('/api/v1/auth/status', () =>
|
|
|
|
|
+ HttpResponse.json({ auth_enabled: true, requires_setup: false })
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(() => {
|
|
|
|
|
+ setAuthToken(null);
|
|
|
|
|
+ localStorage.removeItem('auth_token');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('keeps the persisted token when /auth/me fails transiently (does not force re-login)', async () => {
|
|
|
|
|
+ // Backend not ready yet / brief blip → 500 on every attempt.
|
|
|
|
|
+ server.use(http.get('/api/v1/auth/me', () => new HttpResponse(null, { status: 500 })));
|
|
|
|
|
+ vi.mocked(window.localStorage.removeItem).mockClear();
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 });
|
|
|
|
|
+
|
|
|
|
|
+ // No user this load, but the token MUST survive so a reload can recover —
|
|
|
|
|
+ // the pre-#1889 blanket catch deleted it, making the session unrecoverable.
|
|
|
|
|
+ expect(result.current.user).toBeNull();
|
|
|
|
|
+ expect(getAuthToken()).toBe('valid-token');
|
|
|
|
|
+ // Persisted copy must not be wiped (setAuthToken(null) removes it).
|
|
|
|
|
+ expect(window.localStorage.removeItem).not.toHaveBeenCalledWith('auth_token');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('clears the token on a definitive 401 invalid-token response', async () => {
|
|
|
|
|
+ server.use(
|
|
|
|
|
+ http.get('/api/v1/auth/me', () =>
|
|
|
|
|
+ HttpResponse.json({ detail: 'Could not validate credentials' }, { status: 401 })
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+ vi.mocked(window.localStorage.removeItem).mockClear();
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 });
|
|
|
|
|
+
|
|
|
|
|
+ expect(result.current.user).toBeNull();
|
|
|
|
|
+ expect(getAuthToken()).toBeNull();
|
|
|
|
|
+ // Definitive invalid-token → persisted copy is removed.
|
|
|
|
|
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('auth_token');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('loads the user when the persisted token is valid', async () => {
|
|
|
|
|
+ server.use(
|
|
|
|
|
+ http.get('/api/v1/auth/me', () =>
|
|
|
|
|
+ HttpResponse.json({
|
|
|
|
|
+ id: 1,
|
|
|
|
|
+ username: 'alice',
|
|
|
|
|
+ is_active: true,
|
|
|
|
|
+ permissions: [],
|
|
|
|
|
+ groups: [],
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => expect(result.current.user).not.toBeNull());
|
|
|
|
|
+ expect(result.current.user?.username).toBe('alice');
|
|
|
|
|
+ expect(getAuthToken()).toBe('valid-token');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|