feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- 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>
This commit is contained in:
196
frontend/tests/components/C2TasksPanel.test.tsx
Normal file
196
frontend/tests/components/C2TasksPanel.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user