From 0002601b50788055eb876111e08446113c8537d2 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 16 Jul 2026 10:01:20 +0200 Subject: [PATCH] feat(templates): add pagination with total counts to catalog and campaign picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Catalog's pagination showed only 'Page N' with no way to know how many pages existed, and the campaign 'Add Test' modal's template picker had no pagination at all — with 2800+ templates in the catalog, its flat 100-row fetch made most templates unreachable unless a search happened to match. Adds GET /test-templates/count (open to any authenticated user, mirrors the list endpoint's filters) and wires real 'Page N of M' + total-available counts into both. --- backend/app/routers/test_templates.py | 30 ++++++ backend/app/services/test_template_service.py | 100 +++++++++++------- .../tests/test_t111_test_templates_router.py | 7 +- backend/tests/test_template_count_endpoint.py | 68 ++++++++++++ backend/tests/test_templates_crud.py | 7 +- frontend/src/api/test-templates.ts | 19 ++++ .../src/components/AddTestToCampaignModal.tsx | 60 +++++++++-- frontend/src/pages/TestCatalogPage.tsx | 37 +++++-- 8 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 backend/tests/test_template_count_endpoint.py diff --git a/backend/app/routers/test_templates.py b/backend/app/routers/test_templates.py index 1cc3fba..4bc5aa8 100644 --- a/backend/app/routers/test_templates.py +++ b/backend/app/routers/test_templates.py @@ -58,6 +58,7 @@ from app.services.audit_service import log_action # Import from app.services.test_template_service from app.services.test_template_service import ( bulk_activate, + count_templates, get_template_or_raise, get_template_stats, list_templates, @@ -156,6 +157,35 @@ def _list_templates_handler( ) +# --------------------------------------------------------------------------- +# GET /test-templates/count — total matching a filter, for pagination +# --------------------------------------------------------------------------- + + +@router.get("/count") +def _count_templates_handler( + source: Optional[str] = Query(None), + platform: Optional[str] = Query(None), + severity: Optional[str] = Query(None), + mitre_technique_id: Optional[str] = Query(None), + search: Optional[str] = Query(None), + is_active: Optional[bool] = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + """Return the total count of templates matching the same filters as the + list endpoint, so the catalog UI can show a true page count.""" + return {"total": count_templates( + db, + source=source, + platform=platform, + severity=severity, + mitre_technique_id=mitre_technique_id, + search=search, + is_active=is_active, + )} + + # --------------------------------------------------------------------------- # GET /test-templates/stats — catalog statistics (admin) # --------------------------------------------------------------------------- diff --git a/backend/app/services/test_template_service.py b/backend/app/services/test_template_service.py index ceff86c..3fb5e18 100644 --- a/backend/app/services/test_template_service.py +++ b/backend/app/services/test_template_service.py @@ -23,6 +23,42 @@ from app.models.test import Test from app.utils import escape_like +def _build_template_query( + db: Session, + *, + source: str | None = None, + platform: str | None = None, + severity: str | None = None, + mitre_technique_id: str | None = None, + search: str | None = None, + is_active: bool | None = None, +): + """Build the filtered TestTemplate query shared by list_templates() and + count_templates() — a single source of truth so a paginated page of + results and the total count it's paginated against can never drift + apart (same pattern used for tests in test_crud_service.py).""" + query = db.query(TestTemplate) + if is_active is not None: + query = query.filter(TestTemplate.is_active == is_active) + if source: + query = query.filter(TestTemplate.source == source) + if platform: + query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%")) + if severity: + query = query.filter(TestTemplate.severity == severity) + if mitre_technique_id: + query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id) + if search: + pattern = f"%{escape_like(search)}%" + query = query.filter( + or_( + TestTemplate.name.ilike(pattern), + TestTemplate.description.ilike(pattern), + ) + ) + return query + + # Define function list_templates def list_templates( # Entry: db @@ -46,51 +82,16 @@ def list_templates( limit: int = 50, ) -> list: """Return paginated, filterable list of test templates.""" - # Assign query = db.query(TestTemplate) - query = db.query(TestTemplate) - # Check: is_active is not None - if is_active is not None: - # Assign query = query.filter(TestTemplate.is_active == is_active) - query = query.filter(TestTemplate.is_active == is_active) + query = _build_template_query( + db, source=source, platform=platform, severity=severity, + mitre_technique_id=mitre_technique_id, search=search, is_active=is_active, + ) - # Check: source - if source: - # Assign query = query.filter(TestTemplate.source == source) - query = query.filter(TestTemplate.source == source) - # Check: platform - if platform: - # Assign query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}... - query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%")) - # Check: severity - if severity: - # Assign query = query.filter(TestTemplate.severity == severity) - query = query.filter(TestTemplate.severity == severity) - # Check: mitre_technique_id - if mitre_technique_id: - # Assign query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id) - query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id) - # Check: search - if search: - # Assign pattern = f"%{escape_like(search)}%" - pattern = f"%{escape_like(search)}%" - # Assign query = query.filter( - query = query.filter( - or_( - TestTemplate.name.ilike(pattern), - TestTemplate.description.ilike(pattern), - ) - ) - - # Assign templates = ( templates = ( query - # Chain .order_by() call .order_by(TestTemplate.mitre_technique_id, TestTemplate.name) - # Chain .offset() call .offset(offset) - # Chain .limit() call .limit(limit) - # Chain .all() call .all() ) @@ -108,10 +109,29 @@ def list_templates( for t in templates: t.existing_test_count = counts.get(t.mitre_technique_id, 0) - # Return templates return templates +def count_templates( + db: Session, + *, + source: str | None = None, + platform: str | None = None, + severity: str | None = None, + mitre_technique_id: str | None = None, + search: str | None = None, + is_active: bool | None = None, +) -> int: + """Return the total count of templates matching the same filters as + list_templates() — lets the catalog UI show a true page count even + though the list endpoint caps how many rows it returns per request.""" + query = _build_template_query( + db, source=source, platform=platform, severity=severity, + mitre_technique_id=mitre_technique_id, search=search, is_active=is_active, + ) + return query.count() + + # Define function get_template_stats def get_template_stats(db: Session) -> dict: """Return catalog statistics: totals by source, platform, active/inactive.""" diff --git a/backend/tests/test_t111_test_templates_router.py b/backend/tests/test_t111_test_templates_router.py index 9e7f2c8..fb42c81 100644 --- a/backend/tests/test_t111_test_templates_router.py +++ b/backend/tests/test_t111_test_templates_router.py @@ -182,11 +182,14 @@ def test_soft_delete(): def test_search_filter(): - from app.routers.test_templates import list_templates - source = inspect.getsource(list_templates) + from app.services.test_template_service import _build_template_query, list_templates + # The actual filter-building (including search) now lives in the shared + # _build_template_query() helper, reused by both list and count. + source = inspect.getsource(_build_template_query) assert "search" in source assert "name" in source and "description" in source assert "ilike" in source + assert "_build_template_query" in inspect.getsource(list_templates) print(" [PASS] Search filter searches in name and description") diff --git a/backend/tests/test_template_count_endpoint.py b/backend/tests/test_template_count_endpoint.py new file mode 100644 index 0000000..8580f65 --- /dev/null +++ b/backend/tests/test_template_count_endpoint.py @@ -0,0 +1,68 @@ +"""GET /test-templates/count — total matching the same filters as the list +endpoint, so the catalog UI can compute a true page count.""" + +import pytest + + +@pytest.fixture +def technique(api, auth_headers): + resp = api( + "post", "/api/v1/techniques", auth_headers, + json={"mitre_id": "T1059.300", "name": "Count Endpoint Technique"}, + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def _create_template(api, headers, mitre_technique_id, name): + resp = api( + "post", "/api/v1/test-templates", headers, + json={"mitre_technique_id": mitre_technique_id, "name": name}, + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def test_count_matches_number_of_matching_templates( + client, db, api, auth_headers, red_lead_headers, technique, +): + _create_template(api, red_lead_headers, "T1059.300", "Count test template A") + _create_template(api, red_lead_headers, "T1059.300", "Count test template B") + + resp = api( + "get", "/api/v1/test-templates/count", auth_headers, + params={"mitre_technique_id": "T1059.300"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["total"] == 2 + + +def test_count_respects_search_filter(client, db, api, auth_headers, red_lead_headers, technique): + _create_template(api, red_lead_headers, "T1059.300", "Unique Zebra Template") + _create_template(api, red_lead_headers, "T1059.300", "Something else entirely") + + resp = api("get", "/api/v1/test-templates/count", auth_headers, params={"search": "Zebra"}) + assert resp.status_code == 200, resp.text + assert resp.json()["total"] == 1 + + +def test_count_matches_list_length_when_under_page_size( + client, db, api, auth_headers, red_lead_headers, technique, +): + _create_template(api, red_lead_headers, "T1059.300", "Parity check template") + + listed = api( + "get", "/api/v1/test-templates", auth_headers, + params={"mitre_technique_id": "T1059.300"}, + ) + counted = api( + "get", "/api/v1/test-templates/count", auth_headers, + params={"mitre_technique_id": "T1059.300"}, + ) + assert len(listed.json()) == counted.json()["total"] + + +def test_count_accessible_to_any_authenticated_role(client, db, api, red_tech_headers): + """Pagination is a basic UX need, not a lead-only privilege like /stats.""" + resp = api("get", "/api/v1/test-templates/count", red_tech_headers) + assert resp.status_code == 200, resp.text diff --git a/backend/tests/test_templates_crud.py b/backend/tests/test_templates_crud.py index 2169208..2653d10 100644 --- a/backend/tests/test_templates_crud.py +++ b/backend/tests/test_templates_crud.py @@ -147,8 +147,11 @@ def test_list_templates_with_filters(): assert "search" in source, "List must accept search filter" assert "mitre_technique_id" in source, "List must accept mitre_technique_id filter" - # Verify ilike is used for search - assert "ilike" in source, "Search should use ilike for case-insensitive matching" + # Verify ilike is used for search — lives in the shared + # _build_template_query() helper now, reused by list and count. + from app.services.test_template_service import _build_template_query + assert "ilike" in inspect.getsource(_build_template_query), \ + "Search should use ilike for case-insensitive matching" # =========================================================================== diff --git a/frontend/src/api/test-templates.ts b/frontend/src/api/test-templates.ts index 6d5fea5..45f0400 100644 --- a/frontend/src/api/test-templates.ts +++ b/frontend/src/api/test-templates.ts @@ -65,6 +65,25 @@ export async function getTemplates( return data; } +/** Total count of templates matching the same filters as getTemplates() — for pagination. */ +export async function getTemplateCount( + filters?: Omit, +): Promise { + const params = new URLSearchParams(); + if (filters?.source) params.append("source", filters.source); + if (filters?.platform) params.append("platform", filters.platform); + if (filters?.severity) params.append("severity", filters.severity); + if (filters?.mitre_technique_id) + params.append("mitre_technique_id", filters.mitre_technique_id); + if (filters?.search) params.append("search", filters.search); + params.append("is_active", filters?.is_active !== undefined ? String(filters.is_active) : "true"); + + const { data } = await client.get<{ total: number }>( + `/test-templates/count${params.toString() ? `?${params}` : ""}`, + ); + return data.total; +} + // ── Detail ───────────────────────────────────────────────────────── /** Fetch a single test template by ID. */ diff --git a/frontend/src/components/AddTestToCampaignModal.tsx b/frontend/src/components/AddTestToCampaignModal.tsx index 0bdd09b..cf3219d 100644 --- a/frontend/src/components/AddTestToCampaignModal.tsx +++ b/frontend/src/components/AddTestToCampaignModal.tsx @@ -9,12 +9,13 @@ import { FlaskConical, BookOpen, ArrowLeft, + ChevronLeft, ChevronRight, Filter, } from "lucide-react"; import { getTests, createTestFromTemplate, updateTest } from "../api/tests"; import { addTestToCampaign } from "../api/campaigns"; -import { getTemplates, getTemplateById } from "../api/test-templates"; +import { getTemplates, getTemplateCount, getTemplateById } from "../api/test-templates"; import type { Test, TestState, TestTemplateSummary } from "../types/models"; /* ── helpers ─────────────────────────────────────────────────────── */ @@ -81,6 +82,8 @@ export default function AddTestToCampaignModal({ const [templateSearch, setTemplateSearch] = useState(""); const [filterPlatform, setFilterPlatform] = useState(""); const [filterSeverity, setFilterSeverity] = useState(""); + const [templatePage, setTemplatePage] = useState(0); + const TEMPLATE_PAGE_SIZE = 25; const [selectedTemplateId, setSelectedTemplateId] = useState(null); // form fields (pre-filled from selected template) @@ -102,6 +105,7 @@ export default function AddTestToCampaignModal({ setTemplateSearch(""); setFilterPlatform(""); setFilterSeverity(""); + setTemplatePage(0); setSelectedTemplateId(null); } }, [open]); @@ -119,18 +123,32 @@ export default function AddTestToCampaignModal({ }); const { data: templates, isLoading: templatesLoading } = useQuery({ - queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch], + queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch, templatePage], queryFn: () => getTemplates({ search: templateSearch || undefined, platform: filterPlatform || undefined, severity: filterSeverity || undefined, - limit: 100, + offset: templatePage * TEMPLATE_PAGE_SIZE, + limit: TEMPLATE_PAGE_SIZE, }), enabled: open && tab === "template", staleTime: 60_000, }); + const { data: templateTotal = 0 } = useQuery({ + queryKey: ["templates", "picker-count", filterPlatform, filterSeverity, templateSearch], + queryFn: () => + getTemplateCount({ + search: templateSearch || undefined, + platform: filterPlatform || undefined, + severity: filterSeverity || undefined, + }), + enabled: open && tab === "template", + staleTime: 60_000, + }); + const templateTotalPages = Math.max(1, Math.ceil(templateTotal / TEMPLATE_PAGE_SIZE)); + const { data: fullTemplate, isLoading: fullTemplateLoading } = useQuery({ queryKey: ["template-detail", selectedTemplateId], queryFn: () => getTemplateById(selectedTemplateId!), @@ -288,7 +306,7 @@ export default function AddTestToCampaignModal({ setTemplateSearch(e.target.value)} + onChange={(e) => { setTemplateSearch(e.target.value); setTemplatePage(0); }} placeholder="Search templates by name or technique…" className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" /> @@ -299,7 +317,7 @@ export default function AddTestToCampaignModal({