import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor, fireEvent } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { apiClient } from '@/api/client'; import { C2TasksPanel } from '@/components/C2TasksPanel'; import { renderWithProviders } from '../utils'; vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' }, status: 'authenticated', login: vi.fn(), logout: vi.fn(), isAdmin: false, isRedteam: true, isSoc: false, canEditEngagements: true, }), })); const COMPLETED_TASK = { id: 1, mythic_task_display_id: 10, callback_display_id: 1, command: 'whoami', params: null, status: 'completed', completed: true, output: 'NT AUTHORITY\\SYSTEM', mapping_applied: true, created_at: '2026-06-10T10:00:00', completed_at: '2026-06-10T10:00:05', }; const PENDING_TASK = { id: 2, mythic_task_display_id: 11, callback_display_id: 1, command: 'ipconfig', params: null, status: 'submitted', completed: false, output: null, mapping_applied: false, created_at: '2026-06-10T10:00:10', completed_at: null, }; let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(apiClient); }); afterEach(() => { mock.restore(); vi.clearAllMocks(); }); describe('C2TasksPanel — empty state', () => { it('shows empty state copy when tasks array is empty', async () => { mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] }); renderWithProviders(); await waitFor(() => { expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); }); expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument(); expect(screen.queryByTestId('c2-task-row')).toBeNull(); }); }); describe('C2TasksPanel — populated rows', () => { beforeEach(() => { mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK, PENDING_TASK] }); }); it('renders one row per task', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2); }); }); it('displays task command and mythic display id', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('whoami')).toBeInTheDocument(); expect(screen.getByText('ipconfig')).toBeInTheDocument(); expect(screen.getByText('#10')).toBeInTheDocument(); expect(screen.getByText('#11')).toBeInTheDocument(); }); }); it('shows MIMIC source badge for mapping_applied=true', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('MIMIC')).toBeInTheDocument(); }); }); it('shows IMPORT source badge for mapping_applied=false', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('IMPORT')).toBeInTheDocument(); }); }); it('shows completed_at timestamp for completed task', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument(); }); }); it('shows em dash for null completed_at', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('—')).toBeInTheDocument(); }); }); }); describe('C2TasksPanel — expand on click', () => { beforeEach(() => { mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK] }); }); it('output row is hidden before click', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); }); expect(screen.queryByTestId('c2-task-output')).toBeNull(); }); it('clicking a completed row reveals the output', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); }); fireEvent.click(screen.getByTestId('c2-task-row')); expect(screen.getByTestId('c2-task-output')).toBeInTheDocument(); expect(screen.getByTestId('c2-task-output')).toHaveTextContent('NT AUTHORITY\\SYSTEM'); }); it('clicking the expanded row collapses the output', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); }); fireEvent.click(screen.getByTestId('c2-task-row')); expect(screen.getByTestId('c2-task-output')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('c2-task-row')); expect(screen.queryByTestId('c2-task-output')).toBeNull(); }); it('clicking an incomplete task row does not expand', async () => { mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [PENDING_TASK] }); renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); }); fireEvent.click(screen.getByTestId('c2-task-row')); expect(screen.queryByTestId('c2-task-output')).toBeNull(); }); }); describe('C2TasksPanel — refresh indicator', () => { it('does not show refresh indicator on initial load', async () => { mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] }); renderWithProviders(); await waitFor(() => { expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); }); // During isLoading, isFetching is true but isRefreshing = isFetching && !isLoading = false expect(screen.queryByTestId('c2-task-refresh-indicator')).toBeNull(); }); }); describe('C2TasksPanel — polling behaviour', () => { it('does not refetch when all tasks are completed (refetchInterval false)', async () => { // With all completed tasks, refetchInterval returns false — only one GET call expected let callCount = 0; mock.onGet('/simulations/7/c2/tasks').reply(() => { callCount++; return [200, { tasks: [COMPLETED_TASK] }]; }); renderWithProviders(); await waitFor(() => { expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1); }); // Wait a bit and confirm no extra fetches happened beyond initial await new Promise((r) => setTimeout(r, 100)); expect(callCount).toBe(1); }); });