diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b5d8478b68..34fc91e6d3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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", () => ({ @@ -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; }) => { return (
{conversationId ?? "none"} {activeTarget ? "yes" : "no"} + {labels.operator ?? ""} + {JSON.stringify(labels)} @@ -183,6 +189,11 @@ jest.mock("./components/History/AttackHistory", () => { }); describe("App", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetActiveAccount.mockReturnValue(null); + }); + it("renders with FluentProvider and MainLayout", () => { render(); expect(screen.getByTestId("main-layout")).toBeInTheDocument(); @@ -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(); + + 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(); + + 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", () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25dd980321..b1846647ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' @@ -36,6 +37,7 @@ function ConnectionBannerContainer() { } function App() { + const { instance } = useMsal() const [isDarkMode, setIsDarkMode] = useState(true) const [currentView, setCurrentView] = useState('chat') const [activeTarget, setActiveTarget] = useState(null) @@ -45,16 +47,38 @@ function App() { /** Persisted filter state for the history view */ const [historyFilters, setHistoryFilters] = useState({ ...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 = {} + 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 + + 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 => { diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx index f6793b70e7..ae8b8bd510 100644 --- a/frontend/src/components/Layout/MainLayout.tsx +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -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 { @@ -47,6 +48,7 @@ export default function MainLayout({ Co-PyRIT Python Risk Identification Tool +