From 25877c4092d1a24b077e32dc19fe717172dbc709 Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 8 Jun 2026 18:04:56 +0200 Subject: [PATCH] test: ExportEngagementButton + EngagementDetailPage RBAC tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 tests for ExportEngagementButton (render, open, close-outside, Escape, per-format trigger, loading state, error toast). 3 RBAC tests for EngagementDetailPage (admin/redteam see Export, soc does not). Total: 121 → 133 vitest passing. Co-Authored-By: Claude Sonnet 4.6 --- frontend/tests/EngagementDetailPage.test.tsx | 94 +++++++++++++ .../tests/ExportEngagementButton.test.tsx | 133 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 frontend/tests/EngagementDetailPage.test.tsx create mode 100644 frontend/tests/ExportEngagementButton.test.tsx 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'); + }); + }); +});