feat(c2): integrate Mythic command and control (sprint 8) #11
@@ -216,6 +216,7 @@ export interface C2TaskListItem {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
output: string | null;
|
output: string | null;
|
||||||
mapping_applied: boolean;
|
mapping_applied: boolean;
|
||||||
|
source: 'mimic' | 'import';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,14 +57,22 @@ export function C2CallbackPicker({
|
|||||||
key={cb.display_id}
|
key={cb.display_id}
|
||||||
data-testid={rowTestId}
|
data-testid={rowTestId}
|
||||||
onClick={() => onSelect(cb.display_id)}
|
onClick={() => onSelect(cb.display_id)}
|
||||||
className={`cursor-pointer border-b border-hairline ${
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(cb.display_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className={`cursor-pointer border-b border-hairline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||||
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
|
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-md py-sm font-mono">{cb.display_id}</td>
|
<td className="px-md py-sm font-mono">{cb.display_id}</td>
|
||||||
<td className="px-md py-sm">
|
<td className="px-md py-sm">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${
|
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${
|
||||||
cb.active
|
cb.active
|
||||||
? 'bg-primary-soft text-primary-deep'
|
? 'bg-primary-soft text-primary-deep'
|
||||||
: 'bg-cloud text-graphite border border-hairline'
|
: 'bg-cloud text-graphite border border-hairline'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function badgeClass(status: string): string {
|
|||||||
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
|
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${badgeClass(status)}`}
|
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${badgeClass(status)}`}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -72,7 +72,16 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
|||||||
<tr
|
<tr
|
||||||
data-testid="c2-task-row"
|
data-testid="c2-task-row"
|
||||||
onClick={() => toggleExpand(task.id, task.completed)}
|
onClick={() => toggleExpand(task.id, task.completed)}
|
||||||
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud' : ''}`}
|
onKeyDown={(e) => {
|
||||||
|
if (canExpand && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleExpand(task.id, task.completed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={canExpand ? 0 : undefined}
|
||||||
|
role={canExpand ? 'button' : undefined}
|
||||||
|
aria-expanded={canExpand ? isExpanded : undefined}
|
||||||
|
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' : ''}`}
|
||||||
>
|
>
|
||||||
<td className="px-md py-sm text-graphite">
|
<td className="px-md py-sm text-graphite">
|
||||||
{canExpand ? (
|
{canExpand ? (
|
||||||
@@ -91,8 +100,8 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
|||||||
{task.command}
|
{task.command}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-md py-sm">
|
<td className="px-md py-sm">
|
||||||
<span className="inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold bg-cloud text-graphite border border-hairline">
|
<span className="badge-pill-outline">
|
||||||
{task.mapping_applied ? 'MIMIC' : 'IMPORT'}
|
{task.source === 'mimic' ? 'MIMIC' : 'IMPORT'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-md py-sm">
|
<td className="px-md py-sm">
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ describe('SimulationFormPage — C2 tasks panel visibility', () => {
|
|||||||
completed: true,
|
completed: true,
|
||||||
output: 'SYSTEM',
|
output: 'SYSTEM',
|
||||||
mapping_applied: false,
|
mapping_applied: false,
|
||||||
|
source: 'import',
|
||||||
created_at: '2026-06-10T10:00:00',
|
created_at: '2026-06-10T10:00:00',
|
||||||
completed_at: '2026-06-10T10:00:05',
|
completed_at: '2026-06-10T10:00:05',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ describe('getC2Tasks', () => {
|
|||||||
completed: true,
|
completed: true,
|
||||||
output: 'NT AUTHORITY\\SYSTEM',
|
output: 'NT AUTHORITY\\SYSTEM',
|
||||||
mapping_applied: true,
|
mapping_applied: true,
|
||||||
|
source: 'mimic',
|
||||||
created_at: '2026-06-10T10:00:00',
|
created_at: '2026-06-10T10:00:00',
|
||||||
completed_at: '2026-06-10T10:00:05',
|
completed_at: '2026-06-10T10:00:05',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const COMPLETED_TASK = {
|
|||||||
completed: true,
|
completed: true,
|
||||||
output: 'NT AUTHORITY\\SYSTEM',
|
output: 'NT AUTHORITY\\SYSTEM',
|
||||||
mapping_applied: true,
|
mapping_applied: true,
|
||||||
|
source: 'mimic' as const,
|
||||||
created_at: '2026-06-10T10:00:00',
|
created_at: '2026-06-10T10:00:00',
|
||||||
completed_at: '2026-06-10T10:00:05',
|
completed_at: '2026-06-10T10:00:05',
|
||||||
};
|
};
|
||||||
@@ -42,6 +43,7 @@ const PENDING_TASK = {
|
|||||||
completed: false,
|
completed: false,
|
||||||
output: null,
|
output: null,
|
||||||
mapping_applied: false,
|
mapping_applied: false,
|
||||||
|
source: 'import' as const,
|
||||||
created_at: '2026-06-10T10:00:10',
|
created_at: '2026-06-10T10:00:10',
|
||||||
completed_at: null,
|
completed_at: null,
|
||||||
};
|
};
|
||||||
@@ -91,14 +93,14 @@ describe('C2TasksPanel — populated rows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows MIMIC source badge for mapping_applied=true', async () => {
|
it('shows MIMIC source badge for source=mimic', async () => {
|
||||||
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('MIMIC')).toBeInTheDocument();
|
expect(screen.getByText('MIMIC')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows IMPORT source badge for mapping_applied=false', async () => {
|
it('shows IMPORT source badge for source=import', async () => {
|
||||||
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('IMPORT')).toBeInTheDocument();
|
expect(screen.getByText('IMPORT')).toBeInTheDocument();
|
||||||
@@ -163,6 +165,28 @@ describe('C2TasksPanel — expand on click', () => {
|
|||||||
fireEvent.click(screen.getByTestId('c2-task-row'));
|
fireEvent.click(screen.getByTestId('c2-task-row'));
|
||||||
expect(screen.queryByTestId('c2-task-output')).toBeNull();
|
expect(screen.queryByTestId('c2-task-output')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Enter key on completed row toggles output (a11y)', async () => {
|
||||||
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const row = screen.getByTestId('c2-task-row');
|
||||||
|
fireEvent.keyDown(row, { key: 'Enter' });
|
||||||
|
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(row, { key: 'Enter' });
|
||||||
|
expect(screen.queryByTestId('c2-task-output')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Space key on completed row toggles output (a11y)', async () => {
|
||||||
|
renderWithProviders(<C2TasksPanel simulationId={7} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const row = screen.getByTestId('c2-task-row');
|
||||||
|
fireEvent.keyDown(row, { key: ' ' });
|
||||||
|
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('C2TasksPanel — refresh indicator', () => {
|
describe('C2TasksPanel — refresh indicator', () => {
|
||||||
|
|||||||
@@ -157,4 +157,15 @@ describe('ExecuteViaC2Modal', () => {
|
|||||||
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
|
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
|
||||||
expect(textarea.value).toBe('net user\nwhoami /all');
|
expect(textarea.value).toBe('net user\nwhoami /all');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Enter key on callback row selects it (a11y)', async () => {
|
||||||
|
renderModal('whoami');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
const firstRow = screen.getAllByTestId('c2-callback-row')[0];
|
||||||
|
fireEvent.keyDown(firstRow, { key: 'Enter' });
|
||||||
|
// Row is now selected → Launch button should be enabled
|
||||||
|
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user