fix(scores,reports): fix scores/history 500, disable unfinished report generation cleanly
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- /scores/history was annotated -> dict but the service actually returns a list, so FastAPI's response validation raised an unhandled exception, surfacing as a raw 500 on every call. Fixed the annotation. - The professional_reports.py endpoints (PDF/DOCX/HTML generation) back a Reports feature the frontend already hides entirely (unfinished). Hitting them directly fell through to whatever the render pipeline raised (e.g. missing weasyprint system deps) as an unhelpful 500. Added a REPORTS_ENABLED setting (off by default) so they now return a clean 503 instead, without removing the routes or their test coverage.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("<html><body>ok</body></html>")
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user