feat: add TLS reporting posture summaries
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.tls_report_parser import TLSReportParser
|
||||
|
||||
|
||||
SAMPLE_TLS_REPORT = {
|
||||
"organization-name": "Example Reporter",
|
||||
"date-range": {
|
||||
"start-datetime": "2026-05-20T00:00:00Z",
|
||||
"end-datetime": "2026-05-20T23:59:59Z",
|
||||
},
|
||||
"contact-info": "tlsrpt@example-reporter.test",
|
||||
"report-id": "tls-report-20260520",
|
||||
"policies": [
|
||||
{
|
||||
"policy": {
|
||||
"policy-type": "sts",
|
||||
"policy-string": ["version: STSv1", "mode: enforce"],
|
||||
"policy-domain": "Example.com.",
|
||||
"mx-host": ["mx.example.com"],
|
||||
},
|
||||
"summary": {
|
||||
"total-successful-session-count": 125,
|
||||
"total-failure-session-count": 7,
|
||||
},
|
||||
"failure-details": [
|
||||
{
|
||||
"result-type": "certificate-expired",
|
||||
"sending-mta-ip": "203.0.113.9",
|
||||
"receiving-mx-hostname": "MX.EXAMPLE.COM",
|
||||
"failed-session-count": 7,
|
||||
"failure-reason-code": "tls",
|
||||
"additional-information": "certificate expired",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def sample_tls_report_bytes(report=None):
|
||||
return json.dumps(report or SAMPLE_TLS_REPORT).encode("utf-8")
|
||||
|
||||
|
||||
def test_parse_tls_report_json_normalizes_policy_and_failures():
|
||||
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
|
||||
|
||||
assert parsed["report_id"] == "tls-report-20260520"
|
||||
assert parsed["org_name"] == "Example Reporter"
|
||||
assert parsed["begin_date"].isoformat() == "2026-05-20T00:00:00"
|
||||
assert parsed["policies"][0]["policy_domain"] == "example.com"
|
||||
assert parsed["policies"][0]["policy_type"] == "sts"
|
||||
assert parsed["policies"][0]["total_failure_sessions"] == 7
|
||||
assert parsed["policies"][0]["failures"][0]["result_type"] == "certificate-expired"
|
||||
assert parsed["policies"][0]["failures"][0]["receiving_mx_hostname"] == "mx.example.com"
|
||||
|
||||
|
||||
def test_parse_tls_report_gzip():
|
||||
compressed = gzip.compress(sample_tls_report_bytes())
|
||||
|
||||
parsed = TLSReportParser.parse_file(compressed, "tls-report.json.gz")
|
||||
|
||||
assert parsed["report_id"] == "tls-report-20260520"
|
||||
assert parsed["policies"][0]["total_successful_sessions"] == 125
|
||||
|
||||
|
||||
def test_parse_tls_report_zip():
|
||||
archive = io.BytesIO()
|
||||
with zipfile.ZipFile(archive, "w") as zip_file:
|
||||
zip_file.writestr("nested/tls-report.json", sample_tls_report_bytes())
|
||||
|
||||
parsed = TLSReportParser.parse_file(archive.getvalue(), "tls-report.zip")
|
||||
|
||||
assert parsed["policies"][0]["policy_domain"] == "example.com"
|
||||
|
||||
|
||||
def test_parse_tls_report_generates_stable_id_when_missing():
|
||||
report = dict(SAMPLE_TLS_REPORT)
|
||||
report.pop("report-id")
|
||||
|
||||
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(report), "tls-report.json")
|
||||
|
||||
assert parsed["report_id"].startswith("tlsrpt-")
|
||||
assert len(parsed["report_id"]) == 31
|
||||
|
||||
|
||||
def test_parse_tls_report_rejects_missing_policies():
|
||||
with pytest.raises(ValueError, match="policy-domain"):
|
||||
TLSReportParser.parse_file(b'{"policies":[]}', "tls-report.json")
|
||||
|
||||
|
||||
def test_parse_tls_report_rejects_invalid_extension():
|
||||
with pytest.raises(ValueError, match="Invalid TLS report file type"):
|
||||
TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.txt")
|
||||
@@ -0,0 +1,152 @@
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.api_v1.endpoints import tls_reports as tls_endpoint
|
||||
from app.models.domain import Domain
|
||||
from app.models.report import TLSReport, TLSReportFailure
|
||||
from app.services.tls_report_parser import TLSReportParser
|
||||
from app.services.tls_report_persistence import (
|
||||
save_tls_report,
|
||||
summarize_tls_reports,
|
||||
tls_report_exists,
|
||||
)
|
||||
from app.tests.test_tls_report_parser import sample_tls_report_bytes
|
||||
|
||||
|
||||
def test_upload_tls_report_persists_policy_and_failure_details(authed_client, db_session):
|
||||
response = authed_client.post(
|
||||
"/api/v1/tls-reports/upload",
|
||||
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["policies_created"] == 1
|
||||
assert data["policies_skipped"] == 0
|
||||
assert "message bodies" in data["privacy"]["not_stored"]
|
||||
|
||||
report = db_session.query(TLSReport).one()
|
||||
assert report.policy_domain == "example.com"
|
||||
assert report.total_failure_sessions == 7
|
||||
assert db_session.query(Domain).filter(Domain.name == "example.com").count() == 1
|
||||
failure = db_session.query(TLSReportFailure).one()
|
||||
assert failure.result_type == "certificate-expired"
|
||||
assert failure.failed_session_count == 7
|
||||
|
||||
|
||||
def test_upload_tls_report_marks_duplicates_without_double_counting(authed_client, db_session):
|
||||
files = {"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")}
|
||||
assert authed_client.post("/api/v1/tls-reports/upload", files=files).status_code == 200
|
||||
|
||||
response = authed_client.post(
|
||||
"/api/v1/tls-reports/upload",
|
||||
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["duplicate"] is True
|
||||
assert data["policies_created"] == 0
|
||||
assert data["policies_skipped"] == 1
|
||||
assert db_session.query(TLSReport).count() == 1
|
||||
|
||||
|
||||
def test_tls_report_summary_groups_failures_and_domains(authed_client):
|
||||
authed_client.post(
|
||||
"/api/v1/tls-reports/upload",
|
||||
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
|
||||
)
|
||||
|
||||
response = authed_client.get("/api/v1/tls-reports/summary?domain=example.com&days=30")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["totals"]["reports"] == 1
|
||||
assert data["totals"]["failed_sessions"] == 7
|
||||
assert data["top_failures"][0]["result_type"] == "certificate-expired"
|
||||
assert data["top_failures"][0]["affected_domains"] == ["example.com"]
|
||||
assert data["affected_domains"][0]["failure_rate"] > 0
|
||||
assert "sender or recipient addresses" in data["privacy"]["not_stored"]
|
||||
|
||||
|
||||
def test_list_tls_reports_filters_by_domain(authed_client, db_session):
|
||||
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
|
||||
second = dict(parsed)
|
||||
second["report_id"] = "tls-report-second"
|
||||
second["policies"] = [dict(parsed["policies"][0], policy_domain="example.net")]
|
||||
save_tls_report(db_session, parsed)
|
||||
save_tls_report(db_session, second)
|
||||
db_session.commit()
|
||||
|
||||
response = authed_client.get("/api/v1/tls-reports?domain=example.net")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["reports"][0]["policy_domain"] == "example.net"
|
||||
|
||||
|
||||
def test_tls_report_summary_empty_response_includes_privacy_controls(authed_client):
|
||||
response = authed_client.get("/api/v1/tls-reports/summary")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["totals"]["reports"] == 0
|
||||
assert data["top_failures"] == []
|
||||
assert "raw uploaded attachments" in data["privacy"]["not_stored"]
|
||||
|
||||
|
||||
def test_tls_report_persistence_helpers(db_session):
|
||||
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
|
||||
|
||||
result = save_tls_report(db_session, parsed)
|
||||
db_session.commit()
|
||||
|
||||
assert result["created"] == 1
|
||||
assert tls_report_exists(db_session, "tls-report-20260520", "example.com")
|
||||
summary = summarize_tls_reports(db_session, domain="example.com")
|
||||
assert summary["totals"]["successful_sessions"] == 125
|
||||
|
||||
|
||||
def test_upload_tls_report_rejects_invalid_file_type(authed_client):
|
||||
response = authed_client.post(
|
||||
"/api/v1/tls-reports/upload",
|
||||
files={"file": ("tls-report.txt", sample_tls_report_bytes(), "text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_validate_upload_rejects_missing_name_and_large_file():
|
||||
missing_name = type("Upload", (), {"filename": ""})()
|
||||
too_large = type("Upload", (), {"filename": "report.json"})()
|
||||
|
||||
try:
|
||||
tls_endpoint._validate_upload(missing_name, b"content")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("Expected missing filename to be rejected")
|
||||
|
||||
try:
|
||||
tls_endpoint._validate_upload(
|
||||
too_large,
|
||||
b"x" * (tls_endpoint.MAX_TLS_REPORT_SIZE + 1),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 413
|
||||
else:
|
||||
raise AssertionError("Expected large file to be rejected")
|
||||
|
||||
|
||||
def test_tls_report_html_page_renders():
|
||||
from app.core.logto import SESSION_COOKIE, create_session_token # noqa: PLC0415
|
||||
from app.main import app as main_app # noqa: PLC0415
|
||||
|
||||
cookies = {SESSION_COOKIE: create_session_token(user_id=1)}
|
||||
with TestClient(main_app) as client:
|
||||
response = client.get("/tls-reports", cookies=cookies)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "TLS Reports" in response.text
|
||||
Reference in New Issue
Block a user