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);
});
});