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
31 changes: 31 additions & 0 deletions frontend/e2e/history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,34 @@ test.describe("Attack History Filters", () => {
await expect(page.getByTestId("attack-row-atk-page-000")).toBeVisible({ timeout: 5_000 });
});
});

test.describe("Attack History empty state", () => {
test("guides keyboard users to target configuration and preserves Back navigation", async ({ page }) => {
await mockHistoryAPIs(page, { attacks: [] });
await page.route(/\/api\/targets(?:\?|$)/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [],
pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null },
}),
});
});

await page.goto("/history");

const configureTargetButton = page.getByRole("button", { name: "Configure target" });
await expect(configureTargetButton).toBeVisible();
await configureTargetButton.focus();
await expect(configureTargetButton).toBeFocused();
await configureTargetButton.press("Enter");

await expect(page).toHaveURL(/\/config$/);
await expect(page.getByRole("heading", { level: 1, name: "Target Configuration" })).toBeVisible();

await page.goBack();
await expect(page).toHaveURL(/\/history$/);
await expect(page.getByRole("button", { name: "Configure target" })).toBeVisible();
});
});
38 changes: 38 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,28 @@ jest.mock("./components/History/AttackHistory", () => {
onOpenAttack,
filters,
onFiltersChange,
activeTarget,
onNavigate,
}: {
onOpenAttack: (attackResultId: string) => void;
filters: Record<string, unknown>;
onFiltersChange: (filters: Record<string, unknown>) => void;
activeTarget: unknown;
onNavigate: (view: string) => void;
}) => {
return (
<div data-testid="attack-history">
<span data-testid="history-filters">{JSON.stringify(filters)}</span>
<span data-testid="history-has-target">{activeTarget ? "yes" : "no"}</span>
{activeTarget ? (
<button onClick={() => onNavigate("chat")} data-testid="history-start-attack">
Start attack
</button>
) : (
<button onClick={() => onNavigate("config")} data-testid="history-configure-target">
Configure target
</button>
)}
<button
onClick={() => onOpenAttack("ar-attack-1")}
data-testid="open-attack"
Expand Down Expand Up @@ -440,6 +454,30 @@ describe("App", () => {
expect(screen.getByTestId("attack-history")).toBeInTheDocument();
});

it("navigates from empty history to config when no target is active", () => {
renderApp("/history");

expect(screen.getByTestId("history-has-target")).toHaveTextContent("no");
fireEvent.click(screen.getByTestId("history-configure-target"));

expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "config");
expect(screen.getByTestId("target-config")).toBeInTheDocument();
});

it("navigates from empty history to chat when a target is active", () => {
renderApp();

fireEvent.click(screen.getByTestId("nav-config"));
fireEvent.click(screen.getByTestId("set-target"));
fireEvent.click(screen.getByTestId("nav-history"));

expect(screen.getByTestId("history-has-target")).toHaveTextContent("yes");
fireEvent.click(screen.getByTestId("history-start-attack"));

expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "chat");
expect(screen.getByTestId("chat-window")).toBeInTheDocument();
});

it("opens attack from history and switches to chat", async () => {
mockGetAttack.mockResolvedValue({ attack_result_id: "ar-attack-1", conversation_id: "attack-conv-1", labels: { operator: "roakey" } });
renderApp();
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ function App() {
onOpenAttack={handleOpenAttack}
filters={historyFilters}
onFiltersChange={handleFiltersChange}
activeTarget={activeTarget}
onNavigate={handleNavigate}
/>
}
/>
Expand Down
69 changes: 64 additions & 5 deletions frontend/src/components/History/AttackHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { makeTarget } from '@/test-utils/targetFixtures'
import AttackHistory from './AttackHistory'
import { DEFAULT_HISTORY_FILTERS } from './historyFilters'
import { attacksApi, labelsApi } from '../../services/api'
Expand Down Expand Up @@ -60,6 +62,8 @@ describe('AttackHistory', () => {
onOpenAttack: jest.fn(),
filters: { ...DEFAULT_HISTORY_FILTERS },
onFiltersChange: jest.fn(),
activeTarget: null,
onNavigate: jest.fn(),
}

beforeEach(() => {
Expand Down Expand Up @@ -95,22 +99,73 @@ describe('AttackHistory', () => {
})
})

it('should show empty state when no attacks', async () => {
it('should guide users without an active target to configuration', async () => {
const user = userEvent.setup()
const onNavigate = jest.fn()
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})

render(
<TestWrapper>
<AttackHistory {...defaultProps} />
<AttackHistory {...defaultProps} onNavigate={onNavigate} />
</TestWrapper>
)

await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
})
expect(screen.getByText('No attacks found')).toBeInTheDocument()
expect(screen.getByText('Configure a target before starting an attack.')).toBeInTheDocument()

const configureTargetButton = screen.getByRole('button', { name: 'Configure target' })
expect(configureTargetButton).toBeEnabled()
expect(screen.queryByRole('button', { name: 'Start attack' })).not.toBeInTheDocument()

await user.click(configureTargetButton)
expect(onNavigate).toHaveBeenCalledWith('config')
})

it('should guide users with an active target to start an attack', async () => {
const user = userEvent.setup()
const onNavigate = jest.fn()
mockedAttacksApi.listAttacks.mockResolvedValue({
items: [],
pagination: { limit: 25, has_more: false },
})

render(
<TestWrapper>
<AttackHistory
{...defaultProps}
activeTarget={makeTarget({ target_registry_name: 'active_target' })}
onNavigate={onNavigate}
/>
</TestWrapper>
)

const startAttackButton = await screen.findByRole('button', { name: 'Start attack' })
expect(startAttackButton).toBeEnabled()
expect(screen.getByText('Start an attack to see it here.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Configure target' })).not.toBeInTheDocument()

await user.click(startAttackButton)
expect(onNavigate).toHaveBeenCalledWith('chat')
})

