- Add getC2Tasks / listCallbackHistory / importC2 API functions + types - useC2Tasks with 2500ms polling (stops when all tasks completed) - useC2CallbackHistory, useImportC2 hooks - C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator) - C2CallbackPicker extracted as shared component (reused in both modals) - ImportC2HistoryModal: 2-step callback picker → paginated history table - SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button - 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
6.6 KiB
TypeScript
197 lines
6.6 KiB
TypeScript
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('displays task command and mythic display id', async () => {
|
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('MIMIC')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows IMPORT source badge for mapping_applied=false', async () => {
|
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('IMPORT')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows completed_at timestamp for completed task', async () => {
|
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows em dash for null completed_at', async () => {
|
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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(<C2TasksPanel simulationId={7} />);
|
|
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);
|
|
});
|
|
});
|