feat(phase-15): add Test Catalog page, template instantiation, and auto-migration entrypoint (T-119, T-120, T-121)

T-119: TestCatalogPage with search, filters (source/platform/severity), template cards grid, and pagination

T-120: TestFromTemplateForm modal with pre-filled fields from template, required field validation, and redirect on creation

T-121: Integrate Available Test Templates section in TechniqueDetailPage with Run This Test buttons; fix missing testStateBadgeColors for new states

Also: add backend entrypoint.sh for automatic Alembic migrations + seed on container startup, add curl to Dockerfile for healthcheck
This commit is contained in:
2026-02-09 12:22:29 +01:00
parent cea470053f
commit fd7f855008
8 changed files with 703 additions and 3 deletions

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -13,9 +14,13 @@ import {
Check,
X,
AlertTriangle,
BookOpen,
FlaskConical,
} from "lucide-react";
import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques";
import { getTemplatesByTechnique } from "../api/test-templates";
import { useAuth } from "../context/AuthContext";
import TestFromTemplateForm from "../components/TestFromTemplateForm";
import type { TechniqueStatus, TestState, TestResult } from "../types/models";
const statusBadgeColors: Record<TechniqueStatus, string> = {
@@ -29,6 +34,8 @@ const statusBadgeColors: Record<TechniqueStatus, string> = {
const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
@@ -46,6 +53,8 @@ export default function TechniqueDetailPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [templateFormId, setTemplateFormId] = useState<string | null>(null);
const canReview =
user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
@@ -59,6 +68,12 @@ export default function TechniqueDetailPage() {
enabled: !!mitreId,
});
const { data: templates = [], isLoading: isTemplatesLoading } = useQuery({
queryKey: ["templates-for-technique", mitreId],
queryFn: () => getTemplatesByTechnique(mitreId!),
enabled: !!mitreId,
});
const reviewMutation = useMutation({
mutationFn: () => markTechniqueReviewed(mitreId!),
onSuccess: () => {
@@ -327,6 +342,72 @@ export default function TechniqueDetailPage() {
)}
</div>
{/* Available Test Templates Section */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Available Test Templates</h2>
<button
onClick={() => navigate("/test-catalog")}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
>
<BookOpen className="h-4 w-4" />
Browse Full Catalog
</button>
</div>
{isTemplatesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
</div>
) : templates.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{templates.map((tpl) => (
<div
key={tpl.id}
className="flex items-center justify-between rounded-lg border border-gray-800 bg-gray-800/30 p-4 transition-colors hover:border-gray-700"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-200">{tpl.name}</p>
<div className="mt-1 flex flex-wrap gap-1">
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] text-gray-400">
{tpl.source === "atomic_red_team" ? "Atomic" : tpl.source}
</span>
{tpl.platform && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] capitalize text-gray-400">
{tpl.platform}
</span>
)}
{tpl.severity && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-[10px] capitalize text-gray-400">
{tpl.severity}
</span>
)}
</div>
</div>
<button
onClick={() => setTemplateFormId(tpl.id)}
className="ml-3 flex shrink-0 items-center gap-1 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<FlaskConical className="h-3.5 w-3.5" />
Run This Test
</button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<BookOpen className="mb-2 h-8 w-8 text-gray-600" />
<p className="text-sm">No test templates available for this technique.</p>
<button
onClick={() => navigate("/test-catalog")}
className="mt-3 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
Browse the full test catalog
</button>
</div>
)}
</div>
{/* Intel Items Section */}
{technique.intel_items && technique.intel_items.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
@@ -358,6 +439,15 @@ export default function TechniqueDetailPage() {
</div>
</div>
)}
{/* Template instantiation modal */}
{templateFormId && technique && (
<TestFromTemplateForm
templateId={templateFormId}
techniqueId={technique.id}
onClose={() => setTemplateFormId(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,340 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams, useParams } from "react-router-dom";
import {
Loader2,
Search,
BookOpen,
Filter,
ChevronLeft,
ChevronRight,
FlaskConical,
X,
} from "lucide-react";
import { getTemplates } from "../api/test-templates";
import TestFromTemplateForm from "../components/TestFromTemplateForm";
import type { TestTemplateSummary } from "../types/models";
// ── Constants ──────────────────────────────────────────────────────
const PAGE_SIZE = 12;
const SOURCE_OPTIONS = [
{ value: "", label: "All Sources" },
{ value: "atomic_red_team", label: "Atomic Red Team" },
{ value: "custom", label: "Custom" },
];
const SEVERITY_OPTIONS = [
{ value: "", label: "All Severities" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "critical", label: "Critical" },
];
const PLATFORM_OPTIONS = [
{ value: "", label: "All Platforms" },
{ value: "windows", label: "Windows" },
{ value: "linux", label: "Linux" },
{ value: "macos", label: "macOS" },
{ value: "azure-ad", label: "Azure AD" },
{ value: "office-365", label: "Office 365" },
{ value: "containers", label: "Containers" },
];
const SOURCE_BADGE: Record<string, string> = {
atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30",
custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
};
const SEVERITY_BADGE: Record<string, string> = {
low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
high: "bg-orange-900/50 text-orange-400 border-orange-500/30",
critical: "bg-red-900/50 text-red-400 border-red-500/30",
};
// ── Component ──────────────────────────────────────────────────────
export default function TestCatalogPage() {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("search") || "");
const [source, setSource] = useState(searchParams.get("source") || "");
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
const [page, setPage] = useState(0);
// Build filters
const filters = {
search: search || undefined,
source: source || undefined,
platform: platform || undefined,
severity: severity || undefined,
offset: page * PAGE_SIZE,
limit: PAGE_SIZE,
};
const { data: templates = [], isLoading } = useQuery({
queryKey: ["test-templates", filters],
queryFn: () => getTemplates(filters),
});
// ── Filter handlers ──────────────────────────────────────────────
const applyFilters = () => {
setPage(0);
const params = new URLSearchParams();
if (search) params.set("search", search);
if (source) params.set("source", source);
if (platform) params.set("platform", platform);
if (severity) params.set("severity", severity);
setSearchParams(params);
};
const clearFilters = () => {
setSearch("");
setSource("");
setPlatform("");
setSeverity("");
setPage(0);
setSearchParams({});
};
const hasActiveFilters = search || source || platform || severity;
// ── Render ───────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
<p className="mt-1 text-sm text-gray-400">
Browse available test templates. Use a template to quickly create a security test.
</p>
</div>
{/* Filters bar */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<div className="flex flex-wrap items-end gap-3">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<label className="mb-1 block text-xs font-medium text-gray-500">Search</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && applyFilters()}
placeholder="Search templates..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
/>
</div>
</div>
{/* Source */}
<div className="min-w-[150px]">
<label className="mb-1 block text-xs font-medium text-gray-500">Source</label>
<select
value={source}
onChange={(e) => { setSource(e.target.value); }}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{SOURCE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Platform */}
<div className="min-w-[150px]">
<label className="mb-1 block text-xs font-medium text-gray-500">Platform</label>
<select
value={platform}
onChange={(e) => { setPlatform(e.target.value); }}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{PLATFORM_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Severity */}
<div className="min-w-[140px]">
<label className="mb-1 block text-xs font-medium text-gray-500">Severity</label>
<select
value={severity}
onChange={(e) => { setSeverity(e.target.value); }}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{SEVERITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Buttons */}
<button
onClick={applyFilters}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Filter className="h-4 w-4" />
Apply
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
<X className="h-4 w-4" />
Clear
</button>
)}
</div>
</div>
{/* Results */}
{isLoading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-800 bg-gray-900 py-16">
<BookOpen className="h-12 w-12 text-gray-600" />
<p className="mt-3 text-gray-400">No templates found</p>
<p className="mt-1 text-sm text-gray-500">
{hasActiveFilters
? "Try adjusting your filters or search term."
: "Import Atomic Red Team tests from the System page to populate the catalog."}
</p>
</div>
) : (
<>
{/* Template Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onUse={() => navigate(`/test-catalog/${tpl.id}/use`)}
/>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {page * PAGE_SIZE + 1}
{" - "}
{page * PAGE_SIZE + templates.length}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
<span className="text-sm text-gray-400">Page {page + 1}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={templates.length < PAGE_SIZE}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</>
)}
{/* Template instantiation modal */}
{templateId && (
<TestFromTemplateForm
templateId={templateId}
onClose={() => navigate("/test-catalog")}
/>
)}
</div>
);
}
// ── Template Card ──────────────────────────────────────────────────
function TemplateCard({
template,
onUse,
}: {
template: TestTemplateSummary;
onUse: () => void;
}) {
return (
<div className="flex flex-col rounded-xl border border-gray-800 bg-gray-900 p-5 transition-colors hover:border-gray-700">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 shrink-0 text-cyan-400" />
<h3 className="line-clamp-2 text-sm font-semibold text-white">
{template.name}
</h3>
</div>
</div>
{/* Badges */}
<div className="mt-3 flex flex-wrap gap-1.5">
{/* Technique */}
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
{template.mitre_technique_id}
</span>
{/* Source */}
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
SOURCE_BADGE[template.source] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
}`}
>
{template.source === "atomic_red_team" ? "Atomic" : template.source}
</span>
{/* Platform */}
{template.platform && (
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs capitalize text-gray-400">
{template.platform}
</span>
)}
{/* Severity */}
{template.severity && (
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${
SEVERITY_BADGE[template.severity] || "bg-gray-800/50 text-gray-400 border-gray-600/30"
}`}
>
{template.severity}
</span>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Action */}
<button
onClick={onUse}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 py-2 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<FlaskConical className="h-4 w-4" />
Use Template
</button>
</div>
);
}