feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput, extend SimulationCreateInput with template_id - api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate - hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate, useDeleteTemplate (TanStack Query, invalidates ["templates"]) - TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated), New/Edit/Delete actions, loading/error/empty states - TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form with inline MITRE field (picker + matrix modal), ConfirmDialog for delete - TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6) - SimulationList: replace "New simulation" link with split-button dropdown (Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id) - Layout: "Templates" nav link (admin | redteam, before "Users") - App.tsx: /admin/templates routes gated roles=["admin","redteam"] - 26 new Vitest tests (118 total, 92 original preserved) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { SimulationList } from '@/components/SimulationList';
|
||||
@@ -105,6 +106,42 @@ describe('SimulationList — admin/redteam', () => {
|
||||
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dropdown toggle button when simulations exist', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('new-simulation-dropdown-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown and shows "From template…" option', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
|
||||
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blank')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens TemplatePickerModal when "From template…" is clicked', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
mock.onGet('/simulation-templates').reply(200, []);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SimulationList engagementId={42} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
|
||||
await user.click(screen.getByTestId('from-template-btn'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking a row uses SPA navigation and does not trigger window.location change', async () => {
|
||||
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
|
||||
const originalHref = window.location.href;
|
||||
|
||||
219
frontend/tests/TemplateFormPage.test.tsx
Normal file
219
frontend/tests/TemplateFormPage.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { TemplateFormPage } from '@/pages/TemplateFormPage';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TEMPLATE: SimulationTemplate = {
|
||||
id: 5,
|
||||
name: 'Mimikatz LSASS Dump',
|
||||
description: 'Extract NTLM hashes',
|
||||
commands: 'mimikatz.exe',
|
||||
prerequisites: 'Local admin',
|
||||
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: ['credential-access'] }],
|
||||
tactics: [{ id: 'TA0006', name: 'Credential Access' }],
|
||||
created_at: '2026-05-28T00:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
};
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderNew() {
|
||||
const client = makeClient();
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={['/admin/templates/new']}>
|
||||
<Routes>
|
||||
<Route path="/admin/templates/new" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
|
||||
<Route path="/admin/templates/:id/edit" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEdit(id: number) {
|
||||
const client = makeClient();
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={[`/admin/templates/${id}/edit`]}>
|
||||
<Routes>
|
||||
<Route path="/admin/templates/:id/edit" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
|
||||
<Route path="/admin/templates" element={<div data-testid="templates-list" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TemplateFormPage — new mode', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders the form with name field in empty state', () => {
|
||||
renderNew();
|
||||
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Commands/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Prerequisites/i)).toBeInTheDocument();
|
||||
// All inputs should be empty
|
||||
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('shows validation error when name is empty on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
// Name field is empty by default — click Save directly
|
||||
const saveBtn = screen.getByRole('button', { name: /Save/i });
|
||||
await user.click(saveBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits POST when name is filled', async () => {
|
||||
mock.onPost('/simulation-templates').reply(201, { ...TEMPLATE, id: 99 });
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
await user.type(screen.getByLabelText(/Name/i), 'My Template');
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(mock.history.post.length).toBe(1);
|
||||
});
|
||||
const body = JSON.parse(mock.history.post[0].data as string) as Record<string, unknown>;
|
||||
expect(body.name).toBe('My Template');
|
||||
});
|
||||
|
||||
it('shows backend error on name conflict (409)', async () => {
|
||||
mock.onPost('/simulation-templates').reply(409, { error: 'template name already exists' });
|
||||
const user = userEvent.setup();
|
||||
renderNew();
|
||||
await user.type(screen.getByLabelText(/Name/i), 'Duplicate');
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('template name already exists')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show Delete button in new mode', () => {
|
||||
renderNew();
|
||||
expect(screen.queryByText('Delete')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TemplateFormPage — edit mode', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('loads existing template data into form', async () => {
|
||||
mock.onGet('/simulation-templates/5').reply(200, TEMPLATE);
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByDisplayValue('Extract NTLM hashes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows technique and tactic chips from existing template', async () => {
|
||||
mock.onGet('/simulation-templates/5').reply(200, TEMPLATE);
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('T1003 — OS Credential Dumping')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTitle('TA0006 — Credential Access')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete button in edit mode', async () => {
|
||||
mock.onGet('/simulation-templates/5').reply(200, TEMPLATE);
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits PATCH on save', async () => {
|
||||
mock.onGet('/simulation-templates/5').reply(200, TEMPLATE);
|
||||
mock.onPatch('/simulation-templates/5').reply(200, { ...TEMPLATE, name: 'Updated' });
|
||||
const user = userEvent.setup();
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }));
|
||||
await waitFor(() => {
|
||||
expect(mock.history.patch.length).toBe(1);
|
||||
});
|
||||
const body = JSON.parse(mock.history.patch[0].data as string) as Record<string, unknown>;
|
||||
expect(body.name).toBe('Mimikatz LSASS Dump');
|
||||
});
|
||||
|
||||
it('opens delete confirm dialog and calls DELETE on confirm', async () => {
|
||||
mock.onGet('/simulation-templates/5').reply(200, TEMPLATE);
|
||||
mock.onDelete('/simulation-templates/5').reply(204);
|
||||
const user = userEvent.setup();
|
||||
renderEdit(5);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
// Click the Delete button inside the dialog
|
||||
const dialogDeleteBtn = screen.getAllByText('Delete').find(
|
||||
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
|
||||
) as HTMLElement;
|
||||
await user.click(dialogDeleteBtn);
|
||||
await waitFor(() => {
|
||||
expect(mock.history.delete.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
frontend/tests/TemplatePickerModal.test.tsx
Normal file
151
frontend/tests/TemplatePickerModal.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { TemplatePickerModal } from '@/components/TemplatePickerModal';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
|
||||
const TEMPLATES: SimulationTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Mimikatz LSASS Dump',
|
||||
description: null,
|
||||
commands: null,
|
||||
prerequisites: null,
|
||||
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: [] }],
|
||||
tactics: [],
|
||||
created_at: '2026-05-28T00:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'PowerShell Empire',
|
||||
description: null,
|
||||
commands: null,
|
||||
prerequisites: null,
|
||||
techniques: [],
|
||||
tactics: [{ id: 'TA0002', name: 'Execution' }],
|
||||
created_at: '2026-05-28T01:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
},
|
||||
];
|
||||
|
||||
const onClose = vi.fn();
|
||||
const onInstantiated = vi.fn();
|
||||
const onSelectTemplate = vi.fn();
|
||||
|
||||
describe('TemplatePickerModal', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows loading state while fetching', () => {
|
||||
mock.onGet('/simulation-templates').reply(() => new Promise(() => {}));
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no templates', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, []);
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/No templates available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists templates with name and MITRE count', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-picker-table')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
expect(screen.getByText('PowerShell Empire')).toBeInTheDocument();
|
||||
// T1(1 tech) + 0 tactics = 1 for first, 0 tech + 1 tactic = 1 for second
|
||||
expect(screen.getAllByText('1').length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onSelectTemplate when a template row is clicked', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTestId('template-row-1'));
|
||||
expect(onSelectTemplate).toHaveBeenCalledWith(TEMPLATES[0]);
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
mock.onGet('/simulation-templates').reply(500, { error: 'Server error' });
|
||||
renderWithProviders(
|
||||
<TemplatePickerModal
|
||||
engagementId={1}
|
||||
onClose={onClose}
|
||||
onInstantiated={onInstantiated}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
frontend/tests/TemplatesListPage.test.tsx
Normal file
138
frontend/tests/TemplatesListPage.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { TemplatesListPage } from '@/pages/TemplatesListPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
|
||||
const TEMPLATES: SimulationTemplate[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Mimikatz LSASS Dump',
|
||||
description: 'Extract NTLM hashes',
|
||||
commands: 'mimikatz.exe',
|
||||
prerequisites: 'Local admin',
|
||||
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: ['credential-access'] }],
|
||||
tactics: [{ id: 'TA0006', name: 'Credential Access' }],
|
||||
created_at: '2026-05-28T00:00:00',
|
||||
updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'PowerShell Empire',
|
||||
description: null,
|
||||
commands: null,
|
||||
prerequisites: null,
|
||||
techniques: [],
|
||||
tactics: [],
|
||||
created_at: '2026-05-28T01:00:00',
|
||||
updated_at: '2026-05-28T02:00:00',
|
||||
created_by: { id: 2, username: 'bob' },
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('TemplatesListPage', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
mock.onGet('/simulation-templates').reply(() => new Promise(() => {}));
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state on failure', async () => {
|
||||
mock.onGet('/simulation-templates').reply(500, { error: 'Server error' });
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-state')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no templates', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, []);
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders template list with name, MITRE count, created by', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('PowerShell Empire')).toBeInTheDocument();
|
||||
// MITRE count: techniques(1) + tactics(1) = 2 for first template
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
// Second template: 0 — shown as —
|
||||
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows New button', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText(/New/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows Edit and Delete actions', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Edit').length).toBe(2);
|
||||
expect(screen.getAllByText('Delete').length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls delete endpoint on confirm', async () => {
|
||||
mock.onGet('/simulation-templates').reply(200, TEMPLATES);
|
||||
mock.onDelete('/simulation-templates/1').reply(204);
|
||||
// After delete, refetch returns updated list
|
||||
mock.onGet('/simulation-templates').reply(200, [TEMPLATES[1]]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<TemplatesListPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButtons = screen.getAllByText('Delete');
|
||||
await user.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mock.history.delete.length).toBe(1);
|
||||
});
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user