220 lines
7.4 KiB
TypeScript
220 lines
7.4 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 { 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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|