"""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"}