From a918e002bed34523d1bc3ace087149a5f00c1e07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:12:47 +0000 Subject: [PATCH 1/2] Initial plan From af122881a512d654701715683a75b57413431952 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:18:17 +0000 Subject: [PATCH 2/2] Fix /reports/{report_id} returning 404 - add report detail page and API endpoint Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/4a412936-3bf7-4c02-b121-c0c342c34f1b Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- backend/app/api/api_v1/endpoints/reports.py | 111 +++++++- backend/app/main.py | 8 + backend/app/services/report_store.py | 16 ++ backend/app/templates/report_detail.html | 276 ++++++++++++++++++++ backend/app/tests/test_reports_api.py | 41 +++ 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 backend/app/templates/report_detail.html diff --git a/backend/app/api/api_v1/endpoints/reports.py b/backend/app/api/api_v1/endpoints/reports.py index 521a41a..2e4618a 100644 --- a/backend/app/api/api_v1/endpoints/reports.py +++ b/backend/app/api/api_v1/endpoints/reports.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import Any, Dict, List, Optional from fastapi import APIRouter, File, HTTPException, UploadFile, status from pydantic import BaseModel @@ -374,3 +374,112 @@ async def delete_report(domain: str, report_id: str): success=True, message=f"Report '{report_id}' for domain '{domain}' deleted successfully.", ) + + +class ReportRecordDetail(BaseModel): + """Detailed record from a DMARC report""" + + source_ip: str + count: int + disposition: str + dkim_result: str + spf_result: str + header_from: str + spf: Optional[List[Dict[str, Any]]] = None + dkim: Optional[List[Dict[str, Any]]] = None + + +class ReportPolicyDetail(BaseModel): + """Published policy from a DMARC report""" + + p: str + sp: str = "" + pct: str = "100" + + +class ReportSummaryDetail(BaseModel): + """Summary statistics for a DMARC report""" + + total_count: int + passed_count: int + failed_count: int + pass_rate: float + + +class ReportDetail(BaseModel): + """Full detail of a single DMARC report""" + + report_id: str + org_name: str + email: str + domain: str + begin_date: str + end_date: str + begin_timestamp: int + end_timestamp: int + policy: ReportPolicyDetail + records: List[ReportRecordDetail] + summary: ReportSummaryDetail + + +@router.get("/{report_id}", response_model=ReportDetail) +async def get_report_by_id(report_id: str): + """ + Get full details for a single DMARC report by its report ID. + """ + store = ReportStore.get_instance() + report = store.get_report_by_id(report_id) + + if report is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Report '{report_id}' not found.", + ) + + # Normalize the policy field + policy_val = report.get("policy", {}) + if isinstance(policy_val, str): + policy_val = {"p": policy_val, "sp": "", "pct": "100"} + policy_detail = ReportPolicyDetail( + p=policy_val.get("p", "none"), + sp=policy_val.get("sp", ""), + pct=str(policy_val.get("pct", "100")), + ) + + # Normalize records + record_details = [] + for rec in report.get("records", []): + record_details.append( + ReportRecordDetail( + source_ip=rec.get("source_ip", ""), + count=rec.get("count", 0), + disposition=rec.get("disposition", "none"), + dkim_result=rec.get("dkim_result", ""), + spf_result=rec.get("spf_result", ""), + header_from=rec.get("header_from", ""), + spf=rec.get("spf") if isinstance(rec.get("spf"), list) else None, + dkim=rec.get("dkim") if isinstance(rec.get("dkim"), list) else None, + ) + ) + + raw_summary = report.get("summary", {}) + summary_detail = ReportSummaryDetail( + total_count=raw_summary.get("total_count", 0), + passed_count=raw_summary.get("passed_count", 0), + failed_count=raw_summary.get("failed_count", 0), + pass_rate=raw_summary.get("pass_rate", 0.0), + ) + + return ReportDetail( + report_id=report.get("report_id", ""), + org_name=report.get("org_name", ""), + email=report.get("email", ""), + domain=report.get("domain", ""), + begin_date=str(report.get("begin_date", "")), + end_date=str(report.get("end_date", "")), + begin_timestamp=report.get("begin_timestamp", 0), + end_timestamp=report.get("end_timestamp", 0), + policy=policy_detail, + records=record_details, + summary=summary_detail, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 3d54b8d..19110dc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -342,6 +342,14 @@ async def reports(request: Request): return templates.TemplateResponse(request, "reports.html") +@app.get("/reports/{report_id}", response_class=HTMLResponse) +async def report_detail(request: Request, report_id: str): + """View detailed information for a specific DMARC report""" + return templates.TemplateResponse( + request, "report_detail.html", {"report_id": report_id} + ) + + @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): return templates.TemplateResponse(request, "settings.html") diff --git a/backend/app/services/report_store.py b/backend/app/services/report_store.py index e0dee1d..8c78974 100644 --- a/backend/app/services/report_store.py +++ b/backend/app/services/report_store.py @@ -147,6 +147,22 @@ class ReportStore: """ return self.domain_summary + def get_report_by_id(self, report_id: str) -> Optional[Dict[str, Any]]: + """ + Find a report by its report_id across all domains. + + Args: + report_id: Report identifier from the DMARC report metadata + + Returns: + The report dictionary if found, None otherwise + """ + for reports in self.domain_reports.values(): + for report in reports: + if report.get("report_id") == report_id: + return report + return None + def get_domain_reports(self, domain: str, limit: Optional[int] = None) -> List[Dict[str, Any]]: """ Get all reports for a domain diff --git a/backend/app/templates/report_detail.html b/backend/app/templates/report_detail.html new file mode 100644 index 0000000..c623f6a --- /dev/null +++ b/backend/app/templates/report_detail.html @@ -0,0 +1,276 @@ +{% extends "layouts/base.html" %} +{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %} +{% from "components/ui/button.html" import button, button_link %} +{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %} + +{% block title %}DMARQ - Report Detail{% endblock %} + +{% block content %} +
+ + + + + + + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/backend/app/tests/test_reports_api.py b/backend/app/tests/test_reports_api.py index 195cdba..458fa2a 100644 --- a/backend/app/tests/test_reports_api.py +++ b/backend/app/tests/test_reports_api.py @@ -132,3 +132,44 @@ def test_upload_after_delete_succeeds(client: TestClient): ) assert response.status_code == 200 assert response.json()["success"] is True + + +def test_get_report_by_id_returns_detail(client: TestClient): + """GET /api/v1/reports/{report_id} returns full report detail after upload.""" + zip_bytes = _make_zip(SAMPLE_XML) + client.post( + "/api/v1/reports/upload", + files={"file": ("report.zip", zip_bytes, "application/zip")}, + ) + + response = client.get("/api/v1/reports/123456789") + assert response.status_code == 200 + data = response.json() + assert data["report_id"] == "123456789" + assert data["domain"] == "example.com" + assert data["org_name"] == "google.com" + assert "policy" in data + assert "records" in data + assert "summary" in data + assert data["summary"]["total_count"] == 2 + + +def test_get_report_by_id_not_found(client: TestClient): + """GET /api/v1/reports/{report_id} returns 404 when report does not exist.""" + response = client.get("/api/v1/reports/no-such-report-id") + assert response.status_code == 404 + + +def test_report_detail_html_page(): + """GET /reports/{report_id} returns 200 HTML page. + + The /reports/{report_id} route is registered on the module-level ``app`` + instance in main.py, not on the ``create_app()`` instance used by the + ``client`` fixture, so we must import the module-level app here. + """ + from app.main import app as main_app # noqa: PLC0415 + + with TestClient(main_app) as c: + response = c.get("/reports/123456789") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"]