feature/m5-templates #2

Merged
knacky merged 6 commits from feature/m5-templates into main 2026-05-13 09:19:54 +00:00
7 changed files with 1042 additions and 1 deletions
Showing only changes of commit 2781ce4117 - Show all commits

View File

@@ -13,6 +13,9 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/ibm-plex-sans": "^5.0.20", "@fontsource/ibm-plex-sans": "^5.0.20",
"@fontsource/jetbrains-mono": "^5.0.20", "@fontsource/jetbrains-mono": "^5.0.20",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",

View File

@@ -6,6 +6,8 @@ import { RequireAdmin } from '@/components/RequireAdmin';
import { RequireAuth } from '@/components/RequireAuth'; import { RequireAuth } from '@/components/RequireAuth';
import { AdminGroupsPage } from '@/pages/AdminGroupsPage'; import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'; import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
import { AdminScenariosPage } from '@/pages/AdminScenariosPage';
import { AdminTestsPage } from '@/pages/AdminTestsPage';
import { AdminUsersPage } from '@/pages/AdminUsersPage'; import { AdminUsersPage } from '@/pages/AdminUsersPage';
import { HomePage } from '@/pages/HomePage'; import { HomePage } from '@/pages/HomePage';
import { MitrePage } from '@/pages/MitrePage'; import { MitrePage } from '@/pages/MitrePage';
@@ -82,6 +84,22 @@ function App() {
</RequireAdmin> </RequireAdmin>
} }
/> />
<Route
path="/admin/tests"
element={
<RequireAdmin>
<AdminTestsPage />
</RequireAdmin>
}
/>
<Route
path="/admin/scenarios"
element={
<RequireAdmin>
<AdminScenariosPage />
</RequireAdmin>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -42,6 +42,8 @@ export function Layout() {
{navItem('/admin/users', 'Users')} {navItem('/admin/users', 'Users')}
{navItem('/admin/groups', 'Groups')} {navItem('/admin/groups', 'Groups')}
{navItem('/admin/invitations', 'Invitations')} {navItem('/admin/invitations', 'Invitations')}
{navItem('/admin/tests', 'Tests')}
{navItem('/admin/scenarios', 'Scenarios')}
</> </>
)} )}
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email"> <span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
@@ -69,7 +71,7 @@ export function Layout() {
<Outlet /> <Outlet />
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim"> <footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · design system from tasks/design.md metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · design system from tasks/design.md
</footer> </footer>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,45 @@
import { useId, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface MarkdownFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
label: string;
value: string;
onChange: (next: string) => void;
rows?: number;
hint?: string;
}
/**
* Markdown-content textarea. We deliberately keep it textarea-only (no fancy
* WYSIWYG editor) — markdown lives well in plain text and the saved blob is
* rendered to HTML at display time (M6/M7 mission pages). The label exposes
* "markdown" so the user knows the field accepts MD syntax.
*/
export function MarkdownField({ label, value, onChange, rows = 6, hint, id, className, ...rest }: MarkdownFieldProps) {
const fallbackId = useId();
const inputId = id ?? fallbackId;
return (
<div className="block">
<label
htmlFor={inputId}
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
>
{label} <span className="text-text-dim/60">· markdown</span>
</label>
<textarea
id={inputId}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
'focus:border-cyan focus:outline-none',
className,
)}
{...rest}
/>
{hint && <p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>}
</div>
);
}

View File

