mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
Moar Login Fixes (#4948)
This commit is contained in:
committed by
GitHub
parent
76f2fd3b76
commit
fca8470637
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user