Files
breakpilot-compliance/backend-compliance/tests/test_incident_routes.py
Benjamin Admin 2dd86e97be
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
feat(incidents): Go Incidents nach Python migrieren, Proxy umleiten, 50 Tests
- incident_routes.py: 15 Endpoints (CRUD, Risk Assessment, Art. 33/34 Notifications, Measures, Timeline, Close, Stats)
- Neuer Endpoint PUT /{id}/status (nicht in Go vorhanden, Frontend braucht ihn)
- Proxy von ai-compliance-sdk:8090 auf backend-compliance:8002 umgeleitet
- Go incidents_handlers.go + main.go als DEPRECATED markiert
- 50/50 Tests bestanden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:50:00 +01:00

672 lines
23 KiB
Python

"""Tests for Incident routes (incident_routes.py) — Datenpannen-Management DSGVO Art. 33/34."""
import json
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from fastapi import FastAPI
from datetime import datetime, timedelta, timezone
from compliance.api.incident_routes import (
router,
_calculate_risk_level,
_is_notification_required,
_calculate_72h_deadline,
_incident_to_response,
_measure_to_response,
_parse_jsonb,
DEFAULT_TENANT_ID,
)
app = FastAPI()
app.include_router(router)
client = TestClient(app)
DEFAULT_TENANT = DEFAULT_TENANT_ID
INCIDENT_ID = "ffffffff-0001-0001-0001-000000000001"
MEASURE_ID = "ffffffff-0002-0002-0002-000000000002"
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
# =============================================================================
# Helpers — _DictResult / _DictSession pattern for SQLite-free DB mocks
# =============================================================================
class _DictRow:
"""Simulates a SQLAlchemy RowMapping (dict-like)."""
def __init__(self, data: dict):
self._data = data
def __getitem__(self, key):
return self._data[key]
def __contains__(self, key):
return key in self._data
def keys(self):
return self._data.keys()
def items(self):
return self._data.items()
def values(self):
return self._data.values()
class _DictResult:
"""Simulates a SQLAlchemy CursorResult."""
def __init__(self, rows=None, scalar_val=None):
self._rows = rows or []
self._scalar_val = scalar_val
def mappings(self):
return self
def first(self):
return _DictRow(self._rows[0]) if self._rows else None
def all(self):
return [_DictRow(r) for r in self._rows]
def scalar(self):
return self._scalar_val
def make_incident_row(overrides=None):
now = datetime(2024, 6, 1, 12, 0, 0)
deadline = now + timedelta(hours=72)
data = {
"id": INCIDENT_ID,
"tenant_id": DEFAULT_TENANT,
"title": "Datenpanne Test",
"description": "Test Beschreibung",
"category": "data_breach",
"status": "detected",
"severity": "medium",
"detected_at": now,
"reported_by": "admin",
"affected_data_categories": json.dumps(["personal"]),
"affected_data_subject_count": 100,
"affected_systems": json.dumps(["CRM"]),
"risk_assessment": None,
"authority_notification": json.dumps({
"status": "pending",
"deadline": deadline.isoformat(),
}),
"data_subject_notification": json.dumps({
"required": False,
"status": "not_required",
}),
"timeline": json.dumps([{
"timestamp": now.isoformat(),
"action": "incident_created",
"user_id": "admin",
"details": "Incident detected and reported",
}]),
"root_cause": None,
"lessons_learned": None,
"closed_at": None,
"created_at": now,
"updated_at": now,
}
if overrides:
data.update(overrides)
return data
def make_measure_row(overrides=None):
now = datetime(2024, 6, 1, 12, 0, 0)
data = {
"id": MEASURE_ID,
"incident_id": INCIDENT_ID,
"title": "Passwort Reset",
"description": "Alle betroffenen Passwoerter zuruecksetzen",
"measure_type": "corrective",
"status": "planned",
"responsible": "IT-Admin",
"due_date": None,
"completed_at": None,
"created_at": now,
"updated_at": now,
}
if overrides:
data.update(overrides)
return data
@pytest.fixture
def mock_db():
from classroom_engine.database import get_db
db = MagicMock()
app.dependency_overrides[get_db] = lambda: db
yield db
app.dependency_overrides.clear()
# =============================================================================
# Helper / Utility Tests
# =============================================================================
class TestCalculateRiskLevel:
def test_low(self):
assert _calculate_risk_level(1, 1) == "low"
assert _calculate_risk_level(2, 2) == "low"
def test_medium(self):
assert _calculate_risk_level(2, 3) == "medium"
assert _calculate_risk_level(3, 3) == "medium"
def test_high(self):
assert _calculate_risk_level(3, 4) == "high"
assert _calculate_risk_level(4, 4) == "high"
def test_critical(self):
assert _calculate_risk_level(4, 5) == "critical"
assert _calculate_risk_level(5, 5) == "critical"
class TestIsNotificationRequired:
def test_critical_requires(self):
assert _is_notification_required("critical") is True
def test_high_requires(self):
assert _is_notification_required("high") is True
def test_medium_not_required(self):
assert _is_notification_required("medium") is False
def test_low_not_required(self):
assert _is_notification_required("low") is False
class TestCalculate72hDeadline:
def test_returns_iso_string(self):
dt = datetime(2024, 6, 1, 12, 0, 0)
result = _calculate_72h_deadline(dt)
assert "2024-06-04T12:00:00" in result
class TestParseJsonb:
def test_dict_passthrough(self):
assert _parse_jsonb({"a": 1}) == {"a": 1}
def test_list_passthrough(self):
assert _parse_jsonb([1, 2]) == [1, 2]
def test_json_string(self):
assert _parse_jsonb('{"a": 1}') == {"a": 1}
def test_none(self):
assert _parse_jsonb(None) is None
class TestIncidentToResponse:
def test_parses_jsonb_fields(self):
row = _DictRow(make_incident_row())
result = _incident_to_response(row)
assert isinstance(result["timeline"], list)
assert isinstance(result["authority_notification"], dict)
def test_converts_datetime_to_iso(self):
row = _DictRow(make_incident_row())
result = _incident_to_response(row)
assert "2024-06-01" in result["detected_at"]
# =============================================================================
# Create Incident Tests
# =============================================================================
class TestCreateIncident:
def test_create_basic(self, mock_db):
created_row = make_incident_row()
mock_db.execute.return_value = _DictResult([created_row])
resp = client.post("/incidents", json={
"title": "Datenpanne Test",
"description": "Test",
}, headers={"x-tenant-id": DEFAULT_TENANT, "x-user-id": "admin"})
assert resp.status_code == 200
data = resp.json()
assert "incident" in data
assert "authority_deadline" in data
assert "hours_until_deadline" in data
def test_create_with_detected_at(self, mock_db):
created_row = make_incident_row()
mock_db.execute.side_effect = [
None, # INSERT
_DictResult([created_row]), # SELECT back
]
resp = client.post("/incidents", json={
"title": "Panne 2",
"detected_at": "2024-06-01T10:00:00",
})
assert resp.status_code == 200
assert "authority_deadline" in resp.json()
def test_create_missing_title(self, mock_db):
resp = client.post("/incidents", json={})
assert resp.status_code == 422
# =============================================================================
# List Incidents Tests
# =============================================================================
class TestListIncidents:
def test_list_empty(self, mock_db):
mock_db.execute.side_effect = [
_DictResult(scalar_val=0),
_DictResult([]),
]
resp = client.get("/incidents", headers={"x-tenant-id": DEFAULT_TENANT})
assert resp.status_code == 200
data = resp.json()
assert data["incidents"] == []
assert data["total"] == 0
def test_list_with_items(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult(scalar_val=1),
_DictResult([row]),
]
resp = client.get("/incidents")
assert resp.status_code == 200
data = resp.json()
assert len(data["incidents"]) == 1
assert data["total"] == 1
def test_list_filter_by_status(self, mock_db):
mock_db.execute.side_effect = [
_DictResult(scalar_val=0),
_DictResult([]),
]
resp = client.get("/incidents?status=closed")
assert resp.status_code == 200
# =============================================================================
# Get Incident Tests
# =============================================================================
class TestGetIncident:
def test_get_existing(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]), # incident
_DictResult([]), # measures
]
resp = client.get(f"/incidents/{INCIDENT_ID}")
assert resp.status_code == 200
data = resp.json()
assert "incident" in data
assert "measures" in data
assert "deadline_info" in data
def test_get_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.get(f"/incidents/{UNKNOWN_ID}")
assert resp.status_code == 404
# =============================================================================
# Update Incident Tests
# =============================================================================
class TestUpdateIncident:
def test_update_title(self, mock_db):
row = make_incident_row({"title": "Updated Title"})
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]), # check exists
None, # update
_DictResult([row]), # fetch back
]
resp = client.put(f"/incidents/{INCIDENT_ID}", json={"title": "Updated Title"})
assert resp.status_code == 200
assert resp.json()["incident"]["title"] == "Updated Title"
def test_update_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.put(f"/incidents/{UNKNOWN_ID}", json={"title": "X"})
assert resp.status_code == 404
# =============================================================================
# Delete Incident Tests
# =============================================================================
class TestDeleteIncident:
def test_delete_existing(self, mock_db):
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]), # check exists
None, # delete measures
None, # delete incident
]
resp = client.delete(f"/incidents/{INCIDENT_ID}")
assert resp.status_code == 200
assert resp.json()["message"] == "incident deleted"
def test_delete_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.delete(f"/incidents/{UNKNOWN_ID}")
assert resp.status_code == 404
# =============================================================================
# Status Update Tests (NEW endpoint)
# =============================================================================
class TestStatusUpdate:
def test_update_status(self, mock_db):
row = make_incident_row({"status": "assessment"})
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]), # check exists
None, # update status
None, # append timeline
_DictResult([row]), # fetch back
]
resp = client.put(f"/incidents/{INCIDENT_ID}/status", json={"status": "assessment"})
assert resp.status_code == 200
assert resp.json()["incident"]["status"] == "assessment"
def test_status_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.put(f"/incidents/{UNKNOWN_ID}/status", json={"status": "closed"})
assert resp.status_code == 404
# =============================================================================
# Risk Assessment Tests
# =============================================================================
class TestAssessRisk:
def test_low_risk(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]), # check exists
None, # update risk_assessment + status
None, # append timeline
]
resp = client.post(f"/incidents/{INCIDENT_ID}/assess-risk", json={
"likelihood": 1,
"impact": 2,
"notes": "Low risk",
})
assert resp.status_code == 200
data = resp.json()
assert data["risk_assessment"]["risk_level"] == "low"
assert data["notification_required"] is False
def test_high_risk_requires_notification(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]), # check exists
None, # update authority_notification
None, # update risk_assessment + status
None, # append timeline
]
resp = client.post(f"/incidents/{INCIDENT_ID}/assess-risk", json={
"likelihood": 4,
"impact": 4,
})
assert resp.status_code == 200
data = resp.json()
assert data["risk_assessment"]["risk_level"] == "high"
assert data["notification_required"] is True
assert data["incident_status"] == "notification_required"
def test_critical_risk(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]),
None, None, None,
]
resp = client.post(f"/incidents/{INCIDENT_ID}/assess-risk", json={
"likelihood": 5,
"impact": 5,
})
assert resp.status_code == 200
assert resp.json()["risk_assessment"]["risk_level"] == "critical"
def test_assess_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.post(f"/incidents/{UNKNOWN_ID}/assess-risk", json={
"likelihood": 3, "impact": 3,
})
assert resp.status_code == 404
# =============================================================================
# Authority Notification Tests (Art. 33)
# =============================================================================
class TestNotifyAuthority:
def test_notify_authority(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]), # check exists
None, # update authority_notification + status
None, # append timeline
]
resp = client.post(f"/incidents/{INCIDENT_ID}/notify-authority", json={
"authority_name": "LfD Bayern",
"reference_number": "REF-2024-001",
})
assert resp.status_code == 200
data = resp.json()
assert data["authority_notification"]["authority_name"] == "LfD Bayern"
assert "submitted_within_72h" in data
def test_notify_authority_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.post(f"/incidents/{UNKNOWN_ID}/notify-authority", json={
"authority_name": "Test",
})
assert resp.status_code == 404
# =============================================================================
# Data Subject Notification Tests (Art. 34)
# =============================================================================
class TestNotifySubjects:
def test_notify_subjects(self, mock_db):
row = make_incident_row()
mock_db.execute.side_effect = [
_DictResult([row]),
None, None,
]
resp = client.post(f"/incidents/{INCIDENT_ID}/notify-subjects", json={
"notification_text": "Ihre Daten waren betroffen",
"channel": "email",
})
assert resp.status_code == 200
data = resp.json()
assert data["data_subject_notification"]["status"] == "sent"
assert data["data_subject_notification"]["channel"] == "email"
# =============================================================================
# Measures Tests
# =============================================================================
class TestMeasures:
def test_add_measure(self, mock_db):
measure_row = make_measure_row()
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]), # check exists
None, # insert measure
None, # append timeline
_DictResult([measure_row]), # fetch measure
]
resp = client.post(f"/incidents/{INCIDENT_ID}/measures", json={
"title": "Passwort Reset",
"measure_type": "corrective",
})
assert resp.status_code == 200
assert resp.json()["measure"]["title"] == "Passwort Reset"
def test_add_measure_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.post(f"/incidents/{UNKNOWN_ID}/measures", json={
"title": "Test",
})
assert resp.status_code == 404
def test_update_measure(self, mock_db):
updated_row = make_measure_row({"title": "Neuer Titel"})
mock_db.execute.side_effect = [
_DictResult([{"id": MEASURE_ID}]), # check exists
None, # update
_DictResult([updated_row]), # fetch back
]
resp = client.put(
f"/incidents/{INCIDENT_ID}/measures/{MEASURE_ID}",
json={"title": "Neuer Titel"},
)
assert resp.status_code == 200
assert resp.json()["measure"]["title"] == "Neuer Titel"
def test_complete_measure(self, mock_db):
mock_db.execute.side_effect = [
_DictResult([{"id": MEASURE_ID}]),
None,
]
resp = client.post(f"/incidents/{INCIDENT_ID}/measures/{MEASURE_ID}/complete")
assert resp.status_code == 200
assert resp.json()["message"] == "measure completed"
def test_measures_in_get_response(self, mock_db):
inc_row = make_incident_row()
meas_row = make_measure_row()
mock_db.execute.side_effect = [
_DictResult([inc_row]),
_DictResult([meas_row]),
]
resp = client.get(f"/incidents/{INCIDENT_ID}")
assert resp.status_code == 200
assert len(resp.json()["measures"]) == 1
# =============================================================================
# Timeline Tests
# =============================================================================
class TestTimeline:
def test_add_timeline_entry(self, mock_db):
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]),
None,
]
resp = client.post(f"/incidents/{INCIDENT_ID}/timeline", json={
"action": "investigation_started",
"details": "Forensic analysis begun",
})
assert resp.status_code == 200
data = resp.json()
assert data["timeline_entry"]["action"] == "investigation_started"
def test_timeline_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.post(f"/incidents/{UNKNOWN_ID}/timeline", json={
"action": "test",
})
assert resp.status_code == 404
def test_auto_timeline_on_create(self, mock_db):
"""Create endpoint auto-adds incident_created timeline entry."""
row = make_incident_row()
mock_db.execute.return_value = _DictResult([row])
resp = client.post("/incidents", json={"title": "Test"})
assert resp.status_code == 200
# The insert SQL includes a timeline with incident_created entry
insert_call = mock_db.execute.call_args_list[0]
sql_text = str(insert_call[0][0])
assert "incident_incidents" in sql_text
# =============================================================================
# Close Incident Tests
# =============================================================================
class TestCloseIncident:
def test_close_with_root_cause(self, mock_db):
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]),
None, # update
None, # timeline
]
resp = client.post(f"/incidents/{INCIDENT_ID}/close", json={
"root_cause": "Schwache Passwoerter",
"lessons_learned": "2FA einfuehren",
})
assert resp.status_code == 200
data = resp.json()
assert data["message"] == "incident closed"
assert data["root_cause"] == "Schwache Passwoerter"
def test_close_minimal(self, mock_db):
mock_db.execute.side_effect = [
_DictResult([{"id": INCIDENT_ID}]),
None, None,
]
resp = client.post(f"/incidents/{INCIDENT_ID}/close", json={
"root_cause": "Unbekannt",
})
assert resp.status_code == 200
def test_close_not_found(self, mock_db):
mock_db.execute.return_value = _DictResult([])
resp = client.post(f"/incidents/{UNKNOWN_ID}/close", json={
"root_cause": "Test",
})
assert resp.status_code == 404
# =============================================================================
# Statistics Tests
# =============================================================================
class TestStatistics:
def test_empty_stats(self, mock_db):
stats_row = {
"total": 0, "open": 0, "closed": 0,
"critical": 0, "high": 0, "medium": 0, "low": 0,
}
mock_db.execute.return_value = _DictResult([stats_row])
resp = client.get("/incidents/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["by_severity"]["critical"] == 0
def test_stats_with_data(self, mock_db):
stats_row = {
"total": 5, "open": 3, "closed": 2,
"critical": 1, "high": 2, "medium": 1, "low": 1,
}
mock_db.execute.return_value = _DictResult([stats_row])
resp = client.get("/incidents/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 5
assert data["open"] == 3
assert data["closed"] == 2
assert data["by_severity"]["critical"] == 1
def test_stats_structure(self, mock_db):
stats_row = {
"total": 1, "open": 1, "closed": 0,
"critical": 0, "high": 0, "medium": 1, "low": 0,
}
mock_db.execute.return_value = _DictResult([stats_row])
resp = client.get("/incidents/stats")
data = resp.json()
assert set(data.keys()) == {"total", "open", "closed", "by_severity"}
assert set(data["by_severity"].keys()) == {"critical", "high", "medium", "low"}