From fca8470637700408ea24688c57a2c769c43d1fcf Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 20 Nov 2025 20:51:53 +0000 Subject: [PATCH] Moar Login Fixes (#4948) --- .../SPDF/config/WAUTrackingFilter.java | 10 +- .../controller/web/MetricsController.java | 3 +- .../service/WeeklyActiveUsersService.java | 17 +- .../security/service/JwtService.java | 2 +- .../core/contexts/AppConfigContext.test.tsx | 288 +++++++++++ .../src/core/contexts/AppConfigContext.tsx | 24 +- .../proprietary/auth/springAuthClient.test.ts | 354 +++++++++++++ .../src/proprietary/auth/springAuthClient.ts | 2 + .../proprietary/routes/AuthCallback.test.tsx | 177 +++++++ .../src/proprietary/routes/AuthCallback.tsx | 24 +- .../src/proprietary/routes/Login.test.tsx | 473 ++++++++++++++++++ frontend/src/proprietary/routes/Login.tsx | 8 + frontend/src/proprietary/routes/Signup.tsx | 12 +- 13 files changed, 1368 insertions(+), 26 deletions(-) create mode 100644 frontend/src/core/contexts/AppConfigContext.test.tsx create mode 100644 frontend/src/proprietary/auth/springAuthClient.test.ts create mode 100644 frontend/src/proprietary/routes/AuthCallback.test.tsx create mode 100644 frontend/src/proprietary/routes/Login.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java index 08362fa0b..5cb48c76f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java @@ -2,6 +2,9 @@ package stirling.software.SPDF.config; import java.io.IOException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -9,17 +12,14 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.service.WeeklyActiveUsersService; /** - * Filter to track browser IDs for Weekly Active Users (WAU) counting. - * Only active when security is disabled (no-login mode). + * Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is + * disabled (no-login mode). */ @Component @ConditionalOnProperty(name = "security.enableLogin", havingValue = "false") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 53e60a6b5..92b3de92f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -369,7 +369,8 @@ public class MetricsController { // Check if WAU service is available (only when security.enableLogin=false) if (wauService.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("WAU tracking is only available when security is disabled (no-login mode)"); + .body( + "WAU tracking is only available when security is disabled (no-login mode)"); } WeeklyActiveUsersService service = wauService.get(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java index ddf3a7b26..3b1ae1d04 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -10,8 +10,8 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; /** - * Service for tracking Weekly Active Users (WAU) in no-login mode. - * Uses in-memory storage with automatic cleanup of old entries. + * Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with + * automatic cleanup of old entries. */ @Service @Slf4j @@ -28,6 +28,7 @@ public class WeeklyActiveUsersService { /** * Records a browser access with the current timestamp + * * @param browserId Unique browser identifier from X-Browser-Id header */ public void recordBrowserAccess(String browserId) { @@ -46,6 +47,7 @@ public class WeeklyActiveUsersService { /** * Gets the count of unique browsers seen in the last 7 days + * * @return Weekly Active Users count */ public long getWeeklyActiveUsers() { @@ -55,6 +57,7 @@ public class WeeklyActiveUsersService { /** * Gets the total count of unique browsers ever seen + * * @return Total unique browsers count */ public long getTotalUniqueBrowsers() { @@ -63,6 +66,7 @@ public class WeeklyActiveUsersService { /** * Gets the number of days the service has been running + * * @return Days online */ public long getDaysOnline() { @@ -71,23 +75,20 @@ public class WeeklyActiveUsersService { /** * Gets the timestamp when tracking started + * * @return Start time */ public Instant getStartTime() { return startTime; } - /** - * Removes entries older than 7 days - */ + /** Removes entries older than 7 days */ private void cleanupOldEntries() { Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS); activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); } - /** - * Manual cleanup trigger (can be called by scheduled task if needed) - */ + /** Manual cleanup trigger (can be called by scheduled task if needed) */ public void performCleanup() { int sizeBefore = activeBrowsers.size(); cleanupOldEntries(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a65c79665..061b063aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; - private static final long EXPIRATION = 3600000; + private static final long EXPIRATION = 43200000; private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; diff --git a/frontend/src/core/contexts/AppConfigContext.test.tsx b/frontend/src/core/contexts/AppConfigContext.test.tsx new file mode 100644 index 000000000..63a953d8a --- /dev/null +++ b/frontend/src/core/contexts/AppConfigContext.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { waitFor, renderHook, act } from '@testing-library/react'; +import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext'; +import apiClient from '@app/services/apiClient'; +import { ReactNode } from 'react'; + +// Mock apiClient +vi.mock('@app/services/apiClient'); + +describe('AppConfigContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock window.location.pathname + Object.defineProperty(window, 'location', { + value: { pathname: '/' }, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('should fetch and provide app config on non-auth pages', async () => { + const mockConfig = { + enableLogin: false, + appNameNavbar: 'Stirling PDF', + languages: ['en-US', 'en-GB'], + }; + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + // Initially loading + expect(result.current.loading).toBe(true); + expect(result.current.config).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual(mockConfig); + expect(result.current.error).toBeNull(); + }); + + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { + suppressErrorToast: true, + }); + }); + + it('should skip fetch on auth pages and use default config', async () => { + // Mock being on login page + Object.defineProperty(window, 'location', { + value: { pathname: '/login' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + // Should NOT call API on auth pages + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should handle 401 error gracefully', async () => { + const mockError = Object.assign(new Error('Unauthorized'), { + response: { status: 401, data: {} }, + }); + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + // 401 should be handled gracefully, error may be null or set + }); + }); + + it('should handle network errors', async () => { + const errorMessage = 'Network error occurred'; + const mockError = new Error(errorMessage); + // Network errors don't have response property + // Mock rejection for all retry attempts (default is 3 attempts) + vi.mocked(apiClient.get) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + expect(result.current.error).toBe(errorMessage); + }); + }); + + it('should skip fetch on signup page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/signup' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should skip fetch on auth callback page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/auth/callback' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should skip fetch on invite accept page', async () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/invite/abc123' }, + writable: true, + }); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.config).toEqual({ enableLogin: true }); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should refetch config when jwt-available event is triggered', async () => { + const initialConfig = { + enableLogin: true, + appNameNavbar: 'Stirling PDF', + }; + + const updatedConfig = { + enableLogin: true, + appNameNavbar: 'Stirling PDF', + isAdmin: true, + enableAnalytics: true, + }; + + // First call returns initial config + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: initialConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(initialConfig); + }); + + // Setup second call for refetch + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: updatedConfig, + } as any); + + // Trigger jwt-available event wrapped in act + await act(async () => { + window.dispatchEvent(new CustomEvent('jwt-available')); + // Wait a tick for event handler to run + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => { + expect(result.current.config).toEqual(updatedConfig); + }); + + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); + + it('should provide refetch function', async () => { + const mockConfig = { + enableLogin: false, + appNameNavbar: 'Test App', + }; + + vi.mocked(apiClient.get).mockResolvedValue({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(mockConfig); + }); + + // Call refetch wrapped in act + await act(async () => { + await result.current.refetch(); + }); + + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); + + it('should not fetch twice without force flag', async () => { + const mockConfig = { + enableLogin: false, + }; + + vi.mocked(apiClient.get).mockResolvedValue({ + status: 200, + data: mockConfig, + } as any); + + const { result } = renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(result.current.config).toEqual(mockConfig); + }); + + // Should only be called once (no duplicate fetches) + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + it('should handle initial config prop', async () => { + const initialConfig = { + enableLogin: false, + appNameNavbar: 'Initial App', + }; + + const customWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAppConfig(), { + wrapper: customWrapper, + }); + + // With blocking mode (default), should still fetch even with initial config + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Should still make API call + expect(apiClient.get).toHaveBeenCalled(); + }); + + it('should use suppressErrorToast for all config requests', async () => { + const mockConfig = { enableLogin: true }; + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: mockConfig, + } as any); + + renderHook(() => useAppConfig(), { wrapper }); + + await waitFor(() => { + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', { + suppressErrorToast: true, + }); + }); + }); +}); diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 2f672da26..73e50e6ed 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC = ({ } // apiClient automatically adds JWT header if available via interceptors - const response = await apiClient.get('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined); + // Always suppress error toast - we handle 401 errors locally + const response = await apiClient.get('/api/v1/config/app-config', { suppressErrorToast: true }); const data = response.data; console.debug('[AppConfig] Config fetched successfully:', data); @@ -159,8 +160,25 @@ export const AppConfigProvider: React.FC = ({ }, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]); useEffect(() => { - // Always try to fetch config to check if login is disabled - // The endpoint should be public and return proper JSON + // Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*) + // Config will be fetched after successful authentication via jwt-available event + const currentPath = window.location.pathname; + const isAuthPage = currentPath.includes('/login') || + currentPath.includes('/signup') || + currentPath.includes('/auth/callback') || + currentPath.includes('/invite/'); + + // On auth pages, always skip the config fetch + // The config will be fetched after authentication via jwt-available event + if (isAuthPage) { + console.debug('[AppConfig] On auth page - using default config, skipping fetch'); + setConfig({ enableLogin: true }); + setHasResolvedConfig(true); + setLoading(false); + return; + } + + // On non-auth pages, fetch config (will validate JWT if present) if (autoFetch) { fetchConfig(); } diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts new file mode 100644 index 000000000..5c6af7a75 --- /dev/null +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { springAuth } from '@app/auth/springAuthClient'; +import apiClient from '@app/services/apiClient'; +import { AxiosError } from 'axios'; + +// Mock apiClient +vi.mock('@app/services/apiClient'); + +describe('SpringAuthClient', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + // Clear all mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getSession', () => { + it('should return null session when no JWT in localStorage', async () => { + const result = await springAuth.getSession(); + + expect(result.data.session).toBeNull(); + expect(result.error).toBeNull(); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('should validate JWT and return session when JWT exists', async () => { + const mockToken = 'mock-jwt-token'; + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }; + + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.get).mockResolvedValueOnce({ + status: 200, + data: { user: mockUser }, + } as any); + + const result = await springAuth.getSession(); + + expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', { + headers: { Authorization: `Bearer ${mockToken}` }, + suppressErrorToast: true, + }); + expect(result.data.session).toBeTruthy(); + expect(result.data.session?.user).toEqual(mockUser); + expect(result.data.session?.access_token).toBe(mockToken); + expect(result.error).toBeNull(); + }); + + it('should clear invalid JWT on 401 error', async () => { + const mockToken = 'invalid-jwt-token'; + localStorage.setItem('stirling_jwt', mockToken); + + const mockError = new AxiosError( + 'Unauthorized', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { + status: 401, + statusText: 'Unauthorized', + data: {}, + headers: {}, + config: {} as any, + } + ); + + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const result = await springAuth.getSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + // 401 is handled gracefully, so error should be null + expect(result.error).toBeNull(); + }); + + it('should clear invalid JWT on 403 error', async () => { + const mockToken = 'forbidden-jwt-token'; + localStorage.setItem('stirling_jwt', mockToken); + + const mockError = new AxiosError( + 'Forbidden', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { + status: 403, + statusText: 'Forbidden', + data: {}, + headers: {}, + config: {} as any, + } + ); + + vi.mocked(apiClient.get).mockRejectedValueOnce(mockError); + + const result = await springAuth.getSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + // 403 is handled gracefully, so error should be null + expect(result.error).toBeNull(); + }); + }); + + describe('signInWithPassword', () => { + it('should successfully sign in with email and password', async () => { + const credentials = { + email: 'test@example.com', + password: 'password123', + }; + + const mockToken = 'new-jwt-token'; + const mockUser = { + id: '123', + email: credentials.email, + username: credentials.email, + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { + user: mockUser, + session: { + access_token: mockToken, + expires_in: 3600, + }, + }, + } as any); + + // Spy on window.dispatchEvent + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const result = await springAuth.signInWithPassword(credentials); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/auth/login', + { + username: credentials.email, + password: credentials.password, + }, + { withCredentials: true } + ); + expect(localStorage.getItem('stirling_jwt')).toBe(mockToken); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + expect(result.user).toEqual(mockUser); + expect(result.session?.access_token).toBe(mockToken); + expect(result.error).toBeNull(); + }); + + it('should return error on failed login', async () => { + const credentials = { + email: 'wrong@example.com', + password: 'wrongpassword', + }; + + const errorMessage = 'Invalid credentials'; + const mockError = Object.assign(new Error(errorMessage), { + isAxiosError: true, + response: { + status: 401, + data: { message: errorMessage }, + }, + }); + + vi.mocked(apiClient.post).mockRejectedValueOnce(mockError); + + const result = await springAuth.signInWithPassword(credentials); + + expect(result.user).toBeNull(); + expect(result.session).toBeNull(); + expect(result.error).toBeTruthy(); + expect(result.error?.message).toBe(errorMessage); + }); + }); + + describe('signUp', () => { + it('should successfully register new user', async () => { + const credentials = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + + const mockUser = { + id: '456', + email: credentials.email, + username: credentials.email, + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { user: mockUser }, + } as any); + + const result = await springAuth.signUp(credentials); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/user/register', + { + username: credentials.email, + password: credentials.password, + }, + { withCredentials: true } + ); + expect(result.user).toEqual(mockUser); + expect(result.session).toBeNull(); // No auto-login on signup + expect(result.error).toBeNull(); + }); + + it('should return error on failed registration', async () => { + const credentials = { + email: 'existing@example.com', + password: 'password123', + }; + + const errorMessage = 'User already exists'; + const mockError = Object.assign(new Error(errorMessage), { + isAxiosError: true, + response: { + status: 409, + data: { message: errorMessage }, + }, + }); + + vi.mocked(apiClient.post).mockRejectedValueOnce(mockError); + + const result = await springAuth.signUp(credentials); + + expect(result.user).toBeNull(); + expect(result.session).toBeNull(); + expect(result.error).toBeTruthy(); + expect(result.error?.message).toBe(errorMessage); + }); + }); + + describe('signOut', () => { + it('should successfully sign out and clear JWT', async () => { + const mockToken = 'jwt-to-clear'; + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: {}, + } as any); + + const result = await springAuth.signOut(); + + expect(apiClient.post).toHaveBeenCalledWith( + '/api/v1/auth/logout', + null, + expect.objectContaining({ withCredentials: true }) + ); + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.error).toBeNull(); + }); + + it('should clear JWT even if logout request fails', async () => { + const mockToken = 'jwt-to-clear'; + localStorage.setItem('stirling_jwt', mockToken); + + vi.mocked(apiClient.post).mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500 }, + message: 'Server error', + }); + + const result = await springAuth.signOut(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.error).toBeTruthy(); + }); + }); + + describe('refreshSession', () => { + it('should refresh JWT token successfully', async () => { + const newToken = 'refreshed-jwt-token'; + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }; + + vi.mocked(apiClient.post).mockResolvedValueOnce({ + status: 200, + data: { + user: mockUser, + session: { + access_token: newToken, + expires_in: 3600, + }, + }, + } as any); + + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const result = await springAuth.refreshSession(); + + expect(localStorage.getItem('stirling_jwt')).toBe(newToken); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + expect(result.data.session?.access_token).toBe(newToken); + expect(result.error).toBeNull(); + }); + + it('should clear JWT and return error on 401', async () => { + localStorage.setItem('stirling_jwt', 'expired-token'); + + vi.mocked(apiClient.post).mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + message: 'Token expired', + }); + + const result = await springAuth.refreshSession(); + + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + expect(result.data.session).toBeNull(); + expect(result.error).toBeTruthy(); + }); + }); + + describe('signInWithOAuth', () => { + it('should redirect to OAuth provider', async () => { + const mockAssign = vi.fn(); + Object.defineProperty(window, 'location', { + value: { assign: mockAssign }, + writable: true, + }); + + const result = await springAuth.signInWithOAuth({ + provider: 'github', + options: { redirectTo: '/auth/callback' }, + }); + + expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github'); + expect(result.error).toBeNull(); + }); + }); +}); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 390ea28e5..5aec9625e 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -134,6 +134,7 @@ class SpringAuthClient { headers: { 'Authorization': `Bearer ${token}`, }, + suppressErrorToast: true, // Suppress global error handler (we handle errors locally) }); console.debug('[SpringAuth] /me response status:', response.status); @@ -314,6 +315,7 @@ class SpringAuthClient { 'X-XSRF-TOKEN': this.getCsrfToken() || '', }, withCredentials: true, + suppressErrorToast: true, // Suppress global error handler (we handle errors locally) }); const data = response.data; diff --git a/frontend/src/proprietary/routes/AuthCallback.test.tsx b/frontend/src/proprietary/routes/AuthCallback.test.tsx new file mode 100644 index 000000000..25bca74f6 --- /dev/null +++ b/frontend/src/proprietary/routes/AuthCallback.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import AuthCallback from '@app/routes/AuthCallback'; +import { springAuth } from '@app/auth/springAuthClient'; + +// Mock springAuth +vi.mock('@app/auth/springAuthClient', () => ({ + springAuth: { + getSession: vi.fn(), + }, +})); + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('AuthCallback', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + // Reset window.location.hash + window.location.hash = ''; + }); + + it('should extract JWT from URL hash and validate it', async () => { + const mockToken = 'oauth-jwt-token'; + const mockUser = { + id: '123', + email: 'oauth@example.com', + username: 'oauthuser', + role: 'USER', + }; + + // Set URL hash with access token + window.location.hash = `#access_token=${mockToken}`; + + // Mock successful session validation + vi.mocked(springAuth.getSession).mockResolvedValueOnce({ + data: { + session: { + user: mockUser, + access_token: mockToken, + expires_in: 3600, + expires_at: Date.now() + 3600000, + }, + }, + error: null, + }); + + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + render( + + + + ); + + await waitFor(() => { + // Verify JWT was stored + expect(localStorage.getItem('stirling_jwt')).toBe(mockToken); + + // Verify jwt-available event was dispatched + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'jwt-available' }) + ); + + // Verify getSession was called to validate token + expect(springAuth.getSession).toHaveBeenCalled(); + + // Verify navigation to home + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); + }); + }); + + it('should redirect to login when no access token in hash', async () => { + // No hash or empty hash + window.location.hash = ''; + + render( + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed - no token received.' }, + }); + expect(localStorage.getItem('stirling_jwt')).toBeNull(); + }); + }); + + it('should redirect to login when token validation fails', async () => { + const invalidToken = 'invalid-oauth-token'; + window.location.hash = `#access_token=${invalidToken}`; + + // Mock failed session validation + vi.mocked(springAuth.getSession).mockResolvedValueOnce({ + data: { session: null }, + error: { message: 'Invalid token' }, + }); + + render( + + + + ); + + await waitFor(() => { + // JWT should be stored initially + expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure + + // Verify redirect to login + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed - invalid token.' }, + }); + }); + }); + + it('should handle errors gracefully', async () => { + const mockToken = 'error-token'; + window.location.hash = `#access_token=${mockToken}`; + + // Mock getSession throwing error + vi.mocked(springAuth.getSession).mockRejectedValueOnce( + new Error('Network error') + ); + + render( + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { + replace: true, + state: { error: 'OAuth login failed. Please try again.' }, + }); + }); + }); + + it('should display loading state while processing', () => { + window.location.hash = '#access_token=processing-token'; + + vi.mocked(springAuth.getSession).mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: { session: null }, + error: { message: 'Token expired' }, + }), + 100 + ) + ) + ); + + const { getByText } = render( + + + + ); + + expect(getByText('Completing authentication...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 0c7128368..488a54146 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '@app/auth/UseSession'; +import { springAuth } from '@app/auth/springAuthClient'; /** * OAuth Callback Handler @@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession'; */ export default function AuthCallback() { const navigate = useNavigate(); - const { refreshSession } = useAuth(); useEffect(() => { const handleCallback = async () => { @@ -37,12 +36,23 @@ export default function AuthCallback() { console.log('[AuthCallback] JWT stored in localStorage'); // Dispatch custom event for other components to react to JWT availability - window.dispatchEvent(new CustomEvent('jwt-available')) + window.dispatchEvent(new CustomEvent('jwt-available')); - // Refresh session to load user info into state - await refreshSession(); + // Validate the token and load user info + // This calls /api/v1/auth/me with the JWT to get user details + const { data, error } = await springAuth.getSession(); - console.log('[AuthCallback] Session refreshed, redirecting to home'); + if (error || !data.session) { + console.error('[AuthCallback] Failed to validate token:', error); + localStorage.removeItem('stirling_jwt'); + navigate('/login', { + replace: true, + state: { error: 'OAuth login failed - invalid token.' } + }); + return; + } + + console.log('[AuthCallback] Token validated, redirecting to home'); // Clear the hash from URL and redirect to home page navigate('/', { replace: true }); @@ -56,7 +66,7 @@ export default function AuthCallback() { }; handleCallback(); - }, [navigate, refreshSession]); + }, [navigate]); return (
({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') return fallback; + return key; + }, + }), +})); + +// Mock useAuth hook +vi.mock('@app/auth/UseSession', () => ({ + useAuth: vi.fn(), +})); + +// Mock springAuth +vi.mock('@app/auth/springAuthClient', () => ({ + springAuth: { + signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), + }, +})); + +// Mock useDocumentMeta +vi.mock('@app/hooks/useDocumentMeta', () => ({ + useDocumentMeta: vi.fn(), +})); + +// Mock fetch for provider list +global.fetch = vi.fn(); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Test wrapper with MantineProvider +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('Login', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default auth state - not logged in + vi.mocked(useAuth).mockReturnValue({ + session: null, + user: null, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + // Mock fetch for login UI data + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: {}, + }), + } as Response); + }); + + it('should render login form', async () => { + render( + + + + + + ); + + await waitFor(() => { + // Check for login form elements - use id since it's more reliable + const emailInput = document.getElementById('email'); + expect(emailInput).toBeTruthy(); + }); + }); + + it('should redirect authenticated user to home', async () => { + const mockSession = { + user: { + id: '123', + email: 'test@example.com', + username: 'testuser', + role: 'USER', + }, + access_token: 'mock-token', + expires_in: 3600, + }; + + vi.mocked(useAuth).mockReturnValue({ + session: mockSession, + user: mockSession.user, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }); + }); + }); + + it('should show loading state while auth is loading', () => { + vi.mocked(useAuth).mockReturnValue({ + session: null, + user: null, + loading: true, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }); + + render( + + + + + + ); + + // Component shouldn't redirect or show form while loading + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should handle email/password login', async () => { + const user = userEvent.setup(); + const mockUser = { + id: '123', + email: 'test@example.com', + username: 'test@example.com', + role: 'USER', + }; + + const mockSession = { + user: mockUser, + access_token: 'new-token', + expires_in: 3600, + }; + + vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({ + user: mockUser, + session: mockSession, + error: null, + }); + + render( + + + + + + ); + + // Wait for form to load + await waitFor(() => { + const emailInput = document.getElementById('email'); + expect(emailInput).toBeTruthy(); + const passwordInput = document.getElementById('password'); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + // Fill in form using getElementById + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + if (!emailInput || !passwordInput) { + throw new Error('Form inputs not found'); + } + + await user.type(emailInput, 'test@example.com'); + await user.type(passwordInput, 'password123'); + + // Submit form - use a more flexible query + // Look for button with type="submit" in the form + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + await waitFor(() => { + expect(springAuth.signInWithPassword).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }); + }); + }); + + it('should show error on failed login', async () => { + const user = userEvent.setup(); + const errorMessage = 'Invalid credentials'; + + vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({ + user: null, + session: null, + error: { message: errorMessage }, + }); + + render( + + + + + + ); + + await waitFor(() => { + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + expect(emailInput).toBeTruthy(); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + await user.type(emailInput, 'wrong@example.com'); + await user.type(passwordInput, 'wrongpassword'); + + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it('should validate empty email and password', async () => { + render( + + + + + + ); + + await waitFor(() => { + expect(document.getElementById('email')).toBeTruthy(); + }, { timeout: 3000 }); + + // Find the submit button + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + + // Button should be disabled when email/password are empty + expect(submitButton).toBeDisabled(); + + // Verify sign in was not called + expect(springAuth.signInWithPassword).not.toHaveBeenCalled(); + }); + + it('should display session expired message from URL param', () => { + render( + + + + + + ); + + expect(screen.getByText(/session.*expired/i)).toBeInTheDocument(); + }); + + it('should display account created success message', () => { + render( + + + + + + ); + + expect(screen.getByText(/account created/i)).toBeInTheDocument(); + }); + + it('should prefill email from query param', () => { + const email = 'prefilled@example.com'; + + render( + + + + + + ); + + waitFor(() => { + const emailInput = document.getElementById('email') as HTMLInputElement; + expect(emailInput.value).toBe(email); + }); + }); + + it('should redirect to home when login disabled', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: false, + providerList: {}, + }), + } as Response); + + render( + + + + + + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('should handle OAuth provider click', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: { + '/oauth2/authorization/github': 'GitHub', + }, + }), + } as Response); + + vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ + error: null, + }); + + render( + + + + + + ); + + await waitFor(() => { + const githubButton = screen.queryByText(/github/i); + if (githubButton) { + expect(githubButton).toBeInTheDocument(); + } + }); + + // Since OAuth buttons might be dynamically rendered based on config, + // we just verify the mock is set up correctly + expect(springAuth.signInWithOAuth).toBeDefined(); + }); + + it('should show email form by default when no SSO providers', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + enableLogin: true, + providerList: {}, // No providers + }), + } as Response); + + render( + + + + + + ); + + await waitFor(() => { + expect(document.getElementById('email')).toBeInTheDocument(); + expect(document.getElementById('password')).toBeInTheDocument(); + }); + }); + + it('should disable submit button while signing in', async () => { + const user = userEvent.setup(); + + vi.mocked(springAuth.signInWithPassword).mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + user: null, + session: null, + error: { message: 'Error' }, + }), + 100 + ) + ) + ); + + render( + + + + + + ); + + await waitFor(() => { + const emailInput = document.getElementById('email'); + const passwordInput = document.getElementById('password'); + expect(emailInput).toBeTruthy(); + expect(passwordInput).toBeTruthy(); + }, { timeout: 3000 }); + + const emailInput = document.getElementById('email') as HTMLInputElement; + const passwordInput = document.getElementById('password') as HTMLInputElement; + + await user.type(emailInput, 'test@example.com'); + await user.type(passwordInput, 'password123'); + + const submitButton = await waitFor(() => { + const buttons = screen.queryAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + if (!submitBtn) { + throw new Error('Submit button not found'); + } + return submitBtn; + }, { timeout: 5000 }); + await user.click(submitButton); + + // Button should be disabled while signing in + expect(submitButton).toBeDisabled(); + + // Wait for completion + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index b50de410f..a64fac500 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -30,6 +30,14 @@ export default function Login() { const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); + // Redirect immediately if user has valid session (JWT already validated by AuthProvider) + useEffect(() => { + if (!loading && session) { + console.debug('[Login] User already authenticated, redirecting to home'); + navigate('/', { replace: true }); + } + }, [session, loading, navigate]); + // Fetch enabled SSO providers and login config from backend useEffect(() => { const fetchProviders = async () => { diff --git a/frontend/src/proprietary/routes/Signup.tsx b/frontend/src/proprietary/routes/Signup.tsx index 192a3a95a..ad1b7dc00 100644 --- a/frontend/src/proprietary/routes/Signup.tsx +++ b/frontend/src/proprietary/routes/Signup.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useDocumentMeta } from '@app/hooks/useDocumentMeta'; +import { useAuth } from '@app/auth/UseSession'; import AuthLayout from '@app/routes/authShared/AuthLayout'; import '@app/routes/authShared/auth.css'; import { BASE_PATH } from '@app/constants/app'; @@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService'; export default function Signup() { const navigate = useNavigate(); const { t } = useTranslation(); + const { session, loading } = useAuth(); const [isSigningUp, setIsSigningUp] = useState(false); const [error, setError] = useState(null); const [email, setEmail] = useState(''); @@ -24,6 +26,14 @@ export default function Signup() { const [confirmPassword, setConfirmPassword] = useState(''); const [fieldErrors, setFieldErrors] = useState({}); + // Redirect immediately if user has valid session (JWT already validated by AuthProvider) + useEffect(() => { + if (!loading && session) { + console.debug('[Signup] User already authenticated, redirecting to home'); + navigate('/', { replace: true }); + } + }, [session, loading, navigate]); + const baseUrl = window.location.origin + BASE_PATH; // Set document meta