Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
188 changes: 188 additions & 0 deletions calm-hub-ui/src/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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(),
},
}));

vi.mock('./authConfig.js', () => ({
fetchAuthConfig: vi.fn().mockResolvedValue({ oidc: { enabled: false }, github: { enabled: false } }),
isGitHubLinkingEnabled: vi.fn().mockReturnValue(false),
}));

vi.mock('axios');

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();
});
});
});
56 changes: 56 additions & 0 deletions calm-hub-ui/src/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { User } from 'oidc-client-ts';
import { authService } from './authService.js';
import { fetchAuthConfig, isGitHubLinkingEnabled } from './authConfig.js';
import axios from 'axios';

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);
const [githubLinked, setGithubLinked] = useState<boolean | null>(null);

useEffect(() => {
const authenticate = async () => {
Expand All @@ -17,8 +22,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 @@ -27,13 +42,54 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
authenticate();
}, []);

useEffect(() => {
if (!user) {
setGithubLinked(true);
return;
}

const checkGithubLink = async () => {
await fetchAuthConfig();
if (!isGitHubLinkingEnabled()) {
setGithubLinked(true);
return;
}

try {
const token = user.id_token || user.access_token;
const response = await axios.get('/api/calm/github/status', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.data.linked) {
sessionStorage.removeItem('calm_gh_link_attempted');
setGithubLinked(true);
} else if (sessionStorage.getItem('calm_gh_link_attempted')) {
setGithubLinked(true);
} else {
sessionStorage.setItem('calm_gh_link_attempted', 'true');
const username = user.profile?.preferred_username || user.profile?.email || '';
window.location.href = `/api/calm/github/link?user=${encodeURIComponent(username)}`;
}
} catch {
setGithubLinked(true);
}
};

checkGithubLink();
}, [user]);

if (loading) {
return <div>Loading...</div>;
}

if (!user) {
return <div>Redirecting to login...</div>;
}

if (githubLinked === null) {
return <div>Checking GitHub link...</div>;
}

return <>{children}</>;
};
export default ProtectedRoute;
70 changes: 70 additions & 0 deletions calm-hub-ui/src/authConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
import { getAuthConfig, isOidcEnabled, isGitHubMode, isGitHubLinkingEnabled } 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'] },
github: { enabled: true, oauthClientId: 'gh-client' },
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.github.enabled).toBe(true);
expect(result.databaseMode).toBe('github');
});

it('should return defaults when fetch fails', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('Network error'));

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

expect(result.oidc.enabled).toBe(false);
expect(result.github.enabled).toBe(false);
expect(result.databaseMode).toBe('mongo');
});
});

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

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