Moar Login Fixes (#4948)

This commit is contained in:
Dario Ghunney Ware 2025-11-20 20:51:53 +00:00 committed by GitHub
parent 76f2fd3b76
commit fca8470637
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1368 additions and 26 deletions

View File

@ -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")

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -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 }) => (
<AppConfigProvider>{children}</AppConfigProvider>
);
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 }) => (
<AppConfigProvider initialConfig={initialConfig}>
{children}
</AppConfigProvider>
);
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,
});
});
});
});

View File

@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
}
// apiClient automatically adds JWT header if available via interceptors
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
// Always suppress error toast - we handle 401 errors locally
const response = await apiClient.get<AppConfig>('/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<AppConfigProviderProps> = ({
}, [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();
}

View File

@ -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();
});
});
});

View File

@ -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;

View File

@ -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(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
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(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
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(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
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(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
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(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
expect(getByText('Completing authentication...')).toBeInTheDocument();
});
});

View File

@ -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 (
<div style={{

View File

@ -0,0 +1,473 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
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 }) => (
<MantineProvider>{children}</MantineProvider>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// 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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// 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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<MemoryRouter initialEntries={['/login?expired=true']}>
<Login />
</MemoryRouter>
</TestWrapper>
);
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
});
it('should display account created success message', () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={['/login?messageType=accountCreated']}>
<Login />
</MemoryRouter>
</TestWrapper>
);
expect(screen.getByText(/account created/i)).toBeInTheDocument();
});
it('should prefill email from query param', () => {
const email = 'prefilled@example.com';
render(
<TestWrapper>
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
<Login />
</MemoryRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
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();
});
});
});

View File

@ -30,6 +30,14 @@ export default function Login() {
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(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 () => {

View File

@ -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<string | null>(null);
const [email, setEmail] = useState('');
@ -24,6 +26,14 @@ export default function Signup() {
const [confirmPassword, setConfirmPassword] = useState('');
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// 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