diff --git a/frontend/tests/EngagementDetailPage.test.tsx b/frontend/tests/EngagementDetailPage.test.tsx
new file mode 100644
index 0000000..741beb6
--- /dev/null
+++ b/frontend/tests/EngagementDetailPage.test.tsx
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { Route, Routes } from 'react-router-dom';
+import MockAdapter from 'axios-mock-adapter';
+import { apiClient } from '@/api/client';
+import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
+import { renderWithProviders } from './utils';
+import type { Engagement } from '@/api/types';
+
+vi.mock('@/api/exports', () => ({
+ downloadEngagementExport: vi.fn(),
+}));
+
+const ENGAGEMENT: Engagement = {
+ id: 1,
+ name: 'Test Engagement',
+ description: 'A test engagement',
+ start_date: '2026-06-01',
+ end_date: null,
+ status: 'active',
+ created_at: '2026-06-01T08:00:00',
+ created_by: { id: 1, username: 'alice' },
+};
+
+type MockRole = 'admin' | 'redteam' | 'soc';
+let mockRole: MockRole = 'admin';
+
+function DetailPage() {
+ return (
+
+ } />
+
+ );
+}
+
+vi.mock('@/hooks/useAuth', () => ({
+ useAuth: () => ({
+ user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
+ status: 'authenticated',
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAdmin: mockRole === 'admin',
+ isRedteam: mockRole === 'redteam',
+ isSoc: mockRole === 'soc',
+ canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
+ }),
+}));
+
+describe('EngagementDetailPage — RBAC for Export button', () => {
+ let mock: MockAdapter;
+
+ beforeEach(() => {
+ mock = new MockAdapter(apiClient);
+ mock.onGet('/engagements/1').reply(200, ENGAGEMENT);
+ mock.onGet('/engagements/1/simulations').reply(200, []);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('admin sees Export button', async () => {
+ mockRole = 'admin';
+ renderWithProviders(, {
+ routerProps: { initialEntries: ['/engagements/1'] },
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Test Engagement')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
+ });
+
+ it('redteam sees Export button', async () => {
+ mockRole = 'redteam';
+ renderWithProviders(, {
+ routerProps: { initialEntries: ['/engagements/1'] },
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Test Engagement')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
+ });
+
+ it('soc does NOT see Export button', async () => {
+ mockRole = 'soc';
+ renderWithProviders(, {
+ routerProps: { initialEntries: ['/engagements/1'] },
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Test Engagement')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('export-dropdown')).toBeNull();
+ });
+});
diff --git a/frontend/tests/ExportEngagementButton.test.tsx b/frontend/tests/ExportEngagementButton.test.tsx
new file mode 100644
index 0000000..a139ff3
--- /dev/null
+++ b/frontend/tests/ExportEngagementButton.test.tsx
@@ -0,0 +1,133 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ExportEngagementButton } from '@/components/ExportEngagementButton';
+import { ToastViewport } from '@/components/Toast';
+import { renderWithProviders } from './utils';
+
+function ExportButtonWithToast({ engagementId }: { engagementId: number }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+vi.mock('@/api/exports', () => ({
+ downloadEngagementExport: vi.fn(),
+}));
+
+import { downloadEngagementExport } from '@/api/exports';
+const mockDownload = downloadEngagementExport as ReturnType;
+
+vi.mock('@/hooks/useAuth', () => ({
+ useAuth: () => ({
+ user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
+ status: 'authenticated',
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAdmin: true,
+ isRedteam: false,
+ isSoc: false,
+ canEditEngagements: true,
+ }),
+}));
+
+describe('ExportEngagementButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders Export button with chevron', () => {
+ renderWithProviders();
+ expect(screen.getByTestId('export-btn')).toBeInTheDocument();
+ expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
+ expect(screen.getByText('Export')).toBeInTheDocument();
+ });
+
+ it('clicking primary opens dropdown with three formats', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ expect(screen.getByText('Markdown')).toBeInTheDocument();
+ expect(screen.getByText('CSV')).toBeInTheDocument();
+ expect(screen.getByText('PDF')).toBeInTheDocument();
+ });
+
+ it('clicking outside closes dropdown', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ expect(screen.getByText('Markdown')).toBeInTheDocument();
+ await user.click(document.body);
+ expect(screen.queryByText('Markdown')).toBeNull();
+ });
+
+ it('Escape closes dropdown', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-dropdown-toggle'));
+ expect(screen.getByText('Markdown')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByText('Markdown')).toBeNull();
+ });
+
+ it('clicking Markdown triggers download with format=md', async () => {
+ mockDownload.mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ await user.click(screen.getByTestId('export-format-md'));
+ expect(mockDownload).toHaveBeenCalledWith(42, 'md');
+ });
+
+ it('clicking CSV triggers download with format=csv', async () => {
+ mockDownload.mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ await user.click(screen.getByTestId('export-format-csv'));
+ expect(mockDownload).toHaveBeenCalledWith(42, 'csv');
+ });
+
+ it('clicking PDF triggers download with format=pdf', async () => {
+ mockDownload.mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ await user.click(screen.getByTestId('export-format-pdf'));
+ expect(mockDownload).toHaveBeenCalledWith(42, 'pdf');
+ });
+
+ it('loading state disables items during in-flight', async () => {
+ let resolve!: () => void;
+ mockDownload.mockReturnValue(new Promise((r) => { resolve = r; }));
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ await user.click(screen.getByTestId('export-format-md'));
+ // Items should be disabled while in-flight
+ await waitFor(() => {
+ expect(screen.getByTestId('export-format-csv')).toBeDisabled();
+ expect(screen.getByTestId('export-format-pdf')).toBeDisabled();
+ });
+ resolve();
+ });
+
+ it('error response shows toast', async () => {
+ mockDownload.mockRejectedValue(new Error('Export failed: 403 Forbidden'));
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByTestId('export-btn'));
+ await user.click(screen.getByTestId('export-format-md'));
+ await waitFor(() => {
+ expect(screen.getByTestId('toast')).toBeInTheDocument();
+ expect(screen.getByTestId('toast')).toHaveTextContent('Export failed: 403 Forbidden');
+ });
+ });
+});