feat(tests): richer server-side filters + true total count for GET /tests

GET /tests caps limit at 200, so any page relying on len(results) for a
"total" count (e.g. Validated Tests) silently under-reports once there
are more matches than the page size — the 200 shown was a page cap, not
the real total.

- Adds GET /tests/count, sharing its query-building with list_tests()
  via a new _build_test_query() so the two can't drift apart.
- Adds technique_search (free-text on MITRE ID or name), attack_success,
  detection_result, and validated_from/validated_to filters, so callers
  aren't limited to state/technique_id/platform/creator.
This commit is contained in:
kitos
2026-07-09 12:45:05 +02:00
parent adae7e617e
commit 226869d308
3 changed files with 259 additions and 38 deletions
+57 -21
View File
@@ -118,6 +118,11 @@ from app.services.test_crud_service import (
list_tests as crud_list_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
count_tests as crud_count_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
update_test as crud_update_test,
@@ -203,13 +208,14 @@ def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
# ---------------------------------------------------------------------------
@router.get("", response_model=list[TestOut])
# Define function list_tests
def list_tests(
def _test_list_filter_params(
# Entry: state
state: Optional[str] = Query(None, description="Filter by test state"),
# Entry: technique_id
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
technique_search: Optional[str] = Query(
None, description="Free-text filter on technique MITRE ID or name"
),
# Entry: platform
platform: Optional[str] = Query(None, description="Filter by platform"),
# Entry: created_by
@@ -225,6 +231,52 @@ def list_tests(
not_in_any_campaign: bool = Query(
False, description="Only return tests not linked to any campaign"
),
attack_success: Optional[str] = Query(None, description="Filter by attack success outcome"),
detection_result: Optional[str] = Query(None, description="Filter by detection outcome"),
validated_from: Optional[datetime] = Query(
None, description="Only tests validated on/after this date"
),
validated_to: Optional[datetime] = Query(
None, description="Only tests validated on/before this date"
),
) -> dict:
"""Shared filter params for GET /tests and GET /tests/count, so the two
endpoints can never silently drift out of sync with each other."""
return {
"state": state,
"technique_id": technique_id,
"technique_search": technique_search,
"platform": platform,
"created_by": created_by,
"pending_validation_side": pending_validation_side,
"reviewer_id": reviewer_id,
"not_in_any_campaign": not_in_any_campaign,
"attack_success": attack_success,
"detection_result": detection_result,
"validated_from": validated_from,
"validated_to": validated_to,
}
@router.get("/count")
def count_tests(
filters: dict = Depends(_test_list_filter_params),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Return the true total of tests matching the given filters.
GET /tests caps `limit` at 200, so pages that show "N results" need
this to report the real total rather than len(page) — which silently
under-reports whenever there are more matches than the page size.
"""
return {"total": crud_count_tests(db, **filters)}
@router.get("", response_model=list[TestOut])
# Define function list_tests
def list_tests(
filters: dict = Depends(_test_list_filter_params),
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
@@ -236,12 +288,7 @@ def list_tests(
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
Args:
state (Optional[str]): Filter by test state (e.g. ``draft``, ``validated``).
technique_id (Optional[uuid.UUID]): Filter tests belonging to a specific technique.
platform (Optional[str]): Filter by target platform (e.g. ``windows``, ``linux``).
created_by (Optional[uuid.UUID]): Filter by the UUID of the creator.
pending_validation_side (Optional[str]): Filter ``in_review`` tests pending validation
on ``'red'`` or ``'blue'`` side.
filters (dict): Shared filter params — see ``_test_list_filter_params``.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
@@ -253,18 +300,7 @@ def list_tests(
# Return crud_list_tests(
return crud_list_tests(
db,
# Keyword argument: state
state=state,
# Keyword argument: technique_id
technique_id=technique_id,
# Keyword argument: platform
platform=platform,
# Keyword argument: created_by
created_by=created_by,
# Keyword argument: pending_validation_side
pending_validation_side=pending_validation_side,
reviewer_id=reviewer_id,
not_in_any_campaign=not_in_any_campaign,
**filters,
offset=offset,
# Keyword argument: limit
limit=limit,