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( } /> } /> ), client, }; } function renderEdit(id: number) { const client = makeClient(); return { ...render( } /> } /> ), 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('/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; expect(body.name).toBe('My Template'); }); it('shows backend error on name conflict (409)', async () => { mock.onPost('/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('/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('/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('/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('/templates/5').reply(200, TEMPLATE); mock.onPatch('/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; expect(body.name).toBe('Mimikatz LSASS Dump'); }); it('opens delete confirm dialog and calls DELETE on confirm', async () => { mock.onGet('/templates/5').reply(200, TEMPLATE); mock.onDelete('/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); }); }); });