Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions calm-hub-ui/src/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('./authService.js', () => ({
authService: {
getUser: vi.fn(),
login: vi.fn(),
processRedirect: vi.fn(),
},
}));


import ProtectedRoute from './ProtectedRoute.js';
import { authService } from './authService.js';

const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash';

const fakeUser = {
expired: false,
id_token: 'test-id-token',
access_token: 'test-access-token',
profile: { preferred_username: 'testuser' },
} as unknown as import('oidc-client-ts').User;

describe('ProtectedRoute', () => {
let replaceStateSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
replaceStateSpy = vi.spyOn(window.history, 'replaceState');
});

afterEach(() => {
replaceStateSpy.mockRestore();
Object.defineProperty(window, 'location', {
value: window.location,
writable: true,
});
});

describe('hash preservation before OIDC redirect', () => {
it('saves window.location.hash to sessionStorage before calling login', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.login).mockResolvedValue(undefined);
Object.defineProperty(window, 'location', {
value: { ...window.location, hash: '#/fae-calm/architectures/123/abc', search: '' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

await waitFor(() => {
expect(authService.login).toHaveBeenCalled();
});
expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBe('#/fae-calm/architectures/123/abc');
});

it('does not save an empty hash to sessionStorage', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.login).mockResolvedValue(undefined);
Object.defineProperty(window, 'location', {
value: { ...window.location, hash: '', search: '' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

await waitFor(() => {
expect(authService.login).toHaveBeenCalled();
});
expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull();
});
});

describe('hash restoration after OIDC callback', () => {
it('restores the saved hash after processing the redirect callback', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser);
sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#/fae-calm/architectures/123/abc');
Object.defineProperty(window, 'location', {
value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
'',
'/#/fae-calm/architectures/123/abc'
);
});
expect(sessionStorage.getItem(PRE_AUTH_HASH_KEY)).toBeNull();
});

it('falls back to #/ when no hash was saved', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser);
Object.defineProperty(window, 'location', {
value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/');
});
});

it('falls back to #/ when saved hash is just #', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser);
sessionStorage.setItem(PRE_AUTH_HASH_KEY, '#');
Object.defineProperty(window, 'location', {
value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/#/');
});
});

it('renders children after successful redirect processing', async () => {
vi.mocked(authService.getUser).mockResolvedValue(null);
vi.mocked(authService.processRedirect).mockResolvedValue(fakeUser);
Object.defineProperty(window, 'location', {
value: { ...window.location, search: '?code=AUTH_CODE&state=xyz', hash: '', pathname: '/' },
writable: true,
});

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

expect(await screen.findByText('Protected Content')).toBeInTheDocument();
});
});

describe('already authenticated user', () => {
it('renders children immediately without redirect when session exists', async () => {
vi.mocked(authService.getUser).mockResolvedValue(fakeUser);

render(
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
);

expect(await screen.findByText('Protected Content')).toBeInTheDocument();
expect(authService.login).not.toHaveBeenCalled();
expect(authService.processRedirect).not.toHaveBeenCalled();
});
});
});
13 changes: 13 additions & 0 deletions calm-hub-ui/src/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ interface ProtectedRouteProps {
children: ReactNode;
}

const PRE_AUTH_HASH_KEY = 'calm_pre_auth_hash';

const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -17,8 +19,18 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
setUser(currentUser);
} else if (window.location.search.includes('code=')) {
const loggedInUser = await authService.processRedirect();
const savedHash = sessionStorage.getItem(PRE_AUTH_HASH_KEY);
sessionStorage.removeItem(PRE_AUTH_HASH_KEY);
if (savedHash && savedHash !== '#' && savedHash !== '#/') {
window.history.replaceState(null, '', window.location.pathname + savedHash);
} else {
window.history.replaceState(null, '', window.location.pathname + '#/');
}
setUser(loggedInUser);
} else {
if (window.location.hash) {
sessionStorage.setItem(PRE_AUTH_HASH_KEY, window.location.hash);
}
await authService.login();
}
setLoading(false);
Expand All @@ -34,6 +46,7 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
if (!user) {
return <div>Redirecting to login...</div>;
}

