"""T-127: Tests de métricas actualizadas. Tests for the V2 metrics endpoints (pipeline, team-activity, validation-rate) and for the technique status recalculation logic with the new test states. """ import sys import os import uuid import inspect from unittest.mock import MagicMock, patch, PropertyMock 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" REPORT_TEMPLATES_DIR = "app/templates/reports" REPORT_OUTPUT_DIR = "/tmp/aegis_reports" COMPANY_NAME = "Test Org" COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png" JIRA_ENABLED = False JIRA_URL = "" JIRA_USERNAME = "" JIRA_API_TOKEN = "" JIRA_IS_CLOUD = True JIRA_DEFAULT_PROJECT = "" JIRA_ISSUE_TYPE_TEST = "Task" JIRA_ISSUE_TYPE_CAMPAIGN = "Epic" TEMPO_ENABLED = False TEMPO_API_TOKEN = "" TEMPO_DEFAULT_WORK_TYPE = "Red Team" NVD_API_KEY = "" STALE_THRESHOLD_DAYS = 365 CORS_ORIGINS = "http://localhost:3000" SCORING_WEIGHT_TESTS = 40 SCORING_WEIGHT_DETECTION_RULES = 20 SCORING_WEIGHT_D3FEND = 15 SCORING_WEIGHT_FRESHNESS = 15 SCORING_WEIGHT_PLATFORM_DIVERSITY = 10 _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.models.enums import TestState, TestResult, TechniqueStatus from app.services.status_service import recalculate_technique_status from app.routers.metrics import router as metrics_router def _get_route_paths(): routes = {} for route in metrics_router.routes: path = getattr(route, "path", "") methods = getattr(route, "methods", set()) for method in methods: routes[f"{method} {path}"] = route return routes # --------------------------------------------------------------------------- # Helpers for technique status recalculation tests # --------------------------------------------------------------------------- def _make_test(state: TestState, detection_result=None) -> MagicMock: t = MagicMock() t.id = uuid.uuid4() t.state = state t.detection_result = detection_result t.red_validation_status = None t.blue_validation_status = None return t def _make_technique(tests=None) -> MagicMock: tech = MagicMock() tech.id = uuid.uuid4() tech.tests = tests or [] tech.status_global = TechniqueStatus.not_evaluated return tech def _make_db() -> MagicMock: return MagicMock() # =========================================================================== # 1. test_pipeline_metrics — endpoint exists and queries TestState # =========================================================================== def test_pipeline_metrics_endpoint_exists(): """GET /metrics/test-pipeline endpoint exists.""" routes = _get_route_paths() found = any("test-pipeline" in k and "GET" in k for k in routes) assert found, f"GET /metrics/test-pipeline not found. Routes: {list(routes.keys())}" def test_pipeline_metrics_queries_all_states(): """Pipeline endpoint groups by all test states.""" from app.services.metrics_query_service import get_test_pipeline_counts source = inspect.getsource(get_test_pipeline_counts) assert "Test.state" in source, "Must query Test.state" assert "group_by" in source, "Must group by state" assert "TestPipelineCounts" in source, "Must return TestPipelineCounts schema" # =========================================================================== # 2. test_team_activity_metrics — endpoint exists and calculates correctly # =========================================================================== def test_team_activity_endpoint_exists(): """GET /metrics/team-activity endpoint exists.""" routes = _get_route_paths() found = any("team-activity" in k and "GET" in k for k in routes) assert found, f"GET /metrics/team-activity not found. Routes: {list(routes.keys())}" def test_team_activity_calculates_both_teams(): """Team activity endpoint returns data for both Red and Blue teams.""" from app.services.metrics_query_service import get_team_activity source = inspect.getsource(get_team_activity) assert "Red Team" in source or "red" in source.lower(), "Must include Red Team data" assert "Blue Team" in source or "blue" in source.lower(), "Must include Blue Team data" assert "tests_completed" in source, "Must calculate completed tests" assert "tests_pending" in source, "Must calculate pending tests" def test_team_activity_red_pending_states(): """Red Team pending includes draft and red_executing.""" from app.services.metrics_query_service import get_team_activity source = inspect.getsource(get_team_activity) assert "draft" in source, "Red pending must include draft" assert "red_executing" in source, "Red pending must include red_executing" def test_team_activity_blue_pending_states(): """Blue Team pending includes blue_evaluating.""" from app.services.metrics_query_service import get_team_activity source = inspect.getsource(get_team_activity) assert "blue_evaluating" in source, "Blue pending must include blue_evaluating" # =========================================================================== # 3. test_technique_status_recalculation_with_new_states # =========================================================================== def test_technique_no_tests_is_not_evaluated(): """Technique with no tests -> not_evaluated.""" tech = _make_technique(tests=[]) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.not_evaluated def test_technique_all_validated_detected(): """All tests validated with detected -> technique validated.""" tests = [ _make_test(TestState.validated, detection_result="detected"), _make_test(TestState.validated, detection_result="detected"), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.validated def test_technique_all_validated_partially_detected(): """All tests validated with partially_detected -> technique partial.""" tests = [ _make_test(TestState.validated, detection_result="detected"), _make_test(TestState.validated, detection_result="partially_detected"), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.partial def test_technique_all_validated_not_detected(): """All tests validated with not_detected -> technique not_covered.""" tests = [ _make_test(TestState.validated, detection_result="not_detected"), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.not_covered def test_technique_mixed_validated_and_in_progress(): """Some validated, some still in pipeline -> technique partial.""" tests = [ _make_test(TestState.validated, detection_result="detected"), _make_test(TestState.red_executing), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.partial def test_technique_all_in_progress(): """All tests in intermediate states (no validated) -> technique in_progress.""" tests = [ _make_test(TestState.draft), _make_test(TestState.red_executing), _make_test(TestState.blue_evaluating), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.in_progress def test_technique_with_in_review_tests(): """Tests in in_review are still in-progress (not yet validated).""" tests = [ _make_test(TestState.in_review), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.in_progress def test_technique_with_rejected_tests(): """Rejected tests count as in-progress (need rework).""" tests = [ _make_test(TestState.rejected), ] tech = _make_technique(tests=tests) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.in_progress # =========================================================================== # 4. test_coverage_with_dual_validation # =========================================================================== def test_coverage_correct_after_dual_validation(): """After dual validation (both approved), technique status is correct.""" # A test that completed the full pipeline with detection test = _make_test(TestState.validated, detection_result="detected") test.red_validation_status = "approved" test.blue_validation_status = "approved" tech = _make_technique(tests=[test]) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.validated def test_coverage_partial_when_one_detected_one_partial(): """Mixed detection results after dual validation -> partial coverage.""" test1 = _make_test(TestState.validated, detection_result="detected") test1.red_validation_status = "approved" test1.blue_validation_status = "approved" test2 = _make_test(TestState.validated, detection_result="partially_detected") test2.red_validation_status = "approved" test2.blue_validation_status = "approved" tech = _make_technique(tests=[test1, test2]) db = _make_db() recalculate_technique_status(db, tech) assert tech.status_global == TechniqueStatus.partial # =========================================================================== # 5. test_validation_rate_endpoint — approval/rejection rates # =========================================================================== def test_validation_rate_endpoint_exists(): """GET /metrics/validation-rate endpoint exists.""" routes = _get_route_paths() found = any("validation-rate" in k and "GET" in k for k in routes) assert found, f"GET /metrics/validation-rate not found. Routes: {list(routes.keys())}" def test_validation_rate_queries_both_roles(): """Validation rate endpoint returns data for both red_lead and blue_lead.""" from app.services.metrics_query_service import get_validation_rate source = inspect.getsource(get_validation_rate) assert "red_validation_status" in source, "Must query red_validation_status" assert "blue_validation_status" in source, "Must query blue_validation_status" assert "approved" in source, "Must count approved validations" assert "rejected" in source, "Must count rejected validations" assert "approval_rate" in source, "Must calculate approval_rate" # =========================================================================== # 6. test_recent_tests_endpoint — latest 10 tests # =========================================================================== def test_recent_tests_endpoint_exists(): """GET /metrics/recent-tests endpoint exists.""" routes = _get_route_paths() found = any("recent-tests" in k and "GET" in k for k in routes) assert found, f"GET /metrics/recent-tests not found. Routes: {list(routes.keys())}" def test_recent_tests_limits_to_10(): """Recent tests endpoint limits to 10 results.""" from app.services.metrics_query_service import get_recent_tests source = inspect.getsource(get_recent_tests) assert ".limit(" in source, "Must limit query results" assert "limit" in source and ("10" in source or "limit" in source), \ "Must have limit param or default 10" assert "created_at" in source, "Must order by created_at" # =========================================================================== # 7. test_original_endpoints_still_work # =========================================================================== def test_summary_endpoint_exists(): """GET /metrics/summary (original) endpoint still exists.""" routes = _get_route_paths() found = any("summary" in k and "GET" in k for k in routes) assert found, f"GET /metrics/summary not found. Routes: {list(routes.keys())}" def test_by_tactic_endpoint_exists(): """GET /metrics/by-tactic (original) endpoint still exists.""" routes = _get_route_paths() found = any("by-tactic" in k and "GET" in k for k in routes) assert found, f"GET /metrics/by-tactic not found. Routes: {list(routes.keys())}" # --------------------------------------------------------------------------- # Run all # --------------------------------------------------------------------------- if __name__ == "__main__": print("T-127 Validation: Metrics V2 Tests") print("=" * 55) test_pipeline_metrics_endpoint_exists() test_pipeline_metrics_queries_all_states() test_team_activity_endpoint_exists() test_team_activity_calculates_both_teams() test_team_activity_red_pending_states() test_team_activity_blue_pending_states() test_technique_no_tests_is_not_evaluated() test_technique_all_validated_detected() test_technique_all_validated_partially_detected() test_technique_all_validated_not_detected() test_technique_mixed_validated_and_in_progress() test_technique_all_in_progress() test_technique_with_in_review_tests() test_technique_with_rejected_tests() test_coverage_correct_after_dual_validation() test_coverage_partial_when_one_detected_one_partial() test_validation_rate_endpoint_exists() test_validation_rate_queries_both_roles() test_recent_tests_endpoint_exists() test_recent_tests_limits_to_10() test_summary_endpoint_exists() test_by_tactic_endpoint_exists() print("=" * 55) print("ALL T-127 validations PASSED!")