Skip to content
Merged
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
48 changes: 47 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import App from "./App";
import { attacksApi } from "./services/api";

const mockGetActiveAccount = jest.fn();

// Mock MSAL — App uses useMsal() to wire the instance into the API client
jest.mock("@azure/msal-react", () => ({
useMsal: () => ({ instance: { getActiveAccount: () => null, getAllAccounts: () => [] } }),
useMsal: () => ({ instance: { getActiveAccount: mockGetActiveAccount, getAllAccounts: () => [] } }),
}));

jest.mock("./services/api", () => ({
Expand Down Expand Up @@ -86,17 +88,21 @@ jest.mock("./components/Chat/ChatWindow", () => {
conversationId,
onConversationCreated,
onSelectConversation,
labels,
}: {
onNewAttack: () => void;
activeTarget: unknown;
conversationId: string | null;
onConversationCreated: (attackResultId: string, conversationId: string) => void;
onSelectConversation: (convId: string) => void;
labels: Record<string, string>;
}) => {
return (
<div data-testid="chat-window">
<span data-testid="conversation-id">{conversationId ?? "none"}</span>
<span data-testid="has-target">{activeTarget ? "yes" : "no"}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
<button onClick={onNewAttack} data-testid="new-attack">
New Attack
</button>
Expand Down Expand Up @@ -183,6 +189,11 @@ jest.mock("./components/History/AttackHistory", () => {
});

describe("App", () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetActiveAccount.mockReturnValue(null);
});

it("renders with FluentProvider and MainLayout", () => {
render(<App />);
expect(screen.getByTestId("main-layout")).toBeInTheDocument();
Expand Down Expand Up @@ -337,6 +348,41 @@ describe("App", () => {
await waitFor(() => {
expect(mockedVersionApi.getVersion).toHaveBeenCalled();
});

await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("default_user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
});
});

it("sets operator label from active account alias when backend has no operator", async () => {
mockGetActiveAccount.mockReturnValue({ username: "Test.User@contoso.com" });
mockedVersionApi.getVersion.mockResolvedValueOnce({
version: "2.0.0",
default_labels: { custom: "value" },
});

render(<App />);

await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("test.user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
});
});

it("prefers active account alias over backend operator when both are provided", async () => {
mockGetActiveAccount.mockReturnValue({ username: "override_user@contoso.com" });
mockedVersionApi.getVersion.mockResolvedValueOnce({
version: "2.0.0",
default_labels: { operator: "backend_user", custom: "value" },
});

render(<App />);

await waitFor(() => {
expect(screen.getByTestId("labels-operator")).toHaveTextContent("override_user");
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
});
});

it("stores attack target when conversation is created with active target", () => {
Expand Down
36 changes: 30 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react'
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'
import { useMsal } from '@azure/msal-react'
import MainLayout from './components/Layout/MainLayout'
import ChatWindow from './components/Chat/ChatWindow'
import TargetConfig from './components/Config/TargetConfig'
Expand Down Expand Up @@ -36,6 +37,7 @@ function ConnectionBannerContainer() {
}

function App() {
const { instance } = useMsal()
const [isDarkMode, setIsDarkMode] = useState(true)
const [currentView, setCurrentView] = useState<ViewName>('chat')
const [activeTarget, setActiveTarget] = useState<TargetInstance | null>(null)
Expand All @@ -45,16 +47,38 @@ function App() {
/** Persisted filter state for the history view */
const [historyFilters, setHistoryFilters] = useState<HistoryFilters>({ ...DEFAULT_HISTORY_FILTERS })

// Fetch default labels from backend configuration on startup
// Fetch default labels from backend, then override operator with active account if available
useEffect(() => {
versionApi.getVersion()
.then((data) => {
let ignore = false

async function initLabels() {
let defaultLabels: Record<string, string> = {}
try {
const data = await versionApi.getVersion()
if (data.default_labels && Object.keys(data.default_labels).length > 0) {
setGlobalLabels(prev => ({ ...prev, ...data.default_labels }))
defaultLabels = data.default_labels
}
} catch {
/* version fetch handled elsewhere */
}

if (ignore) return

const account = instance.getActiveAccount?.()
const alias = account?.username ? account.username.split('@')[0].toLowerCase() : null
Comment thread
behnam-o marked this conversation as resolved.

setGlobalLabels(prev => {
const next = { ...prev, ...defaultLabels }
if (alias) {
next.operator = alias
}
return next
})
.catch(() => { /* version fetch handled elsewhere */ })
}, [])
}

initLabels()
return () => { ignore = true }
}, [instance])

const handleSetActiveTarget = useCallback((target: TargetInstance) => {
setActiveTarget(prev => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@fluentui/react-components'
import { versionApi } from '../../services/api'
import Navigation, { type ViewName } from '../Sidebar/Navigation'
import { UserAccountButton } from '../UserAccountButton'
import { useMainLayoutStyles } from './MainLayout.styles'

interface MainLayoutProps {
Expand Down Expand Up @@ -47,6 +48,7 @@ export default function MainLayout({
</Tooltip>
<Text className={styles.title}>Co-PyRIT</Text>
<Text className={styles.subtitle}>Python Risk Identification Tool</Text>
<UserAccountButton />
</div>
<div className={styles.contentArea}>
<aside className={styles.sidebar}>
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/UserAccountButton.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { makeStyles, tokens } from '@fluentui/react-components'

export const useUserAccountButtonStyles = makeStyles({
wrapper: {
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
},
popoverContent: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
},
accountName: {
fontWeight: tokens.fontWeightSemibold,
},
accountEmail: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground3,
},
})
134 changes: 134 additions & 0 deletions frontend/src/components/UserAccountButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { UserAccountButton } from './UserAccountButton'

Comment thread
behnam-o marked this conversation as resolved.
const mockLoginRedirect = jest.fn()
const mockLogoutRedirect = jest.fn()
const mockGetActiveAccount = jest.fn()
let mockAccounts: { name?: string; username?: string }[] = []

jest.mock('@azure/msal-react', () => ({
useMsal: () => ({
instance: {
getActiveAccount: mockGetActiveAccount,
loginRedirect: mockLoginRedirect,
logoutRedirect: mockLogoutRedirect,
},
accounts: mockAccounts,
}),
}))

let mockAuthConfig = { clientId: '', tenantId: '', allowedGroupIds: '' }

jest.mock('../auth/AuthConfigContext', () => ({
useAuthConfig: () => mockAuthConfig,
}))

jest.mock('../auth/msalConfig', () => ({
buildLoginRequest: (clientId: string) => ({ scopes: [`${clientId}/.default`] }),
}))

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

describe('UserAccountButton', () => {
beforeEach(() => {
jest.clearAllMocks()
mockGetActiveAccount.mockReturnValue(null)
mockLoginRedirect.mockResolvedValue(undefined)
mockLogoutRedirect.mockResolvedValue(undefined)
mockAuthConfig = { clientId: '', tenantId: '', allowedGroupIds: '' }
mockAccounts = []
})

it('returns null when auth is disabled (no clientId)', () => {
render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

// UserAccountButton renders null — no buttons appear
expect(screen.queryByRole('button')).toBeNull()
})

it('renders Log In button when auth enabled but no account', () => {
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }

render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument()
})

it('calls loginRedirect when Log In is clicked', async () => {
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
const user = userEvent.setup()

render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /log in/i }))

expect(mockLoginRedirect).toHaveBeenCalledWith({ scopes: ['test-client-id/.default'] })
})

it('renders user display name and Sign Out when account exists', () => {
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
mockGetActiveAccount.mockReturnValue({
name: 'Alice Smith',
username: 'alice@example.com',
})

render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

expect(screen.getByText('Alice Smith')).toBeInTheDocument()
})

it('renders user display name when account comes from accounts[0] (no active account)', () => {
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
mockAccounts = [{ name: 'Bob Jones', username: 'bob@example.com' }]

render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

expect(screen.getByText('Bob Jones')).toBeInTheDocument()
})

it('calls logoutRedirect when Sign Out is clicked', async () => {
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
mockGetActiveAccount.mockReturnValue({
name: 'Alice Smith',
username: 'alice@example.com',
})
const user = userEvent.setup()

render(
<TestWrapper>
<UserAccountButton />
</TestWrapper>
)

// Open the popover by clicking the user button
await user.click(screen.getByRole('button', { name: /alice smith/i }))

await user.click(screen.getByRole('button', { name: /sign out/i }))

Comment thread
behnam-o marked this conversation as resolved.
expect(mockLogoutRedirect).toHaveBeenCalled()
})
})
Loading
Loading