it('should not show an empty-state action while attacks are loading', () => {
mockedAttacksApi.listAttacks.mockImplementation(() => new Promise(() => {}))

render(
<TestWrapper>
<AttackHistory {...defaultProps} />
</TestWrapper>
)

expect(screen.getByText('Loading attacks...')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Start attack' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Configure target' })).not.toBeInTheDocument()
})

it('should render attack table rows', async () => {
Expand Down Expand Up @@ -427,6 +482,8 @@ describe('AttackHistory', () => {
})
expect(screen.getByText('Internal server error')).toBeInTheDocument()
expect(screen.getByTestId('retry-btn')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Start attack' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Configure target' })).not.toBeInTheDocument()
})

it('should retry on clicking retry button', async () => {
Expand Down Expand Up @@ -600,18 +657,20 @@ describe('AttackHistory', () => {
items: [],
pagination: { limit: 25, has_more: false },
})
const activeFilters = { ...DEFAULT_HISTORY_FILTERS, outcome: 'success' }

render(
<TestWrapper>
<AttackHistory {...defaultProps} />
<AttackHistory {...defaultProps} filters={activeFilters} />
</TestWrapper>
)

await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument()
})
// Default empty text (no filters active)
expect(screen.getByText('Run an attack to see it here.')).toBeInTheDocument()
expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Start attack' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Configure target' })).not.toBeInTheDocument()
})

it('should show reset filters button when a filter is active', async () => {
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/components/History/AttackHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
MessageBar,
MessageBarBody,
} from '@fluentui/react-components'
import { ArrowSyncRegular } from '@fluentui/react-icons'
import { ArrowSyncRegular, ChatRegular, SettingsRegular } from '@fluentui/react-icons'
import { attacksApi, labelsApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import type { AttackSummary } from '../../types'
import type { AttackSummary, TargetInstance } from '../../types'
import type { ViewName } from '../Sidebar/Navigation'
import type { HistoryFilters } from './historyFilters'
import { useAttackHistoryStyles } from './AttackHistory.styles'
import HistoryFiltersBar from './HistoryFiltersBar'
Expand All @@ -20,6 +21,8 @@ interface AttackHistoryProps {
onOpenAttack: (attackResultId: string) => void
filters: HistoryFilters
onFiltersChange: (filters: HistoryFilters) => void
activeTarget: TargetInstance | null
onNavigate: (view: ViewName) => void
}

const PAGE_SIZE = 25
Expand All @@ -44,7 +47,13 @@ function buildListParams(filters: HistoryFilters, pageCursor: string | undefined
return params
}

export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }: AttackHistoryProps) {
export default function AttackHistory({
onOpenAttack,
filters,
onFiltersChange,
activeTarget,
onNavigate,
}: AttackHistoryProps) {
const styles = useAttackHistoryStyles()
const [attacks, setAttacks] = useState<AttackSummary[]>([])
const [loading, setLoading] = useState(true)
Expand Down Expand Up @@ -230,8 +239,20 @@ export default function AttackHistory({ onOpenAttack, filters, onFiltersChange }
<Text size={200}>
{hasActiveFilters
? 'Try adjusting your filters.'
: 'Run an attack to see it here.'}
: activeTarget
? 'Start an attack to see it here.'
: 'Configure a target before starting an attack.'}
</Text>
{!hasActiveFilters && (
<Button
className={styles.touchTargetHeight}
appearance="primary"
icon={activeTarget ? <ChatRegular /> : <SettingsRegular />}
onClick={() => onNavigate(activeTarget ? 'chat' : 'config')}
>
{activeTarget ? 'Start attack' : 'Configure target'}
</Button>
)}
</div>
) : (
<AttackTable attacks={attacks} onOpenAttack={onOpenAttack} formatDate={formatDate} />
Expand Down