- 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>
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
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();
|
|
});
|
|
});
|