mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-01-14 20:11:17 +01:00
Moar Login Fixes (#4948)
This commit is contained in:
parent
76f2fd3b76
commit
fca8470637
@ -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")
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
288
frontend/src/core/contexts/AppConfigContext.test.tsx
Normal file
288
frontend/src/core/contexts/AppConfigContext.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
|
||||
354
frontend/src/proprietary/auth/springAuthClient.test.ts
Normal file
354
frontend/src/proprietary/auth/springAuthClient.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
177
frontend/src/proprietary/routes/AuthCallback.test.tsx
Normal file
177
frontend/src/proprietary/routes/AuthCallback.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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={{
|
||||
|
||||
473
frontend/src/proprietary/routes/Login.test.tsx
Normal file
473
frontend/src/proprietary/routes/Login.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user