test(phase-17): add automated tests for Red/Blue workflow, templates CRUD, and V2 metrics (T-125, T-126, T-127)
This commit is contained in:
285
backend/tests/test_templates_crud.py
Normal file
285
backend/tests/test_templates_crud.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""T-126: Tests de TestTemplates — CRUD, filters, instantiation, permissions.
|
||||
|
||||
Tests the template CRUD endpoints, filter logic, template instantiation,
|
||||
soft-delete behaviour, and admin-only access control.
|
||||
Uses mock objects and router inspection to avoid needing a database.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
import inspect
|
||||
from unittest.mock import MagicMock
|
||||
from types import ModuleType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy dependencies before importing app modules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if backend_dir not in sys.path:
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
if "pydantic_settings" not in sys.modules:
|
||||
_ps = ModuleType("pydantic_settings")
|
||||
class _BaseSettings:
|
||||
def __init__(self, **kwargs): pass
|
||||
def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs)
|
||||
_ps.BaseSettings = _BaseSettings
|
||||
sys.modules["pydantic_settings"] = _ps
|
||||
|
||||
if "app.config" not in sys.modules:
|
||||
_cfg = ModuleType("app.config")
|
||||
class _FakeSettings:
|
||||
DATABASE_URL = "sqlite:///:memory:"
|
||||
SECRET_KEY = "test"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||
MINIO_ENDPOINT = "localhost:9000"
|
||||
MINIO_ACCESS_KEY = "test"
|
||||
MINIO_SECRET_KEY = "test"
|
||||
MINIO_BUCKET = "test"
|
||||
_cfg.settings = _FakeSettings()
|
||||
sys.modules["app.config"] = _cfg
|
||||
|
||||
if "app.database" not in sys.modules:
|
||||
_db = ModuleType("app.database")
|
||||
_db.Base = type("Base", (), {"metadata": MagicMock()})
|
||||
_db.get_db = MagicMock()
|
||||
sys.modules["app.database"] = _db
|
||||
|
||||
for _mod in [
|
||||
"taxii2client", "taxii2client.v20",
|
||||
"jose", "boto3", "botocore", "botocore.exceptions",
|
||||
"apscheduler", "apscheduler.schedulers",
|
||||
"apscheduler.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
]:
|
||||
if _mod not in sys.modules:
|
||||
m = ModuleType(_mod)
|
||||
if _mod == "taxii2client.v20": m.Server = MagicMock
|
||||
elif _mod == "jose": m.JWTError = Exception; m.jwt = MagicMock()
|
||||
elif _mod == "boto3": m.client = MagicMock()
|
||||
elif _mod == "botocore.exceptions": m.ClientError = Exception
|
||||
elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||
elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||
sys.modules[_mod] = m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.routers.test_templates import (
|
||||
router,
|
||||
list_templates,
|
||||
templates_by_technique,
|
||||
create_template,
|
||||
delete_template,
|
||||
toggle_template_active,
|
||||
template_stats,
|
||||
)
|
||||
from app.routers.tests import create_test_from_template
|
||||
from app.schemas.test_template import TestTemplateCreate
|
||||
|
||||
|
||||
def _get_route_paths():
|
||||
routes = {}
|
||||
for route in router.routes:
|
||||
path = getattr(route, "path", "")
|
||||
methods = getattr(route, "methods", set())
|
||||
for method in methods:
|
||||
routes[f"{method} {path}"] = route
|
||||
return routes
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. test_create_template — admin can create a template
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_create_template():
|
||||
"""Admin can create a template — endpoint exists and requires admin role."""
|
||||
routes = _get_route_paths()
|
||||
found = any("POST" in k and "{template_id}" not in k for k in routes)
|
||||
assert found, f"POST /test-templates not found. Routes: {list(routes.keys())}"
|
||||
|
||||
# Verify admin role is required
|
||||
source = inspect.getsource(create_template)
|
||||
assert "require_role" in source and "admin" in source, \
|
||||
"create_template must require admin role"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. test_list_templates_with_filters — source, platform, severity work
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_list_templates_with_filters():
|
||||
"""Filters of source, platform, severity, search all work."""
|
||||
source = inspect.getsource(list_templates)
|
||||
|
||||
# Verify all filter parameters exist in the function signature
|
||||
assert "source" in source, "List must accept source filter"
|
||||
assert "platform" in source, "List must accept platform filter"
|
||||
assert "severity" in source, "List must accept severity filter"
|
||||
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"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. test_get_templates_by_technique — filter by MITRE technique
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_get_templates_by_technique():
|
||||
"""Endpoint to get templates by technique exists and filters correctly."""
|
||||
routes = _get_route_paths()
|
||||
found = any("by-technique" in k and "GET" in k for k in routes)
|
||||
assert found, f"GET /test-templates/by-technique/{{mitre_id}} not found. Routes: {list(routes.keys())}"
|
||||
|
||||
source = inspect.getsource(templates_by_technique)
|
||||
assert "mitre_technique_id" in source, "Must filter by mitre_technique_id"
|
||||
assert "is_active" in source, "Must filter only active templates"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. test_instantiate_template — create test from template pre-fills fields
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_instantiate_template():
|
||||
"""POST /tests/from-template creates a test pre-filled from template data."""
|
||||
source = inspect.getsource(create_test_from_template)
|
||||
|
||||
# Verify it reads from template and copies fields
|
||||
assert "template" in source, "Must reference template"
|
||||
assert "template.name" in source, "Must copy name from template"
|
||||
assert "template.description" in source, "Must copy description from template"
|
||||
assert "template.platform" in source, "Must copy platform from template"
|
||||
assert "template.attack_procedure" in source or "attack_procedure" in source, \
|
||||
"Must copy attack_procedure from template"
|
||||
|
||||
# Verify state is set to draft
|
||||
assert "draft" in source, "New test from template must be in draft state"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. test_soft_delete_template — deactivation doesn't physically remove
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_soft_delete_template():
|
||||
"""DELETE endpoint sets is_active=False instead of removing the record."""
|
||||
source = inspect.getsource(delete_template)
|
||||
|
||||
assert "is_active" in source, "Must set is_active"
|
||||
assert "False" in source, "Must set is_active to False"
|
||||
# Should NOT call db.delete(template)
|
||||
assert "db.delete" not in source, "Should NOT physically delete the template"
|
||||
assert "deactivated" in source.lower() or "soft" in source.lower() or "detail" in source.lower(), \
|
||||
"Should return a deactivation message"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. test_non_admin_cannot_create_template — only admin role
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_non_admin_cannot_create_template():
|
||||
"""Only admin can create templates — enforce via require_role."""
|
||||
source = inspect.getsource(create_template)
|
||||
assert 'require_role("admin")' in source, \
|
||||
"create_template must use require_role('admin')"
|
||||
|
||||
# Also check update and delete
|
||||
from app.routers.test_templates import update_template
|
||||
source_update = inspect.getsource(update_template)
|
||||
assert 'require_role("admin")' in source_update, \
|
||||
"update_template must use require_role('admin')"
|
||||
|
||||
source_delete = inspect.getsource(delete_template)
|
||||
assert 'require_role("admin")' in source_delete, \
|
||||
"delete_template must use require_role('admin')"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. test_toggle_active_endpoint — toggle between active/inactive
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_toggle_active_endpoint():
|
||||
"""PATCH /test-templates/{id}/toggle-active exists and toggles is_active."""
|
||||
routes = _get_route_paths()
|
||||
found = any("toggle-active" in k and "PATCH" in k for k in routes)
|
||||
assert found, f"PATCH /test-templates/{{id}}/toggle-active not found. Routes: {list(routes.keys())}"
|
||||
|
||||
source = inspect.getsource(toggle_template_active)
|
||||
assert "is_active" in source, "Must reference is_active"
|
||||
assert "not" in source, "Must toggle (negate) the is_active value"
|
||||
assert 'require_role("admin")' in source, "Must require admin role"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. test_stats_endpoint — catalog statistics
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_stats_endpoint():
|
||||
"""GET /test-templates/stats returns catalog statistics."""
|
||||
routes = _get_route_paths()
|
||||
found = any("stats" in k and "GET" in k for k in routes)
|
||||
assert found, f"GET /test-templates/stats not found. Routes: {list(routes.keys())}"
|
||||
|
||||
source = inspect.getsource(template_stats)
|
||||
assert "by_source" in source, "Must return breakdown by source"
|
||||
assert "by_platform" in source, "Must return breakdown by platform"
|
||||
assert "active" in source, "Must return active count"
|
||||
assert 'require_role("admin")' in source, "Must require admin role"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. test_list_only_active_by_default — list filters inactive templates
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_list_only_active_by_default():
|
||||
"""The list endpoint filters to is_active=True by default."""
|
||||
source = inspect.getsource(list_templates)
|
||||
assert "is_active" in source and "True" in source, \
|
||||
"List must filter by is_active == True by default"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 10. test_pagination_support
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_pagination_support():
|
||||
"""List endpoint supports offset and limit pagination."""
|
||||
source = inspect.getsource(list_templates)
|
||||
assert "offset" in source, "Must accept offset parameter"
|
||||
assert "limit" in source, "Must accept limit parameter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("T-126 Validation: TestTemplates CRUD Tests")
|
||||
print("=" * 55)
|
||||
test_create_template()
|
||||
test_list_templates_with_filters()
|
||||
test_get_templates_by_technique()
|
||||
test_instantiate_template()
|
||||
test_soft_delete_template()
|
||||
test_non_admin_cannot_create_template()
|
||||
test_toggle_active_endpoint()
|
||||
test_stats_endpoint()
|
||||
test_list_only_active_by_default()
|
||||
test_pagination_support()
|
||||
print("=" * 55)
|
||||
print("ALL T-126 validations PASSED!")
|
||||
Reference in New Issue
Block a user