return <>{children}</>;
};
export default ProtectedRoute;
74 changes: 74 additions & 0 deletions calm-hub-ui/src/authConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
import { getAuthConfig, isOidcEnabled, isGitHubMode } from './authConfig.js';

vi.mock('axios');

describe('authConfig', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('fetchAuthConfig', () => {
it('should fetch config from backend', async () => {
const mockConfig = {
oidc: { enabled: true, provider: 'entra-id', authority: 'https://login.microsoft.com/tenant', clientId: 'client-123', scopes: ['openid', 'profile'] },
databaseMode: 'github',
};
vi.mocked(axios.get).mockResolvedValue({ data: mockConfig });

const { fetchAuthConfig: fetch } = await import('./authConfig.js');
const result = await fetch();

expect(result.oidc.enabled).toBe(true);
expect(result.oidc.provider).toBe('entra-id');
expect(result.databaseMode).toBe('github');
});

it('should retry once and succeed on transient failure', async () => {
const mockConfig = {
oidc: { enabled: true, provider: 'generic-oidc' },
databaseMode: 'mongo',
};
vi.mocked(axios.get)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ data: mockConfig });

const { fetchAuthConfig: fetch } = await import('./authConfig.js');
const result = await fetch();

expect(result.oidc.enabled).toBe(true);
});

it('should throw when both attempts fail', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('Network error'));

const { fetchAuthConfig: fetch } = await import('./authConfig.js');

await expect(fetch()).rejects.toThrow('Unable to load authentication configuration');
});
});

describe('getAuthConfig', () => {
it('should return default config before fetch', () => {
const config = getAuthConfig();
expect(config.oidc.enabled).toBe(false);
});
});

describe('isOidcEnabled', () => {
it('should return false when not fetched', () => {
expect(isOidcEnabled()).toBe(false);
});
});

describe('isGitHubMode', () => {
it('should return false when not fetched', () => {
expect(isGitHubMode()).toBe(false);
});
});
});
56 changes: 56 additions & 0 deletions calm-hub-ui/src/authConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import axios from 'axios';

export interface AuthConfig {
oidc: {
enabled: boolean;
provider?: string;
authority?: string;
clientId?: string;
scopes?: string[];
redirectUri?: string;
};
databaseMode: string;
}

const DEFAULT_CONFIG: AuthConfig = {
oidc: { enabled: false },
databaseMode: 'mongo',
};

let cachedConfig: AuthConfig | null = null;

export async function fetchAuthConfig(): Promise<AuthConfig> {
if (cachedConfig) {
return cachedConfig;
}
const attempt = async (): Promise<AuthConfig> => {
const response = await axios.get<AuthConfig>('/api/calm/auth/config');
return response.data;
};
try {
cachedConfig = await attempt();
return cachedConfig;
} catch {
// Retry once after a short delay before giving up
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
cachedConfig = await attempt();
return cachedConfig;
} catch (retryError) {
console.error('Failed to fetch auth config after retry:', retryError);
throw new Error('Unable to load authentication configuration');
}
}
}

export function getAuthConfig(): AuthConfig {
return cachedConfig || DEFAULT_CONFIG;
}

export function isOidcEnabled(): boolean {
return cachedConfig?.oidc?.enabled ?? false;
}

export function isGitHubMode(): boolean {
return cachedConfig?.databaseMode === 'github';
}
6 changes: 6 additions & 0 deletions calm-hub-ui/src/authService.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getAuthHeaders,
isAuthServiceEnabled,
} from './authService.js';
import * as authConfig from './authConfig.js';

vi.mock('axios');

Expand All @@ -16,6 +17,11 @@ describe('authService', () => {

describe('checkAuthorityService', () => {
it('should return true when the authority service responds successfully', async () => {
vi.spyOn(authConfig, 'getAuthConfig').mockReturnValue({
oidc: { enabled: true, authority: 'https://auth.example.com' },
github: { enabled: false },
databaseMode: 'mongo',
});
vi.mocked(axios.head).mockResolvedValue({ status: 200 });
const result = await checkAuthorityService();
expect(result).toBe(true);
Expand Down
Loading
Loading