feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config, deleteC2Config, testC2Config, listCallbacks, executeC2) following the frozen M1+M2 backend contracts - frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult, C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse - frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config, useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2 - frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config card (url + write-only token + verify-tls + save/delete/test-connection), 503 disabled state, ConfirmDialog on delete - frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table (mono data cells), commands textarea pre-filled from rt.commands, Launch disabled until row selected + non-empty commands - frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit mode only, admin+redteam only (canEditEngagements gate) - frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT card, visible only when !isDone && canEditRT && hasC2Config; opens modal - Tests: 33 new tests across api/c2, components/C2ConfigCard, components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage (172 total, 139 baseline + 33 new, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal file
135
frontend/tests/components/C2ConfigCard.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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 { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||
import { renderWithProviders } from '../utils';
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: true,
|
||||
isRedteam: false,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — no config (404)', () => {
|
||||
it('renders the card with empty fields when no config exists', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(404);
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
// Wait for loading to finish — query resolves to null on 404
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-url-input')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('c2-token-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('c2-verify-tls')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('c2-save-btn')).toBeInTheDocument();
|
||||
// Delete button only shown when has_token
|
||||
expect(screen.queryByTestId('c2-delete-btn')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — with config (has_token=true)', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(200, {
|
||||
has_token: true,
|
||||
url: 'https://mythic.lab:7443',
|
||||
verify_tls: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Replace token affordance when has_token=true', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
// Token input shows placeholder bullets (readOnly)
|
||||
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
|
||||
expect(tokenInput.readOnly).toBe(true);
|
||||
expect(tokenInput.placeholder).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('shows Delete configuration button when has_token=true', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-delete-btn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking Replace token makes input editable', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Replace token')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Replace token'));
|
||||
await waitFor(() => {
|
||||
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
|
||||
expect(tokenInput.readOnly).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('Test connection button is enabled when config exists', async () => {
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Connected on successful test', async () => {
|
||||
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('c2-test-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on failed test', async () => {
|
||||
mock
|
||||
.onPost('/engagements/1/c2-config/test')
|
||||
.reply(200, { ok: false, error: 'Connection refused' });
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('c2-test-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('C2ConfigCard — 503 disabled state', () => {
|
||||
it('shows 503 banner and disables all inputs', async () => {
|
||||
mock.onGet('/engagements/1/c2-config').reply(503);
|
||||
renderWithProviders(<C2ConfigCard engagementId={1} />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/C2 features are disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-url-input')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-token-input')).toBeDisabled();
|
||||
expect(screen.getByTestId('c2-verify-tls')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user