# mypy: disable-error-code="arg-type" """ FastAPI routes for Evidence management. Endpoints: - /evidence: Evidence listing and creation - /evidence/upload: Evidence file upload - /evidence/collect: CI/CD evidence collection - /evidence/ci-status: CI/CD evidence status Phase 1 Step 4 refactor: handlers delegate to EvidenceService. Pure helpers (`_parse_ci_evidence`, `_extract_findings_detail`) and the SOURCE_CONTROL_MAP constant are re-exported from this module so the existing tests (tests/test_evidence_routes.py) continue to import them from the legacy path. """ import logging from typing import Any, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy.orm import Session from classroom_engine.database import get_db from compliance.api._http_errors import translate_domain_errors from compliance.db import ControlRepository, EvidenceRepository from compliance.schemas.evidence import ( EvidenceCreate, EvidenceListResponse, EvidenceResponse, ) from compliance.services.auto_risk_updater import AutoRiskUpdater from compliance.domain import NotFoundError, ValidationError from compliance.services.evidence_service import ( SOURCE_CONTROL_MAP, EvidenceService, _extract_findings_detail, # re-exported for legacy test imports _parse_ci_evidence, # re-exported for legacy test imports _store_evidence, # re-exported for legacy test imports _update_risks as _update_risks_impl, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["compliance-evidence"]) def get_evidence_service(db: Session = Depends(get_db)) -> EvidenceService: # Read repo + auto-updater classes from this module's namespace at call # time so test patches against compliance.api.evidence_routes.* propagate. return EvidenceService( db, evidence_repo_cls=EvidenceRepository, control_repo_cls=ControlRepository, auto_updater_cls=AutoRiskUpdater, ) # ============================================================================ # Evidence CRUD # ============================================================================ @router.get("/evidence", response_model=EvidenceListResponse) async def list_evidence( control_id: Optional[str] = None, evidence_type: Optional[str] = None, status: Optional[str] = None, page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"), limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"), service: EvidenceService = Depends(get_evidence_service), ) -> EvidenceListResponse: """List evidence with optional filters and pagination.""" with translate_domain_errors(): return service.list_evidence(control_id, evidence_type, status, page, limit) @router.post("/evidence", response_model=EvidenceResponse) async def create_evidence( evidence_data: EvidenceCreate, service: EvidenceService = Depends(get_evidence_service), ) -> EvidenceResponse: """Create new evidence record.""" with translate_domain_errors(): return service.create_evidence(evidence_data) @router.delete("/evidence/{evidence_id}") async def delete_evidence( evidence_id: str, service: EvidenceService = Depends(get_evidence_service), ) -> dict[str, Any]: """Delete an evidence record.""" with translate_domain_errors(): return service.delete_evidence(evidence_id) # ============================================================================ # Upload # ============================================================================ @router.post("/evidence/upload") async def upload_evidence( control_id: str = Query(...), evidence_type: str = Query(...), title: str = Query(...), file: UploadFile = File(...), description: Optional[str] = Query(None), service: EvidenceService = Depends(get_evidence_service), ) -> EvidenceResponse: """Upload evidence file.""" with translate_domain_errors(): return await service.upload_evidence( control_id, evidence_type, title, file, description ) # ============================================================================ # CI/CD Evidence Collection # ============================================================================ def _update_risks( db: Session, *, source: str, control_id: str, ci_job_id: Optional[str], report_data: Optional[dict[str, Any]], ) -> Any: """Thin wrapper so test patches against this module's _update_risks take effect.""" return _update_risks_impl( db, source=source, control_id=control_id, ci_job_id=ci_job_id, report_data=report_data, auto_updater_cls=AutoRiskUpdater, ) @router.post("/evidence/collect") async def collect_ci_evidence( source: str = Query( ..., description="Evidence source: sast, dependency_scan, sbom, container_scan, test_results", ), ci_job_id: Optional[str] = Query(None, description="CI/CD Job ID for traceability"), ci_job_url: Optional[str] = Query(None, description="URL to CI/CD job"), report_data: Optional[dict[str, Any]] = None, db: Session = Depends(get_db), ) -> dict[str, Any]: """ Collect evidence from CI/CD pipeline. Handler stays inline so tests can patch ``compliance.api.evidence_routes._store_evidence`` / ``compliance.api.evidence_routes._update_risks`` directly. """ if source not in SOURCE_CONTROL_MAP: raise HTTPException( status_code=400, detail=f"Unknown source '{source}'. Supported: {list(SOURCE_CONTROL_MAP.keys())}", ) control_id = SOURCE_CONTROL_MAP[source] ctrl_repo = ControlRepository(db) control = ctrl_repo.get_by_control_id(control_id) if not control: raise HTTPException( status_code=404, detail=f"Control {control_id} not found. Please seed the database first.", ) parsed = _parse_ci_evidence(report_data or {}) evidence = _store_evidence( db, control_db_id=control.id, source=source, parsed=parsed, ci_job_id=ci_job_id, ci_job_url=ci_job_url, report_data=report_data, ) risk_update_result = _update_risks( db, source=source, control_id=control_id, ci_job_id=ci_job_id, report_data=report_data, ) return { "success": True, "evidence_id": evidence.id, "control_id": control_id, "source": source, "status": parsed["evidence_status"], "findings_count": parsed["findings_count"], "critical_findings": parsed["critical_findings"], "artifact_path": evidence.artifact_path, "message": f"Evidence collected successfully for control {control_id}", "auto_risk_update": ( { "enabled": True, "control_updated": risk_update_result.control_updated, "old_status": risk_update_result.old_status, "new_status": risk_update_result.new_status, "risks_affected": risk_update_result.risks_affected, "alerts_generated": risk_update_result.alerts_generated, } if risk_update_result else {"enabled": False, "error": "Auto-update skipped"} ), } @router.get("/evidence/ci-status") async def get_ci_evidence_status( control_id: Optional[str] = Query(None, description="Filter by control ID"), days: int = Query(30, description="Look back N days"), service: EvidenceService = Depends(get_evidence_service), ) -> dict[str, Any]: """Get CI/CD evidence collection status overview.""" with translate_domain_errors(): return service.ci_status(control_id, days) # ---------------------------------------------------------------------------- # Legacy re-exports for tests that import helpers directly. # ---------------------------------------------------------------------------- __all__ = [ "router", "SOURCE_CONTROL_MAP", "EvidenceRepository", "ControlRepository", "AutoRiskUpdater", "_parse_ci_evidence", "_extract_findings_detail", "_store_evidence", "_update_risks", ]