@@ -0,0 +1,136 @@
/**
* Shared types + query-key factory for the M5 template catalogue.
*
* Two resources: `test_templates` (atomic test units) and `scenario_templates`
* (ordered lists of tests). Both back the admin pages and feed the M6 mission
* wizard.
*/
import type { MitreTagKind } from './mitre';
export type OpsecLevel = 'low' | 'medium' | 'high';
export interface MitreTagOut {
kind: MitreTagKind;
external_id: string;
name: string;
url: string | null;
}
export interface MitreTagInWire {
kind: MitreTagKind;
external_id: string;
}
export interface TestTemplate {
id: string;
name: string;
description: string | null;
objective: string | null;
procedure_md: string | null;
prerequisites_md: string | null;
expected_result_red_md: string | null;
expected_detection_blue_md: string | null;
opsec_level: OpsecLevel;
tags: string[];
expected_iocs: string[];
mitre_tags: MitreTagOut[];
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface TestTemplateListResponse {
items: TestTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateTestTemplatePayload {
name: string;
description?: string | null;
objective?: string | null;
procedure_md?: string | null;
prerequisites_md?: string | null;
expected_result_red_md?: string | null;
expected_detection_blue_md?: string | null;
opsec_level?: OpsecLevel;
tags?: string[];
expected_iocs?: string[];
mitre_tags?: MitreTagInWire[];
}
export type UpdateTestTemplatePayload = Partial<CreateTestTemplatePayload>;
export interface ScenarioTest {
position: number;
test_template_id: string;
test_template_name: string;
test_template_deleted: boolean;
}
export interface ScenarioTemplate {
id: string;
name: string;
description: string | null;
tests: ScenarioTest[];
tests_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface ScenarioTemplateListResponse {
items: ScenarioTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateScenarioPayload {
name: string;
description?: string | null;
test_template_ids?: string[];
}
export interface UpdateScenarioPayload {
name?: string;
description?: string | null;
}
export interface SetScenarioTestsPayload {
test_template_ids: string[];
}
export interface TestTemplateFilters {
q?: string;
tactic?: string;
technique?: string;
subtechnique?: string;
opsec?: OpsecLevel | '';
tag?: string;
}
export const templateKeys = {
// Test templates
tests: (filters?: TestTemplateFilters) =>
['templates', 'tests', filters ?? {}] as const,
test: (id: string) => ['templates', 'tests', id] as const,
// Scenario templates
scenarios: (q?: string) => ['templates', 'scenarios', q ?? ''] as const,
scenario: (id: string) => ['templates', 'scenarios', id] as const,
};
export function buildTestQueryString(filters: TestTemplateFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.tactic) params.set('tactic', filters.tactic);
if (filters.technique) params.set('technique', filters.technique);
if (filters.subtechnique) params.set('subtechnique', filters.subtechnique);
if (filters.opsec) params.set('opsec', filters.opsec);
if (filters.tag) params.set('tag', filters.tag);
const s = params.toString();
return s ? `?${s}` : '';
}

View File

@@ -0,0 +1,434 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
templateKeys,
type CreateScenarioPayload,
type ScenarioTemplate,
type ScenarioTemplateListResponse,
type TestTemplate,
type TestTemplateListResponse,
} from '@/lib/templates';
interface FormState {
name: string;
description: string;
test_ids: string[];
}
function blankForm(): FormState {
return { name: '', description: '', test_ids: [] };
}
function toForm(sc: ScenarioTemplate): FormState {
return {
name: sc.name,
description: sc.description ?? '',
test_ids: sc.tests.map((t) => t.test_template_id),
};
}
function useScenarios(q: string) {
return useQuery({
queryKey: templateKeys.scenarios(q),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>(
`/scenario-templates${q ? `?q=${encodeURIComponent(q)}` : ''}`,
),
});
}
function useTestCatalogue() {
return useQuery({
queryKey: templateKeys.tests({}),
queryFn: () => apiGet<TestTemplateListResponse>('/test-templates?limit=500'),
});
}
interface SortableTestRowProps {
id: string;
index: number;
name: string;
onRemove: () => void;
}
function SortableTestRow({ id, index, name, onRemove }: SortableTestRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<li
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-md border border-border bg-bg-card px-3 py-2"
data-testid={`scenario-test-row-${id}`}
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab font-mono text-text-dim hover:text-text-bright active:cursor-grabbing"
aria-label={`Drag ${name}`}
data-testid={`drag-handle-${id}`}
>
</button>
<span className="font-mono text-2xs text-text-dim w-6">{String(index + 1).padStart(2, '0')}</span>
<span className="font-mono text-xs text-text-bright flex-1">{name}</span>
<Button variant="ghost" accent="rose" onClick={onRemove} aria-label={`Remove ${name}`}>
</Button>
</li>
);
}
export function AdminScenariosPage() {
const qc = useQueryClient();
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<ScenarioTemplate | null>(null);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const scenarios = useScenarios(q);
const catalogue = useTestCatalogue();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const create = useMutation({
mutationFn: (payload: CreateScenarioPayload) =>
apiPost<ScenarioTemplate>('/scenario-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
onError: (e) => setError(humanError(e)),
});
const updateMeta = useMutation({
mutationFn: ({ id, name, description }: { id: string; name: string; description: string | null }) =>
apiPatch<ScenarioTemplate>(`/scenario-templates/${id}`, { name, description }),
});
const setTests = useMutation({
mutationFn: ({ id, test_template_ids }: { id: string; test_template_ids: string[] }) =>
apiPut<ScenarioTemplate>(`/scenario-templates/${id}/tests`, { test_template_ids }),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/scenario-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(sc: ScenarioTemplate) {
setForm(toForm(sc));
setError(null);
setEditing(sc);
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
setForm((f) => {
const from = f.test_ids.indexOf(String(active.id));
const to = f.test_ids.indexOf(String(over.id));
if (from < 0 || to < 0) return f;
return { ...f, test_ids: arrayMove(f.test_ids, from, to) };
});
}
async function submit() {
setError(null);
if (!form.name.trim()) {
setError('Name is required.');
return;
}
try {
if (editing) {
// Two-step: metadata first, then ordered tests.
await updateMeta.mutateAsync({
id: editing.id,
name: form.name.trim(),
description: form.description || null,
});
await setTests.mutateAsync({ id: editing.id, test_template_ids: form.test_ids });
} else {
await create.mutateAsync({
name: form.name.trim(),
description: form.description || null,
test_template_ids: form.test_ids,
});
}
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
setEditing(null);
setCreating(false);
} catch (e) {
setError(humanError(e));
}
}
const isModalOpen = creating || editing !== null;
const testNameById = useMemo(() => {
const map = new Map<string, string>();
catalogue.data?.items.forEach((t) => map.set(t.id, t.name));
return map;
}, [catalogue.data]);
// Tests available for inclusion (excluding already-picked + soft-deleted).
const availableTests = useMemo<TestTemplate[]>(() => {
if (!catalogue.data) return [];
const picked = new Set(form.test_ids);
return catalogue.data.items.filter((t) => !picked.has(t.id) && !t.deleted_at);
}, [catalogue.data, form.test_ids]);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Scenarios"
accent="purple"
description="Ordered playbooks composed from the test catalogue. Drag rows to reorder; the order is the execution sequence."
/>
<div className="mb-6 flex flex-wrap items-end gap-3">
<TextField
label="Search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="name or description"
data-testid="scenarios-search"
/>
<Button accent="purple" onClick={openCreate} data-testid="create-scenario" className="ml-auto">
+ New scenario
</Button>
</div>
{scenarios.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
<div className="grid gap-3" data-testid="scenarios-list">
{scenarios.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{scenarios.data?.items.map((sc) => (
<Card
key={sc.id}
accent="purple"
title={sc.name}
sub={sc.description ?? '—'}
data-testid={`scenario-row-${sc.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent="purple">{sc.tests_count} test{sc.tests_count === 1 ? '' : 's'}</Tag>
{sc.tests.slice(0, 4).map((t) => (
<Tag key={`${sc.id}:${t.position}`} accent={t.test_template_deleted ? 'rose' : 'cyan'}>
{t.position + 1}. {t.test_template_name}
</Tag>
))}
{sc.tests.length > 4 && (
<Tag accent="yellow">+{sc.tests.length - 4} more</Tag>
)}
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => openEdit(sc)} data-testid={`edit-scenario-${sc.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${sc.name}"?`)) remove.mutate(sc.id);
}}
data-testid={`delete-scenario-${sc.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{scenarios.data && scenarios.data.items.length === 0 && !scenarios.isLoading && (
<p className="font-mono text-2xs text-text-dim">No scenarios yet.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Scenario · ${editing.name}` : 'New scenario template'}
accent="purple"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="scenario-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="grid gap-3">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-scenario-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-scenario-description"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Tests in order ({form.test_ids.length})
</p>
{form.test_ids.length === 0 ? (
<p className="font-mono text-2xs text-text-dim mb-2">No test picked yet.</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={form.test_ids} strategy={verticalListSortingStrategy}>
<ol className="grid gap-2 mb-3" data-testid="scenario-tests-ordered">
{form.test_ids.map((id, idx) => (
<SortableTestRow
key={id}
id={id}
index={idx}
name={testNameById.get(id) ?? '<missing>'}
onRemove={() =>
setForm((f) => ({ ...f, test_ids: f.test_ids.filter((t) => t !== id) }))
}
/>
))}
</ol>
</SortableContext>
</DndContext>
)}
<CataloguePicker
tests={availableTests}
onAdd={(id) => setForm((f) => ({ ...f, test_ids: [...f.test_ids, id] }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="purple"
onClick={submit}
disabled={create.isPending || updateMeta.isPending || setTests.isPending}
data-testid="form-scenario-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
interface CataloguePickerProps {
tests: TestTemplate[];
onAdd: (id: string) => void;
}
function CataloguePicker({ tests, onAdd }: CataloguePickerProps) {
const [q, setQ] = useState('');
const filtered = useMemo(() => {
const norm = q.trim().toLowerCase();
if (!norm) return tests.slice(0, 50);
return tests.filter((t) => t.name.toLowerCase().includes(norm)).slice(0, 50);
}, [tests, q]);
return (
<div className="rounded-md border border-border bg-bg-card p-3">
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Add a test from the catalogue
</p>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search catalogue…"
className="mb-2 w-full rounded-md border border-border bg-bg-base px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim focus:border-cyan focus:outline-none"
data-testid="scenario-catalogue-search"
/>
<ul className="max-h-48 overflow-auto grid gap-1" data-testid="scenario-catalogue-list">
{filtered.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => onAdd(t.id)}
className="flex-1 text-left rounded-sm px-2 py-1 font-mono text-xs text-text-bright hover:bg-bg-base"
data-testid={`catalogue-add-${t.id}`}
>
+ {t.name}
</button>
</li>
))}
{filtered.length === 0 && (
<li className="font-mono text-2xs text-text-dim">No matching test in the catalogue.</li>
)}
</ul>
</div>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}

View File

@@ -0,0 +1,403 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { MarkdownField } from '@/components/MarkdownField';
import { MitreTagPicker } from '@/components/MitreTagPicker';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
} from '@/lib/api';
import { type MitreTag, type MitreTagKind } from '@/lib/mitre';
import {
buildTestQueryString,
templateKeys,
type CreateTestTemplatePayload,
type OpsecLevel,
type TestTemplate,
type TestTemplateFilters,
type TestTemplateListResponse,
} from '@/lib/templates';
const OPSEC_LEVELS: OpsecLevel[] = ['low', 'medium', 'high'];
interface FormState {
name: string;
description: string;
objective: string;
procedure_md: string;
prerequisites_md: string;
expected_result_red_md: string;
expected_detection_blue_md: string;
opsec_level: OpsecLevel;
tags: string;
expected_iocs: string;
mitre_tags: MitreTag[];
}
function blankForm(): FormState {
return {
name: '',
description: '',
objective: '',
procedure_md: '',
prerequisites_md: '',
expected_result_red_md: '',
expected_detection_blue_md: '',
opsec_level: 'medium',
tags: '',
expected_iocs: '',
mitre_tags: [],
};
}
function toForm(t: TestTemplate): FormState {
return {
name: t.name,
description: t.description ?? '',
objective: t.objective ?? '',
procedure_md: t.procedure_md ?? '',
prerequisites_md: t.prerequisites_md ?? '',
expected_result_red_md: t.expected_result_red_md ?? '',
expected_detection_blue_md: t.expected_detection_blue_md ?? '',
opsec_level: t.opsec_level,
tags: t.tags.join(', '),
expected_iocs: t.expected_iocs.join(', '),
mitre_tags: t.mitre_tags.map((tag) => ({
kind: tag.kind as MitreTagKind,
id: tag.external_id,
external_id: tag.external_id,
name: tag.name,
})),
};
}
function csvToList(s: string): string[] {
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
function toPayload(form: FormState): CreateTestTemplatePayload {
return {
name: form.name.trim(),
description: form.description || null,
objective: form.objective || null,
procedure_md: form.procedure_md || null,
prerequisites_md: form.prerequisites_md || null,
expected_result_red_md: form.expected_result_red_md || null,
expected_detection_blue_md: form.expected_detection_blue_md || null,
opsec_level: form.opsec_level,
tags: csvToList(form.tags),
expected_iocs: csvToList(form.expected_iocs),
mitre_tags: form.mitre_tags.map((t) => ({ kind: t.kind, external_id: t.external_id })),
};
}
function useTestTemplates(filters: TestTemplateFilters) {
return useQuery({
queryKey: templateKeys.tests(filters),
queryFn: () =>
apiGet<TestTemplateListResponse>(`/test-templates${buildTestQueryString(filters)}`),
});
}
export function AdminTestsPage() {
const qc = useQueryClient();
const [filters, setFilters] = useState<TestTemplateFilters>({});
const [editing, setEditing] = useState<TestTemplate | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const tests = useTestTemplates(filters);
const create = useMutation({
mutationFn: (payload: CreateTestTemplatePayload) =>
apiPost<TestTemplate>('/test-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const update = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: CreateTestTemplatePayload }) =>
apiPut<TestTemplate>(`/test-templates/${id}`, payload),
onSuccess: async () => {
setEditing(null);
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/test-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(t: TestTemplate) {
setForm(toForm(t));
setError(null);
setEditing(t);
}
function submit() {
const payload = toPayload(form);
if (!payload.name) {
setError('Name is required.');
return;
}
if (editing) update.mutate({ id: editing.id, payload });
else create.mutate(payload);
}
const isModalOpen = creating || editing !== null;
return (
<>
<SectionHeader
prefix="Admin"
highlight="Tests"
accent="orange"
description="Reusable test units. Each test belongs to a scenario at instantiation time, but the catalogue lives independently."
/>
<div className="mb-6 flex flex-wrap items-end gap-3" data-testid="tests-filters">
<TextField
label="Search"
placeholder="name or description"
value={filters.q ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, q: e.target.value || undefined }))}
data-testid="filter-q"
/>
<TextField
label="Tactic external_id"
placeholder="TA0006"
value={filters.tactic ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tactic: e.target.value || undefined }))}
data-testid="filter-tactic"
/>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC
</span>
<select
value={filters.opsec ?? ''}
onChange={(e) =>
setFilters((f) => ({ ...f, opsec: (e.target.value as OpsecLevel | '') || undefined }))
}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="filter-opsec"
>
<option value=""> all </option>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tag"
placeholder="phish"
value={filters.tag ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tag: e.target.value || undefined }))}
data-testid="filter-tag"
/>
<Button accent="orange" onClick={openCreate} data-testid="create-test" className="ml-auto">
+ New test
</Button>
</div>
{tests.isError && <Alert accent="red">Failed to load tests.</Alert>}
<div className="grid gap-3" data-testid="tests-list">
{tests.isLoading && <p className="font-mono text-xs text-text-dim">Loading</p>}
{tests.data?.items.map((t) => (
<Card
key={t.id}
accent="orange"
title={t.name}
sub={t.description ?? '—'}
data-testid={`test-row-${t.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={t.opsec_level === 'high' ? 'red' : t.opsec_level === 'low' ? 'green' : 'yellow'}>
opsec: {t.opsec_level}
</Tag>
{t.mitre_tags.map((tag) => (
<Tag
key={`${tag.kind}:${tag.external_id}`}
accent={tag.kind === 'tactic' ? 'cyan' : tag.kind === 'technique' ? 'orange' : 'purple'}
>
{tag.external_id}
</Tag>
))}
{t.tags.map((tg) => (
<Tag key={tg} accent="cyan">#{tg}</Tag>
))}
<div className="ml-auto flex gap-2">
<Button accent="orange" onClick={() => openEdit(t)} data-testid={`edit-test-${t.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${t.name}"?`)) remove.mutate(t.id);
}}
data-testid={`delete-test-${t.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{tests.data && tests.data.items.length === 0 && !tests.isLoading && (
<p className="font-mono text-2xs text-text-dim">No tests match the current filters.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Test · ${editing.name}` : 'New test template'}
accent="orange"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="test-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="grid gap-3">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-description"
/>
<TextField
label="Objective (1-liner)"
value={form.objective}
onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))}
/>
<MarkdownField
label="Procedure"
value={form.procedure_md}
onChange={(v) => setForm((f) => ({ ...f, procedure_md: v }))}
data-testid="form-procedure"
hint="Step-by-step playbook. Markdown supported."
/>
<MarkdownField
label="Prerequisites"
value={form.prerequisites_md}
onChange={(v) => setForm((f) => ({ ...f, prerequisites_md: v }))}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<MarkdownField
label="Red — expected result"
value={form.expected_result_red_md}
onChange={(v) => setForm((f) => ({ ...f, expected_result_red_md: v }))}
rows={4}
/>
<MarkdownField
label="Blue — expected detection"
value={form.expected_detection_blue_md}
onChange={(v) => setForm((f) => ({ ...f, expected_detection_blue_md: v }))}
rows={4}
/>
</div>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC level
</span>
<select
value={form.opsec_level}
onChange={(e) => setForm((f) => ({ ...f, opsec_level: e.target.value as OpsecLevel }))}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="form-opsec"
>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tags (comma-separated)"
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
data-testid="form-tags"
hint="e.g. phish, persistence, quick-win"
/>
<TextField
label="Expected IOCs (comma-separated)"
value={form.expected_iocs}
onChange={(e) => setForm((f) => ({ ...f, expected_iocs: e.target.value }))}
hint="Indicators the blue team should look for"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-1">
MITRE ATT&amp;CK tags
</p>
<MitreTagPicker
value={form.mitre_tags}
onChange={(next) => setForm((f) => ({ ...f, mitre_tags: next }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="orange"
onClick={submit}
disabled={create.isPending || update.isPending}
data-testid="form-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}