diff --git a/backend/app/config.py b/backend/app/config.py index 4561504..38962a1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -107,6 +107,11 @@ class Settings(BaseSettings): STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale # ── Reporting ───────────────────────────────────────────────────── + # PDF/DOCX/HTML report generation (professional_reports router) is + # unfinished — the frontend hides the Reports UI entirely. Defaults + # off so a fresh deploy returns a clean 503 instead of a raw 500 from + # a half-working render pipeline (e.g. missing weasyprint system deps). + REPORTS_ENABLED: bool = False REPORT_TEMPLATES_DIR: str = "app/templates/reports" # Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports" REPORT_OUTPUT_DIR: str = "/app/reports" diff --git a/backend/app/routers/professional_reports.py b/backend/app/routers/professional_reports.py index c15e6d1..98bd810 100644 --- a/backend/app/routers/professional_reports.py +++ b/backend/app/routers/professional_reports.py @@ -40,6 +40,18 @@ def _assert_safe_report_path(filepath: str) -> str: raise HTTPException(status_code=500, detail="Report generation path error") return filepath + +def _assert_reports_enabled() -> None: + """Raise a clean 503 while report generation is unfinished/disabled. + + The frontend hides the Reports UI entirely; without this guard, hitting + these endpoints directly falls through to whatever the underlying + render pipeline raises (e.g. missing weasyprint system deps) as a raw, + unhelpful 500. + """ + if not settings.REPORTS_ENABLED: + raise HTTPException(status_code=503, detail="Report generation is not yet available on this platform") + # Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) @@ -72,6 +84,7 @@ def generate_purple_report( user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ) -> FileResponse: """Generate a Purple Team campaign assessment report.""" + _assert_reports_enabled() # Assign filepath = report_generation_service.generate_purple_campaign_report( filepath = report_generation_service.generate_purple_campaign_report( db, str(campaign_id), output_format=format, @@ -102,6 +115,7 @@ def generate_coverage_report( user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ) -> FileResponse: """Generate an organization-wide MITRE ATT&CK coverage report.""" + _assert_reports_enabled() # Assign filepath = report_generation_service.generate_coverage_report( filepath = report_generation_service.generate_coverage_report( db, output_format=format, @@ -132,6 +146,7 @@ def generate_executive_report( user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ) -> FileResponse: """Generate an executive security summary report.""" + _assert_reports_enabled() # Assign filepath = report_generation_service.generate_executive_summary( filepath = report_generation_service.generate_executive_summary( db, output_format=format, @@ -162,6 +177,7 @@ def generate_quarterly_report( user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), ) -> FileResponse: """Generate a quarterly security summary report.""" + _assert_reports_enabled() # Assign filepath = report_generation_service.generate_quarterly_summary( filepath = report_generation_service.generate_quarterly_summary( db, output_format=format, @@ -194,6 +210,7 @@ def generate_technique_report( user: User = Depends(get_current_user), ) -> FileResponse: """Generate a detailed report for one MITRE technique.""" + _assert_reports_enabled() # Assign filepath = report_generation_service.generate_technique_detail_report( filepath = report_generation_service.generate_technique_detail_report( db, str(technique_id), output_format=format, diff --git a/backend/app/routers/scores.py b/backend/app/routers/scores.py index 1fd91ad..691e9f4 100644 --- a/backend/app/routers/scores.py +++ b/backend/app/routers/scores.py @@ -165,7 +165,7 @@ def score_history( db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), -) -> dict: +) -> list: """Get historical score data points (weekly). Args: @@ -174,7 +174,7 @@ def score_history( current_user (User): Authenticated user making the request. Returns: - dict: Weekly score data points for the requested period. + list: Weekly score data points for the requested period. """ # Return get_score_history(db, period) return get_score_history(db, period) diff --git a/backend/tests/test_professional_reports_router.py b/backend/tests/test_professional_reports_router.py index f1190b3..702dc09 100644 --- a/backend/tests/test_professional_reports_router.py +++ b/backend/tests/test_professional_reports_router.py @@ -16,7 +16,8 @@ def test_purple_campaign_pdf_download(mock_gen, client, auth_headers, db): f.write(b"%PDF-1.4 fake") mock_gen.return_value = fake_pdf - with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir): + with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \ + patch.object(settings, "REPORTS_ENABLED", True): campaign = Campaign(name="Export Camp", status="active") db.add(campaign) db.commit() @@ -38,7 +39,8 @@ def test_coverage_summary_html(mock_gen, client, auth_headers): f.write("ok") mock_gen.return_value = fake_html - with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir): + with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \ + patch.object(settings, "REPORTS_ENABLED", True): r = client.get( "/api/v1/reports/generate/coverage-summary", params={"format": "html"}, @@ -46,3 +48,15 @@ def test_coverage_summary_html(mock_gen, client, auth_headers): ) assert r.status_code == 200 assert "text/html" in r.headers["content-type"] + + +def test_reports_disabled_by_default_returns_503(client, auth_headers): + """Reports are unfinished — the frontend hides the UI entirely, and the + backend must refuse cleanly rather than let a half-working render + pipeline (e.g. missing weasyprint system deps) surface a raw 500.""" + r = client.get( + "/api/v1/reports/generate/coverage-summary", + params={"format": "pdf"}, + headers=auth_headers, + ) + assert r.status_code == 503 diff --git a/backend/tests/test_scores_history.py b/backend/tests/test_scores_history.py new file mode 100644 index 0000000..471ff54 --- /dev/null +++ b/backend/tests/test_scores_history.py @@ -0,0 +1,19 @@ +"""GET /scores/history returns a list of weekly data points — regression +test for a response-model mismatch (endpoint annotated -> dict while the +service actually returns a list, causing a raw 500 on serialization).""" + + +def test_scores_history_returns_a_list(client, auth_headers): + resp = client.get("/api/v1/scores/history", headers=auth_headers) + assert resp.status_code == 200, resp.text + body = resp.json() + assert isinstance(body, list) + + +def test_scores_history_accepts_all_periods(client, auth_headers): + for period in ("30d", "90d", "1y"): + resp = client.get( + "/api/v1/scores/history", params={"period": period}, headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + assert isinstance(resp.json(), list)