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:
@@ -332,3 +332,104 @@ describe('SimulationFormPage — Execute via C2 button visibility', () => {
|
||||
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimulationFormPage — C2 tasks panel visibility', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRole = 'redteam';
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/simulations/7').reply(200, BASE_SIM);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
// Wait for page data to load then confirm no panel
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(404);
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
mythic_task_display_id: 10,
|
||||
callback_display_id: 1,
|
||||
command: 'whoami',
|
||||
params: null,
|
||||
status: 'completed',
|
||||
completed: true,
|
||||
output: 'SYSTEM',
|
||||
mapping_applied: false,
|
||||
created_at: '2026-06-10T10:00:00',
|
||||
completed_at: '2026-06-10T10:00:05',
|
||||
},
|
||||
],
|
||||
});
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('SOC role never sees C2 tasks panel', async () => {
|
||||
mockRole = 'soc';
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Import C2 history button when c2 config exists', async () => {
|
||||
mock.onGet('/engagements/42/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
|
||||
renderWithProviders(<EditPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-import-trigger-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
deleteC2Config,
|
||||
executeC2,
|
||||
getC2Config,
|
||||
getC2Tasks,
|
||||
importC2,
|
||||
listCallbackHistory,
|
||||
listCallbacks,
|
||||
putC2Config,
|
||||
testC2Config,
|
||||
@@ -134,3 +137,63 @@ describe('executeC2', () => {
|
||||
expect(body.commands).toEqual(['whoami']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getC2Tasks', () => {
|
||||
it('GET /simulations/:id/c2/tasks returns tasks list', async () => {
|
||||
mock.onGet('/simulations/7/c2/tasks').reply(200, {
|
||||
tasks: [
|
||||
{
|
||||
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 result = await getC2Tasks(7);
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].status).toBe('completed');
|
||||
expect(result.tasks[0].output).toBe('NT AUTHORITY\\SYSTEM');
|
||||
expect(mock.history['get'][0].url).toBe('/simulations/7/c2/tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCallbackHistory', () => {
|
||||
it('GET with page/page_size params', async () => {
|
||||
mock.onGet('/engagements/1/c2/callbacks/2/history').reply(200, {
|
||||
tasks: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
const result = await listCallbackHistory(1, 2, { page: 1, pageSize: 25 });
|
||||
expect(result.total).toBe(0);
|
||||
const req = mock.history['get'][0];
|
||||
expect(req.url).toBe('/engagements/1/c2/callbacks/2/history');
|
||||
expect(req.params).toMatchObject({ page: 1, page_size: 25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('importC2', () => {
|
||||
it('POST /simulations/:id/c2/import with task_display_ids', async () => {
|
||||
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 3, skipped: 1 });
|
||||
const result = await importC2(7, {
|
||||
callback_display_id: 2,
|
||||
task_display_ids: [10, 11, 12, 13],
|
||||
});
|
||||
expect(result.imported).toBe(3);
|
||||
expect(result.skipped).toBe(1);
|
||||
const req = mock.history['post'][0];
|
||||
expect(req.url).toBe('/simulations/7/c2/import');
|
||||
const body = JSON.parse(req.data as string);
|
||||
expect(body.callback_display_id).toBe(2);
|
||||
expect(body.task_display_ids).toEqual([10, 11, 12, 13]);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal file
380
frontend/tests/components/ImportC2HistoryModal.test.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
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 { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
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 CALLBACKS = [
|
||||
{
|
||||
display_id: 1,
|
||||
active: true,
|
||||
host: 'WIN-TARGET',
|
||||
user: 'administrator',
|
||||
domain: 'lab.local',
|
||||
last_checkin: '2026-06-10T10:00:00',
|
||||
},
|
||||
{
|
||||
display_id: 2,
|
||||
active: false,
|
||||
host: 'WIN-DC01',
|
||||
user: 'SYSTEM',
|
||||
domain: 'lab.local',
|
||||
last_checkin: '2026-06-10T09:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
const HISTORY_TASKS = [
|
||||
{
|
||||
display_id: 10,
|
||||
command: 'whoami',
|
||||
status: 'completed',
|
||||
completed: true,
|
||||
completed_at: '2026-06-10T10:00:05',
|
||||
created_at: '2026-06-10T10:00:00',
|
||||
},
|
||||
{
|
||||
display_id: 11,
|
||||
command: 'ipconfig',
|
||||
status: 'completed',
|
||||
completed: true,
|
||||
completed_at: '2026-06-10T10:00:10',
|
||||
created_at: '2026-06-10T10:00:05',
|
||||
},
|
||||
];
|
||||
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderModal() {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<ImportC2HistoryModal
|
||||
simulationId={7}
|
||||
engagementId={42}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
describe('ImportC2HistoryModal — step 1: callback picker', () => {
|
||||
it('renders modal with title and callback rows', async () => {
|
||||
renderModal();
|
||||
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Import C2 history')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('history table is not shown before selecting a callback', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
expect(screen.queryByTestId('c2-history-row')).toBeNull();
|
||||
});
|
||||
|
||||
it('Import button is disabled with no selection', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — step 2: history table appears after callback select', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: HISTORY_TASKS,
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows history table after selecting a callback', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows history task commands in the table', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('whoami')).toBeInTheDocument();
|
||||
expect(screen.getByText('ipconfig')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — multi-checkbox selection', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: HISTORY_TASKS,
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('Import button remains disabled with no tasks checked', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
|
||||
});
|
||||
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Import button becomes enabled after checking a task row', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('checking via checkbox also enables Import', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row-checkbox')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row-checkbox')[1]);
|
||||
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('unchecking a row disables Import when it was the only selection', async () => {
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
|
||||
});
|
||||
// Check then uncheck
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — pagination', () => {
|
||||
it('shows Prev/Next buttons when tasks exceed page_size', async () => {
|
||||
// 30 tasks, page_size 25 → 2 pages
|
||||
const manyTasks = Array.from({ length: 25 }, (_, i) => ({
|
||||
display_id: i + 1,
|
||||
command: `cmd${i + 1}`,
|
||||
status: 'completed',
|
||||
completed: true,
|
||||
completed_at: '2026-06-10T10:00:00',
|
||||
created_at: '2026-06-10T10:00:00',
|
||||
}));
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: manyTasks,
|
||||
total: 30,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-history-prev')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('c2-history-next')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Prev is disabled on page 1', async () => {
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: HISTORY_TASKS,
|
||||
total: 50,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-history-prev')).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByTestId('c2-history-next')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — submit payload', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: HISTORY_TASKS,
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends correct callback_display_id and task_display_ids on import', async () => {
|
||||
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 2, skipped: 0 });
|
||||
const { onClose } = renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
|
||||
});
|
||||
// Select both tasks
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[1]);
|
||||
|
||||
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const req = mock.history['post'][0];
|
||||
expect(req.url).toBe('/simulations/7/c2/import');
|
||||
const body = JSON.parse(req.data as string);
|
||||
expect(body.callback_display_id).toBe(1);
|
||||
expect(body.task_display_ids).toContain(10);
|
||||
expect(body.task_display_ids).toContain(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — toast wording', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
|
||||
tasks: [HISTORY_TASKS[0]],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Imported N task(s)" when skipped is 0', async () => {
|
||||
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 1, skipped: 0 });
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skipped count when skipped > 0', async () => {
|
||||
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 0, skipped: 1 });
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Imported 0 task(s), 1 already attached'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline error and keeps modal open on import failure', async () => {
|
||||
mock.onPost('/simulations/7/c2/import').reply(500, { error: 'Import failed' });
|
||||
const { onClose } = renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
|
||||
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import failed')).toBeInTheDocument();
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportC2HistoryModal — Cancel button', () => {
|
||||
it('Cancel button calls onClose', async () => {
|
||||
const { onClose } = renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user