feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
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 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s

Paket A — RAG Proxy:
- NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts
  → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung
- UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls
  GET /regulations → dynamische suggestedQuestions
  POST /search → Qdrant-Ergebnisse mit score, title, reference

Paket B — Security-Backlog + Quality:
- NEU: migrations/014_security_backlog.sql + 015_quality.sql
- NEU: compliance/api/security_backlog_routes.py — CRUD + Stats
- NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats
- UPDATE: security-backlog/page.tsx — mockItems → API
- UPDATE: quality/page.tsx — mockMetrics/mockTests → API
- UPDATE: compliance/api/__init__.py — Router-Registrierung
- NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden)
- NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden)

Paket C — Notfallplan Incidents + Templates:
- NEU: migrations/016_notfallplan_incidents.sql
  compliance_notfallplan_incidents + compliance_notfallplan_templates
- UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates
- UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API
- UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden)

Paket D — Loeschfristen localStorage → API:
- NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...)
- NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update
- UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration
  createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE,
  handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy
  apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons
- NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden)

Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-03 18:04:53 +01:00
parent 9143b84daa
commit 25d5da78ef
19 changed files with 5718 additions and 524 deletions

View File

@@ -0,0 +1,630 @@
"""Tests for Loeschfristen routes and schemas (loeschfristen_routes.py)."""
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from fastapi import FastAPI
from datetime import datetime
from compliance.api.loeschfristen_routes import (
LoeschfristCreate,
LoeschfristUpdate,
StatusUpdate,
_row_to_dict,
_get_tenant_id,
DEFAULT_TENANT_ID,
JSONB_FIELDS,
router,
)
app = FastAPI()
app.include_router(router)
client = TestClient(app)
DEFAULT_TENANT = DEFAULT_TENANT_ID # "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
POLICY_ID = "ffffffff-0001-0001-0001-000000000001"
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
# =============================================================================
# Helpers
# =============================================================================
def make_policy_row(overrides=None):
data = {
"id": POLICY_ID,
"tenant_id": DEFAULT_TENANT,
"policy_id": "LF-2024-001",
"data_object_name": "Kundendaten",
"description": "Kundendaten Loeschfrist",
"affected_groups": [],
"data_categories": [],
"primary_purpose": "Vertrag",
"deletion_trigger": "PURPOSE_END",
"retention_driver": "HGB_257",
"retention_driver_detail": None,
"retention_duration": 10,
"retention_unit": "YEARS",
"retention_description": None,
"start_event": None,
"has_active_legal_hold": False,
"legal_holds": [],
"storage_locations": [],
"deletion_method": "MANUAL_REVIEW_DELETE",
"deletion_method_detail": None,
"responsible_role": None,
"responsible_person": None,
"release_process": None,
"linked_vvt_activity_ids": [],
"status": "DRAFT",
"last_review_date": None,
"next_review_date": None,
"review_interval": "ANNUAL",
"tags": [],
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
def make_stats_row(overrides=None):
data = {
"total": 0,
"active": 0,
"draft": 0,
"review_needed": 0,
"archived": 0,
"legal_holds_count": 0,
"overdue_reviews": 0,
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
@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 TestRowToDict:
def test_converts_datetime_to_isoformat(self):
row = make_policy_row({"created_at": datetime(2024, 6, 1, 12, 0, 0)})
result = _row_to_dict(row)
assert result["created_at"] == "2024-06-01T12:00:00"
def test_converts_none_datetime_remains_none(self):
row = make_policy_row({"next_review_date": None})
result = _row_to_dict(row)
assert result["next_review_date"] is None
def test_preserves_string_values(self):
row = make_policy_row({"data_object_name": "Mitarbeiterdaten"})
result = _row_to_dict(row)
assert result["data_object_name"] == "Mitarbeiterdaten"
def test_preserves_list_values(self):
row = make_policy_row({"tags": ["dsgvo", "hgb"]})
result = _row_to_dict(row)
assert result["tags"] == ["dsgvo", "hgb"]
def test_preserves_int_values(self):
row = make_policy_row({"retention_duration": 7})
result = _row_to_dict(row)
assert result["retention_duration"] == 7
class TestGetTenantId:
def test_valid_uuid_is_returned(self):
assert _get_tenant_id("9282a473-5c95-4b3a-bf78-0ecc0ec71d3e") == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
def test_invalid_uuid_returns_default(self):
assert _get_tenant_id("not-a-uuid") == DEFAULT_TENANT_ID
def test_none_returns_default(self):
assert _get_tenant_id(None) == DEFAULT_TENANT_ID
class TestJsonbFields:
def test_jsonb_fields_set(self):
expected = {"affected_groups", "data_categories", "legal_holds",
"storage_locations", "linked_vvt_activity_ids", "tags"}
assert JSONB_FIELDS == expected
# =============================================================================
# Schema Tests — LoeschfristCreate
# =============================================================================
class TestLoeschfristCreate:
def test_minimal_requires_data_object_name(self):
obj = LoeschfristCreate(data_object_name="Kundendaten")
assert obj.data_object_name == "Kundendaten"
assert obj.deletion_trigger == "PURPOSE_END"
assert obj.status == "DRAFT"
assert obj.has_active_legal_hold is False
def test_full_object(self):
obj = LoeschfristCreate(
data_object_name="Mitarbeiterdaten",
description="HR-Daten",
primary_purpose="Arbeitsvertrag",
retention_driver="AO_147",
retention_duration=6,
retention_unit="YEARS",
status="ACTIVE",
tags=["hr", "personal"],
data_categories=["name", "address"],
)
assert obj.retention_duration == 6
assert obj.retention_unit == "YEARS"
assert obj.status == "ACTIVE"
assert len(obj.tags) == 2
def test_missing_data_object_name_raises_validation_error(self):
import pydantic
with pytest.raises(pydantic.ValidationError):
LoeschfristCreate()
def test_optional_fields_default_to_none(self):
obj = LoeschfristCreate(data_object_name="Test")
assert obj.description is None
assert obj.retention_duration is None
assert obj.responsible_role is None
assert obj.policy_id is None
class TestLoeschfristUpdate:
def test_empty_update(self):
obj = LoeschfristUpdate()
data = obj.model_dump(exclude_unset=True)
assert data == {}
def test_partial_update_status(self):
obj = LoeschfristUpdate(status="ACTIVE")
data = obj.model_dump(exclude_unset=True)
assert data == {"status": "ACTIVE"}
def test_partial_update_multiple_fields(self):
obj = LoeschfristUpdate(
data_object_name="Neuer Name",
retention_duration=5,
retention_unit="YEARS",
)
data = obj.model_dump(exclude_unset=True)
assert data["data_object_name"] == "Neuer Name"
assert data["retention_duration"] == 5
assert "status" not in data
class TestStatusUpdateSchema:
def test_valid_status(self):
obj = StatusUpdate(status="ACTIVE")
assert obj.status == "ACTIVE"
def test_all_valid_statuses(self):
for s in ("DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"):
obj = StatusUpdate(status=s)
assert obj.status == s
# =============================================================================
# GET /loeschfristen — List
# =============================================================================
class TestListLoeschfristen:
def test_list_returns_empty(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = MagicMock(__getitem__=lambda s, i: 0)
mock_db.execute.return_value.fetchall.return_value = []
# Two execute calls: COUNT then SELECT
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen")
assert resp.status_code == 200
body = resp.json()
assert "policies" in body
assert "total" in body
def test_list_returns_policies(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 1
policy_row = make_policy_row()
list_result = MagicMock()
list_result.fetchall.return_value = [policy_row]
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen")
assert resp.status_code == 200
body = resp.json()
assert len(body["policies"]) == 1
assert body["policies"][0]["data_object_name"] == "Kundendaten"
def test_list_filter_by_status(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen?status=ACTIVE")
assert resp.status_code == 200
def test_list_filter_by_retention_driver(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen?retention_driver=HGB_257")
assert resp.status_code == 200
def test_list_filter_by_search(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen?search=Kunden")
assert resp.status_code == 200
def test_list_uses_default_tenant(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
client.get("/loeschfristen")
# First call is the COUNT query
first_call_params = mock_db.execute.call_args_list[0][0][1]
assert first_call_params["tenant_id"] == DEFAULT_TENANT
def test_list_pagination_params(self, mock_db):
count_result = MagicMock()
count_result.__getitem__ = lambda s, i: 0
list_result = MagicMock()
list_result.fetchall.return_value = []
mock_db.execute.side_effect = [
MagicMock(fetchone=lambda: count_result),
list_result,
]
resp = client.get("/loeschfristen?limit=10&offset=20")
assert resp.status_code == 200
# =============================================================================
# GET /loeschfristen/stats
# =============================================================================
class TestGetLoeschfristenStats:
def test_stats_returns_all_keys(self, mock_db):
stats_row = make_stats_row()
mock_db.execute.return_value.fetchone.return_value = stats_row
resp = client.get("/loeschfristen/stats")
assert resp.status_code == 200
body = resp.json()
for key in ("total", "active", "draft", "review_needed", "archived",
"legal_holds_count", "overdue_reviews"):
assert key in body, f"Missing key: {key}"
def test_stats_returns_correct_counts(self, mock_db):
stats_row = make_stats_row({
"total": 10,
"active": 4,
"draft": 3,
"review_needed": 2,
"archived": 1,
"legal_holds_count": 1,
"overdue_reviews": 0,
})
mock_db.execute.return_value.fetchone.return_value = stats_row
resp = client.get("/loeschfristen/stats")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 10
assert body["active"] == 4
assert body["draft"] == 3
assert body["review_needed"] == 2
assert body["archived"] == 1
assert body["legal_holds_count"] == 1
def test_stats_all_zeros_when_no_data(self, mock_db):
stats_row = make_stats_row()
mock_db.execute.return_value.fetchone.return_value = stats_row
resp = client.get("/loeschfristen/stats")
assert resp.status_code == 200
body = resp.json()
for key in ("total", "active", "draft", "review_needed", "archived",
"legal_holds_count", "overdue_reviews"):
assert body[key] == 0
def test_stats_uses_default_tenant(self, mock_db):
stats_row = make_stats_row()
mock_db.execute.return_value.fetchone.return_value = stats_row
client.get("/loeschfristen/stats")
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == DEFAULT_TENANT
def test_stats_with_valid_tenant_header(self, mock_db):
stats_row = make_stats_row()
mock_db.execute.return_value.fetchone.return_value = stats_row
client.get(
"/loeschfristen/stats",
headers={"x-tenant-id": "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"},
)
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# =============================================================================
# POST /loeschfristen
# =============================================================================
class TestCreateLoeschfrist:
def test_create_minimal(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/loeschfristen", json={"data_object_name": "Kundendaten"})
assert resp.status_code == 201
assert resp.json()["data_object_name"] == "Kundendaten"
def test_create_missing_data_object_name_returns_422(self, mock_db):
resp = client.post("/loeschfristen", json={"description": "No name"})
assert resp.status_code == 422
def test_create_full_payload(self, mock_db):
row = make_policy_row({
"data_object_name": "Mitarbeiterdaten",
"status": "ACTIVE",
"retention_duration": 6,
"retention_unit": "YEARS",
})
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/loeschfristen", json={
"data_object_name": "Mitarbeiterdaten",
"description": "HR-Datensatz",
"retention_driver": "AO_147",
"retention_duration": 6,
"retention_unit": "YEARS",
"status": "ACTIVE",
})
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "ACTIVE"
assert data["retention_duration"] == 6
def test_create_commits(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/loeschfristen", json={"data_object_name": "X"})
mock_db.commit.assert_called_once()
def test_create_uses_default_tenant(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/loeschfristen", json={"data_object_name": "X"})
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == DEFAULT_TENANT
def test_create_jsonb_fields_are_json_encoded(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/loeschfristen", json={
"data_object_name": "Test",
"tags": ["a", "b"],
"data_categories": ["name"],
})
call_params = mock_db.execute.call_args[0][1]
import json
# JSONB fields must be JSON strings for CAST
assert json.loads(call_params["tags"]) == ["a", "b"]
assert json.loads(call_params["data_categories"]) == ["name"]
# =============================================================================
# GET /loeschfristen/{id}
# =============================================================================
class TestGetLoeschfrist:
def test_get_existing_policy(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.get(f"/loeschfristen/{POLICY_ID}")
assert resp.status_code == 200
assert resp.json()["data_object_name"] == "Kundendaten"
def test_get_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
resp = client.get(f"/loeschfristen/{UNKNOWN_ID}")
assert resp.status_code == 404
def test_get_passes_id_and_tenant(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
client.get(f"/loeschfristen/{POLICY_ID}")
call_params = mock_db.execute.call_args[0][1]
assert call_params["id"] == POLICY_ID
assert call_params["tenant_id"] == DEFAULT_TENANT
def test_get_all_fields_present(self, mock_db):
row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.get(f"/loeschfristen/{POLICY_ID}")
assert resp.status_code == 200
body = resp.json()
for field in ("id", "tenant_id", "data_object_name", "status",
"retention_duration", "retention_unit", "created_at", "updated_at"):
assert field in body, f"Missing field: {field}"
# =============================================================================
# PUT /loeschfristen/{id}
# =============================================================================
class TestUpdateLoeschfrist:
def test_update_success(self, mock_db):
updated_row = make_policy_row({"data_object_name": "Neuer Name"})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={"data_object_name": "Neuer Name"})
assert resp.status_code == 200
assert resp.json()["data_object_name"] == "Neuer Name"
def test_update_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
resp = client.put(f"/loeschfristen/{UNKNOWN_ID}", json={"data_object_name": "X"})
assert resp.status_code == 404
def test_update_empty_body_returns_400(self, mock_db):
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={})
assert resp.status_code == 400
def test_update_jsonb_field(self, mock_db):
updated_row = make_policy_row({"tags": ["urgent"]})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}", json={"tags": ["urgent"]})
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
import json
assert json.loads(call_params["tags"]) == ["urgent"]
def test_update_sets_updated_at(self, mock_db):
updated_row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = updated_row
client.put(f"/loeschfristen/{POLICY_ID}", json={"status": "ACTIVE"})
call_params = mock_db.execute.call_args[0][1]
assert "updated_at" in call_params
def test_update_commits(self, mock_db):
updated_row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = updated_row
client.put(f"/loeschfristen/{POLICY_ID}", json={"status": "ACTIVE"})
mock_db.commit.assert_called_once()
# =============================================================================
# PUT /loeschfristen/{id}/status
# =============================================================================
class TestUpdateLoeschfristStatus:
def test_valid_status_active(self, mock_db):
updated_row = make_policy_row({"status": "ACTIVE"})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
assert resp.status_code == 200
assert resp.json()["status"] == "ACTIVE"
def test_valid_status_archived(self, mock_db):
updated_row = make_policy_row({"status": "ARCHIVED"})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ARCHIVED"})
assert resp.status_code == 200
def test_valid_status_review_needed(self, mock_db):
updated_row = make_policy_row({"status": "REVIEW_NEEDED"})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "REVIEW_NEEDED"})
assert resp.status_code == 200
def test_valid_status_draft(self, mock_db):
updated_row = make_policy_row({"status": "DRAFT"})
mock_db.execute.return_value.fetchone.return_value = updated_row
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "DRAFT"})
assert resp.status_code == 200
def test_invalid_status_returns_400(self, mock_db):
resp = client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "INVALID_STATUS"})
assert resp.status_code == 400
def test_status_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
resp = client.put(f"/loeschfristen/{UNKNOWN_ID}/status", json={"status": "ACTIVE"})
assert resp.status_code == 404
def test_status_update_commits(self, mock_db):
updated_row = make_policy_row()
mock_db.execute.return_value.fetchone.return_value = updated_row
client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
mock_db.commit.assert_called_once()
def test_status_update_passes_correct_params(self, mock_db):
updated_row = make_policy_row({"status": "ACTIVE"})
mock_db.execute.return_value.fetchone.return_value = updated_row
client.put(f"/loeschfristen/{POLICY_ID}/status", json={"status": "ACTIVE"})
call_params = mock_db.execute.call_args[0][1]
assert call_params["status"] == "ACTIVE"
assert call_params["id"] == POLICY_ID
assert call_params["tenant_id"] == DEFAULT_TENANT
# =============================================================================
# DELETE /loeschfristen/{id}
# =============================================================================
class TestDeleteLoeschfrist:
def test_delete_success_returns_204(self, mock_db):
mock_db.execute.return_value.rowcount = 1
resp = client.delete(f"/loeschfristen/{POLICY_ID}")
assert resp.status_code == 204
def test_delete_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.rowcount = 0
resp = client.delete(f"/loeschfristen/{UNKNOWN_ID}")
assert resp.status_code == 404
def test_delete_commits(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(f"/loeschfristen/{POLICY_ID}")
mock_db.commit.assert_called_once()
def test_delete_commits_before_rowcount_check(self, mock_db):
mock_db.execute.return_value.rowcount = 0
client.delete(f"/loeschfristen/{UNKNOWN_ID}")
mock_db.commit.assert_called_once()
def test_delete_passes_correct_id_and_tenant(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(f"/loeschfristen/{POLICY_ID}")
call_params = mock_db.execute.call_args[0][1]
assert call_params["id"] == POLICY_ID
assert call_params["tenant_id"] == DEFAULT_TENANT
def test_delete_with_custom_tenant(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(
f"/loeschfristen/{POLICY_ID}",
headers={"x-tenant-id": "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"},
)
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"

View File

@@ -1,6 +1,14 @@
"""Tests for Notfallplan routes and schemas (notfallplan_routes.py)."""
"""Tests for Notfallplan routes and schemas (notfallplan_routes.py).
Covers existing schema tests plus the new incidents and templates HTTP endpoints.
"""
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from fastapi import FastAPI
from datetime import datetime
from compliance.api.notfallplan_routes import (
ContactCreate,
ContactUpdate,
@@ -8,11 +16,87 @@ from compliance.api.notfallplan_routes import (
ScenarioUpdate,
ChecklistCreate,
ExerciseCreate,
IncidentCreate,
TemplateCreate,
router,
)
app = FastAPI()
app.include_router(router)
client = TestClient(app)
DEFAULT_TENANT = "default"
INCIDENT_ID = "dddddddd-0001-0001-0001-000000000001"
TEMPLATE_ID = "eeeeeeee-0001-0001-0001-000000000001"
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
# =============================================================================
# Schema Tests — ContactCreate
# Helpers
# =============================================================================
def make_incident_row(overrides=None):
data = {
"id": INCIDENT_ID,
"tenant_id": DEFAULT_TENANT,
"title": "Test Incident",
"description": "An incident occurred",
"detected_at": datetime(2024, 1, 1),
"detected_by": "System",
"status": "detected",
"severity": "medium",
"affected_data_categories": [],
"estimated_affected_persons": 0,
"measures": [],
"art34_required": False,
"art34_justification": None,
"reported_to_authority_at": None,
"notified_affected_at": None,
"closed_at": None,
"closed_by": None,
"lessons_learned": None,
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
for k, v in data.items():
setattr(row, k, v)
row._mapping = data
return row
def make_template_row(overrides=None):
data = {
"id": TEMPLATE_ID,
"tenant_id": DEFAULT_TENANT,
"type": "art33",
"title": "Art. 33 Template",
"content": "Sehr geehrte Behoerde...",
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
for k, v in data.items():
setattr(row, k, v)
row._mapping = data
return row
@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()
# =============================================================================
# Existing Schema Tests — ContactCreate
# =============================================================================
class TestContactCreate:
@@ -46,7 +130,7 @@ class TestContactCreate:
# =============================================================================
# Schema Tests — ContactUpdate
# Existing Schema Tests — ContactUpdate
# =============================================================================
class TestContactUpdate:
@@ -62,7 +146,7 @@ class TestContactUpdate:
# =============================================================================
# Schema Tests — ScenarioCreate
# Existing Schema Tests — ScenarioCreate
# =============================================================================
class TestScenarioCreate:
@@ -100,7 +184,7 @@ class TestScenarioCreate:
# =============================================================================
# Schema Tests — ScenarioUpdate
# Existing Schema Tests — ScenarioUpdate
# =============================================================================
class TestScenarioUpdate:
@@ -121,7 +205,7 @@ class TestScenarioUpdate:
# =============================================================================
# Schema Tests — ChecklistCreate
# Existing Schema Tests — ChecklistCreate
# =============================================================================
class TestChecklistCreate:
@@ -144,7 +228,7 @@ class TestChecklistCreate:
# =============================================================================
# Schema Tests — ExerciseCreate
# Existing Schema Tests — ExerciseCreate
# =============================================================================
class TestExerciseCreate:
@@ -165,3 +249,558 @@ class TestExerciseCreate:
assert req.outcome == "passed"
assert len(req.participants) == 2
assert req.notes == "Übung verlief planmäßig"
# =============================================================================
# New Schema Tests — IncidentCreate / TemplateCreate
# =============================================================================
class TestIncidentCreateSchema:
def test_incident_create_minimal(self):
inc = IncidentCreate(title="Breach")
assert inc.title == "Breach"
assert inc.status == "detected"
assert inc.severity == "medium"
assert inc.estimated_affected_persons == 0
assert inc.art34_required is False
assert inc.affected_data_categories == []
assert inc.measures == []
def test_incident_create_full(self):
inc = IncidentCreate(
title="Big Breach",
description="Ransomware attack",
detected_by="SIEM",
status="assessed",
severity="critical",
affected_data_categories=["personal", "health"],
estimated_affected_persons=1000,
measures=["isolation", "backup restore"],
art34_required=True,
art34_justification="High risk to data subjects",
)
assert inc.severity == "critical"
assert inc.estimated_affected_persons == 1000
assert len(inc.affected_data_categories) == 2
assert inc.art34_required is True
def test_incident_create_serialization_excludes_none(self):
inc = IncidentCreate(title="T")
data = inc.model_dump(exclude_none=True)
assert data["title"] == "T"
assert "art34_justification" not in data
assert "description" not in data
class TestTemplateCreateSchema:
def test_template_create_requires_title_content(self):
t = TemplateCreate(title="T", content="C", type="art33")
assert t.title == "T"
assert t.content == "C"
assert t.type == "art33"
def test_template_create_default_type(self):
t = TemplateCreate(title="T", content="C")
assert t.type == "art33"
def test_template_create_art34_type(self):
t = TemplateCreate(title="Notification Letter", content="Dear...", type="art34")
assert t.type == "art34"
# =============================================================================
# Incidents — GET /notfallplan/incidents
# =============================================================================
class TestListIncidents:
def test_list_incidents_returns_empty_list(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents")
assert resp.status_code == 200
assert resp.json() == []
def test_list_incidents_returns_one_incident(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchall.return_value = [row]
resp = client.get("/notfallplan/incidents")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["title"] == "Test Incident"
assert data[0]["status"] == "detected"
assert data[0]["severity"] == "medium"
def test_list_incidents_returns_multiple(self, mock_db):
rows = [
make_incident_row({"id": "id-1", "title": "Incident A"}),
make_incident_row({"id": "id-2", "title": "Incident B"}),
]
mock_db.execute.return_value.fetchall.return_value = rows
resp = client.get("/notfallplan/incidents")
assert resp.status_code == 200
assert len(resp.json()) == 2
def test_list_incidents_filter_by_status(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents?status=closed")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("status") == "closed"
def test_list_incidents_filter_by_severity(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents?severity=high")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("severity") == "high"
def test_list_incidents_filter_combined(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents?status=detected&severity=critical")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("status") == "detected"
assert call_params.get("severity") == "critical"
def test_list_incidents_uses_default_tenant(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("tenant_id") == DEFAULT_TENANT
def test_list_incidents_custom_tenant_header(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/incidents", headers={"X-Tenant-ID": "my-tenant"})
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("tenant_id") == "my-tenant"
def test_list_incidents_all_fields_present(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchall.return_value = [row]
resp = client.get("/notfallplan/incidents")
item = resp.json()[0]
expected_fields = (
"id", "tenant_id", "title", "description", "detected_at",
"detected_by", "status", "severity", "affected_data_categories",
"estimated_affected_persons", "measures", "art34_required",
"art34_justification", "reported_to_authority_at",
"notified_affected_at", "closed_at", "closed_by",
"lessons_learned", "created_at", "updated_at",
)
for field in expected_fields:
assert field in item, f"Missing field: {field}"
# =============================================================================
# Incidents — POST /notfallplan/incidents
# =============================================================================
class TestCreateIncident:
def test_create_incident_minimal(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/incidents", json={"title": "New Incident"})
assert resp.status_code == 201
assert resp.json()["title"] == "Test Incident"
def test_create_incident_full_payload(self, mock_db):
row = make_incident_row({
"title": "Critical Breach",
"description": "Database exposed",
"detected_by": "SOC Team",
"status": "assessed",
"severity": "critical",
"estimated_affected_persons": 500,
})
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/incidents", json={
"title": "Critical Breach",
"description": "Database exposed",
"detected_by": "SOC Team",
"status": "assessed",
"severity": "critical",
"estimated_affected_persons": 500,
})
assert resp.status_code == 201
data = resp.json()
assert data["severity"] == "critical"
assert data["estimated_affected_persons"] == 500
def test_create_incident_missing_title_returns_422(self, mock_db):
resp = client.post("/notfallplan/incidents", json={"description": "No title here"})
assert resp.status_code == 422
def test_create_incident_default_status_passed_to_db(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/notfallplan/incidents", json={"title": "T"})
call_params = mock_db.execute.call_args[0][1]
assert call_params["status"] == "detected"
assert call_params["severity"] == "medium"
def test_create_incident_commits(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/notfallplan/incidents", json={"title": "T"})
mock_db.commit.assert_called_once()
def test_create_incident_with_art34_required(self, mock_db):
row = make_incident_row({"art34_required": True, "art34_justification": "Hohe Risikobewertung"})
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/incidents", json={
"title": "High Risk",
"art34_required": True,
"art34_justification": "Hohe Risikobewertung",
})
assert resp.status_code == 201
assert resp.json()["art34_required"] is True
def test_create_incident_passes_tenant_id(self, mock_db):
row = make_incident_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/notfallplan/incidents",
json={"title": "T"},
headers={"X-Tenant-ID": "custom-org"})
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "custom-org"
# =============================================================================
# Incidents — PUT /notfallplan/incidents/{id}
# =============================================================================
class TestUpdateIncident:
def test_update_incident_success(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row({"status": "assessed"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "assessed"})
assert resp.status_code == 200
assert resp.json()["status"] == "assessed"
def test_update_incident_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
resp = client.put(f"/notfallplan/incidents/{UNKNOWN_ID}", json={"status": "closed"})
assert resp.status_code == 404
def test_update_incident_empty_body_returns_400(self, mock_db):
existing = MagicMock()
mock_db.execute.return_value.fetchone.return_value = existing
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={})
assert resp.status_code == 400
def test_update_incident_status_to_reported_auto_sets_timestamp(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row({"status": "reported"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "reported"})
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert "reported_to_authority_at" in call_params
def test_update_incident_status_to_closed_auto_sets_closed_at(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row({"status": "closed"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "closed"})
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert "closed_at" in call_params
def test_update_incident_lessons_learned(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row({"lessons_learned": "Besseres Monitoring nötig"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(
f"/notfallplan/incidents/{INCIDENT_ID}",
json={"lessons_learned": "Besseres Monitoring nötig"},
)
assert resp.status_code == 200
assert resp.json()["lessons_learned"] == "Besseres Monitoring nötig"
def test_update_incident_severity(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row({"severity": "high"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "high"})
assert resp.status_code == 200
assert resp.json()["severity"] == "high"
def test_update_incident_commits(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row()
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "low"})
mock_db.commit.assert_called()
def test_update_incident_always_sets_updated_at(self, mock_db):
existing = MagicMock()
updated_row = make_incident_row()
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"title": "Renamed"})
call_params = mock_db.execute.call_args[0][1]
assert "updated_at" in call_params
# =============================================================================
# Incidents — DELETE /notfallplan/incidents/{id}
# =============================================================================
class TestDeleteIncident:
def test_delete_incident_success_returns_204(self, mock_db):
mock_db.execute.return_value.rowcount = 1
resp = client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
assert resp.status_code == 204
def test_delete_incident_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.rowcount = 0
resp = client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
assert resp.status_code == 404
def test_delete_incident_commits(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
mock_db.commit.assert_called_once()
def test_delete_incident_commits_even_when_not_found(self, mock_db):
# Commit is called before the rowcount check in the implementation
mock_db.execute.return_value.rowcount = 0
client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
mock_db.commit.assert_called_once()
def test_delete_incident_passes_tenant_id(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(
f"/notfallplan/incidents/{INCIDENT_ID}",
headers={"X-Tenant-ID": "acme"},
)
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "acme"
# =============================================================================
# Templates — GET /notfallplan/templates
# =============================================================================
class TestListTemplates:
def test_list_templates_returns_empty_list(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/templates")
assert resp.status_code == 200
assert resp.json() == []
def test_list_templates_returns_one(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchall.return_value = [row]
resp = client.get("/notfallplan/templates")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["title"] == "Art. 33 Template"
assert data[0]["type"] == "art33"
def test_list_templates_returns_multiple(self, mock_db):
rows = [
make_template_row({"id": "id-1", "type": "art33", "title": "Meldung Art.33"}),
make_template_row({"id": "id-2", "type": "art34", "title": "Meldung Art.34"}),
]
mock_db.execute.return_value.fetchall.return_value = rows
resp = client.get("/notfallplan/templates")
assert resp.status_code == 200
assert len(resp.json()) == 2
def test_list_templates_filter_by_type(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/templates?type=art34")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert call_params.get("type") == "art34"
def test_list_templates_without_type_no_type_param_sent(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
resp = client.get("/notfallplan/templates")
assert resp.status_code == 200
call_params = mock_db.execute.call_args[0][1]
assert "type" not in call_params
def test_list_templates_uses_default_tenant(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
client.get("/notfallplan/templates")
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == DEFAULT_TENANT
def test_list_templates_custom_tenant_header(self, mock_db):
mock_db.execute.return_value.fetchall.return_value = []
client.get("/notfallplan/templates", headers={"X-Tenant-ID": "acme"})
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "acme"
def test_list_templates_all_fields_present(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchall.return_value = [row]
resp = client.get("/notfallplan/templates")
item = resp.json()[0]
for field in ("id", "tenant_id", "type", "title", "content", "created_at", "updated_at"):
assert field in item, f"Missing field: {field}"
# =============================================================================
# Templates — POST /notfallplan/templates
# =============================================================================
class TestCreateTemplate:
def test_create_template_success(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/templates", json={
"title": "Art. 33 Template",
"content": "Sehr geehrte Behoerde...",
"type": "art33",
})
assert resp.status_code == 201
assert resp.json()["title"] == "Art. 33 Template"
def test_create_template_missing_title_returns_422(self, mock_db):
resp = client.post("/notfallplan/templates", json={
"content": "Some content",
"type": "art33",
})
assert resp.status_code == 422
def test_create_template_missing_content_returns_422(self, mock_db):
resp = client.post("/notfallplan/templates", json={
"title": "Template",
"type": "art33",
})
assert resp.status_code == 422
def test_create_template_missing_type_uses_default_art33(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/templates", json={"title": "T", "content": "C"})
assert resp.status_code == 201
call_params = mock_db.execute.call_args[0][1]
assert call_params["type"] == "art33"
def test_create_template_art34_type(self, mock_db):
row = make_template_row({"type": "art34"})
mock_db.execute.return_value.fetchone.return_value = row
resp = client.post("/notfallplan/templates", json={
"title": "Art. 34 Notification",
"content": "Sehr geehrte Betroffene...",
"type": "art34",
})
assert resp.status_code == 201
assert resp.json()["type"] == "art34"
def test_create_template_commits(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post("/notfallplan/templates", json={"title": "T", "content": "C", "type": "art33"})
mock_db.commit.assert_called_once()
def test_create_template_passes_tenant_id(self, mock_db):
row = make_template_row()
mock_db.execute.return_value.fetchone.return_value = row
client.post(
"/notfallplan/templates",
json={"title": "T", "content": "C", "type": "art33"},
headers={"X-Tenant-ID": "my-org"},
)
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "my-org"
# =============================================================================
# Templates — PUT /notfallplan/templates/{id}
# =============================================================================
class TestUpdateTemplate:
def test_update_template_title_success(self, mock_db):
existing = MagicMock()
updated_row = make_template_row({"title": "Updated Title"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "Updated Title"})
assert resp.status_code == 200
assert resp.json()["title"] == "Updated Title"
def test_update_template_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
resp = client.put(f"/notfallplan/templates/{UNKNOWN_ID}", json={"title": "X"})
assert resp.status_code == 404
def test_update_template_empty_body_returns_400(self, mock_db):
existing = MagicMock()
mock_db.execute.return_value.fetchone.return_value = existing
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={})
assert resp.status_code == 400
def test_update_template_content(self, mock_db):
existing = MagicMock()
updated_row = make_template_row({"content": "New body text"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(
f"/notfallplan/templates/{TEMPLATE_ID}",
json={"content": "New body text"},
)
assert resp.status_code == 200
assert resp.json()["content"] == "New body text"
def test_update_template_type(self, mock_db):
existing = MagicMock()
updated_row = make_template_row({"type": "internal"})
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"type": "internal"})
assert resp.status_code == 200
def test_update_template_commits(self, mock_db):
existing = MagicMock()
updated_row = make_template_row()
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
mock_db.commit.assert_called()
def test_update_template_sets_updated_at(self, mock_db):
existing = MagicMock()
updated_row = make_template_row()
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
call_params = mock_db.execute.call_args[0][1]
assert "updated_at" in call_params
# =============================================================================
# Templates — DELETE /notfallplan/templates/{id}
# =============================================================================
class TestDeleteTemplate:
def test_delete_template_success_returns_204(self, mock_db):
mock_db.execute.return_value.rowcount = 1
resp = client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
assert resp.status_code == 204
def test_delete_template_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.rowcount = 0
resp = client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
assert resp.status_code == 404
def test_delete_template_commits_on_success(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
mock_db.commit.assert_called_once()
def test_delete_template_commits_even_when_not_found(self, mock_db):
mock_db.execute.return_value.rowcount = 0
client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
mock_db.commit.assert_called_once()
def test_delete_template_passes_correct_tenant_id(self, mock_db):
mock_db.execute.return_value.rowcount = 1
client.delete(
f"/notfallplan/templates/{TEMPLATE_ID}",
headers={"X-Tenant-ID": "acme"},
)
call_params = mock_db.execute.call_args[0][1]
assert call_params["tenant_id"] == "acme"

View File

@@ -0,0 +1,937 @@
"""Tests for AI Quality Metrics and Tests routes (quality_routes.py).
Covers:
- Schema validation (MetricCreate, MetricUpdate, TestCreate, TestUpdate)
- Helper functions (_row_to_dict, _get_tenant_id)
- HTTP endpoints via FastAPI TestClient with mocked DB session
"""
import pytest
from unittest.mock import MagicMock
from datetime import datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.quality_routes import (
router,
MetricCreate,
MetricUpdate,
TestCreate,
TestUpdate,
_row_to_dict,
_get_tenant_id,
DEFAULT_TENANT_ID,
)
from classroom_engine.database import get_db
# =============================================================================
# TestClient Setup
# =============================================================================
app = FastAPI()
app.include_router(router)
client = TestClient(app)
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
OTHER_TENANT = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
METRIC_ID = "bbbbbbbb-0001-0001-0001-000000000001"
TEST_ID = "cccccccc-0001-0001-0001-000000000001"
def make_metric_row(overrides=None):
data = {
"id": METRIC_ID,
"tenant_id": DEFAULT_TENANT,
"name": "Test Metric",
"category": "accuracy",
"score": 85.0,
"threshold": 80.0,
"trend": "stable",
"ai_system": None,
"last_measured": datetime(2024, 1, 1),
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
def make_test_row(overrides=None):
data = {
"id": TEST_ID,
"tenant_id": DEFAULT_TENANT,
"name": "Test Run",
"status": "passed",
"duration": "1.2s",
"ai_system": None,
"details": None,
"last_run": datetime(2024, 1, 1),
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
@pytest.fixture
def mock_db():
db = MagicMock()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
yield db
app.dependency_overrides.clear()
# =============================================================================
# Schema Tests — MetricCreate
# =============================================================================
class TestMetricCreate:
def test_minimal_valid(self):
req = MetricCreate(name="Accuracy Score")
assert req.name == "Accuracy Score"
assert req.category == "accuracy"
assert req.score == 0.0
assert req.threshold == 80.0
assert req.trend == "stable"
assert req.ai_system is None
assert req.last_measured is None
def test_full_values(self):
ts = datetime(2026, 3, 1)
req = MetricCreate(
name="Fairness Score",
category="fairness",
score=92.5,
threshold=85.0,
trend="improving",
ai_system="RecSys-v2",
last_measured=ts,
)
assert req.name == "Fairness Score"
assert req.category == "fairness"
assert req.score == 92.5
assert req.trend == "improving"
assert req.ai_system == "RecSys-v2"
assert req.last_measured == ts
def test_serialization_excludes_none(self):
req = MetricCreate(name="Drift Score", score=75.0)
data = req.model_dump(exclude_none=True)
assert data["name"] == "Drift Score"
assert data["score"] == 75.0
assert "ai_system" not in data
assert "last_measured" not in data
def test_default_trend_stable(self):
req = MetricCreate(name="Test")
assert req.trend == "stable"
def test_default_score_zero(self):
req = MetricCreate(name="Test")
assert req.score == 0.0
# =============================================================================
# Schema Tests — MetricUpdate
# =============================================================================
class TestMetricUpdate:
def test_empty_update(self):
req = MetricUpdate()
data = req.model_dump(exclude_unset=True)
assert data == {}
def test_partial_score_update(self):
req = MetricUpdate(score=95.0)
data = req.model_dump(exclude_unset=True)
assert data == {"score": 95.0}
def test_trend_and_threshold_update(self):
req = MetricUpdate(trend="declining", threshold=90.0)
data = req.model_dump(exclude_unset=True)
assert data["trend"] == "declining"
assert data["threshold"] == 90.0
assert "name" not in data
def test_full_update(self):
req = MetricUpdate(
name="New Name",
category="robustness",
score=88.0,
threshold=85.0,
trend="improving",
ai_system="ModelB",
)
data = req.model_dump(exclude_unset=True)
assert len(data) == 6
assert data["category"] == "robustness"
# =============================================================================
# Schema Tests — TestCreate
# =============================================================================
class TestTestCreate:
def test_minimal_valid(self):
req = TestCreate(name="Bias Detection Test")
assert req.name == "Bias Detection Test"
assert req.status == "pending"
assert req.duration is None
assert req.ai_system is None
assert req.details is None
assert req.last_run is None
def test_full_values(self):
ts = datetime(2026, 3, 1, 12, 0, 0)
req = TestCreate(
name="Accuracy Test Suite",
status="passed",
duration="3.45s",
ai_system="ClassifierV3",
details="All 500 samples passed",
last_run=ts,
)
assert req.status == "passed"
assert req.duration == "3.45s"
assert req.ai_system == "ClassifierV3"
assert req.details == "All 500 samples passed"
assert req.last_run == ts
def test_failed_status(self):
req = TestCreate(name="Fairness Check", status="failed")
assert req.status == "failed"
def test_serialization_excludes_none(self):
req = TestCreate(name="Quick Test", status="passed")
data = req.model_dump(exclude_none=True)
assert "duration" not in data
assert "ai_system" not in data
# =============================================================================
# Schema Tests — TestUpdate
# =============================================================================
class TestTestUpdate:
def test_empty_update(self):
req = TestUpdate()
data = req.model_dump(exclude_unset=True)
assert data == {}
def test_status_update(self):
req = TestUpdate(status="failed")
data = req.model_dump(exclude_unset=True)
assert data == {"status": "failed"}
def test_duration_and_details_update(self):
req = TestUpdate(duration="10.5s", details="Timeout on 3 samples")
data = req.model_dump(exclude_unset=True)
assert data["duration"] == "10.5s"
assert data["details"] == "Timeout on 3 samples"
assert "name" not in data
# =============================================================================
# Helper Tests — _row_to_dict
# =============================================================================
class TestRowToDict:
def test_basic_metric_conversion(self):
row = make_metric_row()
result = _row_to_dict(row)
assert result["id"] == METRIC_ID
assert result["name"] == "Test Metric"
assert result["score"] == 85.0
assert result["threshold"] == 80.0
def test_datetime_serialized(self):
ts = datetime(2024, 6, 15, 9, 0, 0)
row = make_metric_row({"created_at": ts, "last_measured": ts})
result = _row_to_dict(row)
assert result["created_at"] == ts.isoformat()
assert result["last_measured"] == ts.isoformat()
def test_none_values_preserved(self):
row = make_metric_row()
result = _row_to_dict(row)
assert result["ai_system"] is None
def test_uuid_converted_to_string(self):
import uuid
uid = uuid.UUID(DEFAULT_TENANT)
row = MagicMock()
row._mapping = {"id": uid, "tenant_id": uid}
result = _row_to_dict(row)
assert result["id"] == str(uid)
def test_numeric_fields_unchanged(self):
row = MagicMock()
row._mapping = {"score": 92.5, "threshold": 80.0, "count": 10}
result = _row_to_dict(row)
assert result["score"] == 92.5
assert result["threshold"] == 80.0
assert result["count"] == 10
# =============================================================================
# Helper Tests — _get_tenant_id
# =============================================================================
class TestGetTenantId:
def test_valid_uuid_returned(self):
result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT)
assert result == DEFAULT_TENANT
def test_none_returns_default(self):
result = _get_tenant_id(x_tenant_id=None)
assert result == DEFAULT_TENANT_ID
def test_invalid_uuid_returns_default(self):
result = _get_tenant_id(x_tenant_id="invalid-uuid")
assert result == DEFAULT_TENANT_ID
def test_empty_string_returns_default(self):
result = _get_tenant_id(x_tenant_id="")
assert result == DEFAULT_TENANT_ID
def test_other_valid_tenant(self):
result = _get_tenant_id(x_tenant_id=OTHER_TENANT)
assert result == OTHER_TENANT
# =============================================================================
# HTTP Tests — GET /quality/stats
# =============================================================================
class TestGetQualityStats:
def test_stats_all_zeros(self, mock_db):
metrics_row = MagicMock()
metrics_row.total_metrics = 0
metrics_row.avg_score = 0
metrics_row.metrics_above_threshold = 0
tests_row = MagicMock()
tests_row.passed = 0
tests_row.failed = 0
tests_row.warning = 0
tests_row.total = 0
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
response = client.get("/quality/stats")
assert response.status_code == 200
data = response.json()
assert data["total_metrics"] == 0
assert data["avg_score"] == 0.0
assert data["metrics_above_threshold"] == 0
assert data["passed"] == 0
assert data["failed"] == 0
assert data["warning"] == 0
assert data["total_tests"] == 0
def test_stats_with_data(self, mock_db):
metrics_row = MagicMock()
metrics_row.total_metrics = 5
metrics_row.avg_score = 87.4
metrics_row.metrics_above_threshold = 4
tests_row = MagicMock()
tests_row.passed = 10
tests_row.failed = 2
tests_row.warning = 1
tests_row.total = 13
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
response = client.get("/quality/stats")
assert response.status_code == 200
data = response.json()
assert data["total_metrics"] == 5
assert data["avg_score"] == 87.4
assert data["metrics_above_threshold"] == 4
assert data["passed"] == 10
assert data["failed"] == 2
assert data["warning"] == 1
assert data["total_tests"] == 13
def test_stats_none_values_become_zero(self, mock_db):
metrics_row = MagicMock()
metrics_row.total_metrics = None
metrics_row.avg_score = None
metrics_row.metrics_above_threshold = None
tests_row = MagicMock()
tests_row.passed = None
tests_row.failed = None
tests_row.warning = None
tests_row.total = None
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
response = client.get("/quality/stats")
assert response.status_code == 200
data = response.json()
assert data["total_metrics"] == 0
assert data["avg_score"] == 0.0
assert data["total_tests"] == 0
def test_stats_with_tenant_header(self, mock_db):
metrics_row = MagicMock()
metrics_row.total_metrics = 2
metrics_row.avg_score = 90.0
metrics_row.metrics_above_threshold = 2
tests_row = MagicMock()
tests_row.passed = 5
tests_row.failed = 0
tests_row.warning = 0
tests_row.total = 5
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
response = client.get(
"/quality/stats",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 200
assert response.json()["total_metrics"] == 2
# =============================================================================
# HTTP Tests — GET /quality/metrics
# =============================================================================
class TestListMetrics:
def test_empty_list(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = []
mock_db.execute.return_value = execute_result
response = client.get("/quality/metrics")
assert response.status_code == 200
data = response.json()
assert data["metrics"] == []
assert data["total"] == 0
def test_list_with_data(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 2
m1 = make_metric_row()
m2 = make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002", "name": "Second Metric"})
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [m1, m2]
mock_db.execute.return_value = execute_result
response = client.get("/quality/metrics")
assert response.status_code == 200
data = response.json()
assert len(data["metrics"]) == 2
assert data["total"] == 2
def test_filter_by_category(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_metric_row({"category": "fairness"})]
mock_db.execute.return_value = execute_result
response = client.get("/quality/metrics?category=fairness")
assert response.status_code == 200
data = response.json()
assert data["metrics"][0]["category"] == "fairness"
def test_filter_by_ai_system(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_metric_row({"ai_system": "ModelAlpha"})]
mock_db.execute.return_value = execute_result
response = client.get("/quality/metrics?ai_system=ModelAlpha")
assert response.status_code == 200
data = response.json()
assert data["metrics"][0]["ai_system"] == "ModelAlpha"
def test_pagination(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 20
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_metric_row()]
mock_db.execute.return_value = execute_result
response = client.get("/quality/metrics?limit=1&offset=10")
assert response.status_code == 200
data = response.json()
assert data["total"] == 20
assert len(data["metrics"]) == 1
# =============================================================================
# HTTP Tests — POST /quality/metrics
# =============================================================================
class TestCreateMetric:
def test_create_success(self, mock_db):
created_row = make_metric_row({"name": "New Metric"})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/quality/metrics",
json={"name": "New Metric"},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Metric"
mock_db.commit.assert_called_once()
def test_create_full_metric(self, mock_db):
created_row = make_metric_row({
"name": "Robustness Score",
"category": "robustness",
"score": 78.5,
"threshold": 75.0,
"trend": "improving",
})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post("/quality/metrics", json={
"name": "Robustness Score",
"category": "robustness",
"score": 78.5,
"threshold": 75.0,
"trend": "improving",
})
assert response.status_code == 201
data = response.json()
assert data["category"] == "robustness"
assert data["score"] == 78.5
def test_create_missing_name_fails(self, mock_db):
response = client.post("/quality/metrics", json={"score": 90.0})
assert response.status_code == 422
def test_create_with_tenant_header(self, mock_db):
created_row = make_metric_row({"tenant_id": OTHER_TENANT})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/quality/metrics",
json={"name": "Tenant B metric"},
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 201
assert mock_db.commit.called
# =============================================================================
# HTTP Tests — PUT /quality/metrics/{id}
# =============================================================================
class TestUpdateMetric:
def test_update_success(self, mock_db):
updated_row = make_metric_row({"score": 95.0, "trend": "improving"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/quality/metrics/{METRIC_ID}",
json={"score": 95.0, "trend": "improving"},
)
assert response.status_code == 200
data = response.json()
assert data["score"] == 95.0
assert data["trend"] == "improving"
mock_db.commit.assert_called_once()
def test_update_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
response = client.put(
"/quality/metrics/nonexistent-id",
json={"score": 50.0},
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_update_no_fields_returns_400(self, mock_db):
response = client.put(f"/quality/metrics/{METRIC_ID}", json={})
assert response.status_code == 400
def test_partial_update_category(self, mock_db):
updated_row = make_metric_row({"category": "explainability"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/quality/metrics/{METRIC_ID}",
json={"category": "explainability"},
)
assert response.status_code == 200
assert response.json()["category"] == "explainability"
# =============================================================================
# HTTP Tests — DELETE /quality/metrics/{id}
# =============================================================================
class TestDeleteMetric:
def test_delete_success_204(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(f"/quality/metrics/{METRIC_ID}")
assert response.status_code == 204
mock_db.commit.assert_called_once()
def test_delete_not_found_returns_404(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 0
mock_db.execute.return_value = delete_result
response = client.delete("/quality/metrics/nonexistent-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_delete_with_tenant_header(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(
f"/quality/metrics/{METRIC_ID}",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 204
# =============================================================================
# HTTP Tests — GET /quality/tests
# =============================================================================
class TestListQualityTests:
def test_empty_list(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = []
mock_db.execute.return_value = execute_result
response = client.get("/quality/tests")
assert response.status_code == 200
data = response.json()
assert data["tests"] == []
assert data["total"] == 0
def test_list_with_data(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 2
t1 = make_test_row()
t2 = make_test_row({"id": "cccccccc-0002-0002-0002-000000000002", "name": "Second Test"})
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [t1, t2]
mock_db.execute.return_value = execute_result
response = client.get("/quality/tests")
assert response.status_code == 200
data = response.json()
assert len(data["tests"]) == 2
assert data["total"] == 2
def test_filter_by_status(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_test_row({"status": "failed"})]
mock_db.execute.return_value = execute_result
response = client.get("/quality/tests?status=failed")
assert response.status_code == 200
data = response.json()
assert data["tests"][0]["status"] == "failed"
def test_filter_by_ai_system(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_test_row({"ai_system": "ModelBeta"})]
mock_db.execute.return_value = execute_result
response = client.get("/quality/tests?ai_system=ModelBeta")
assert response.status_code == 200
data = response.json()
assert data["tests"][0]["ai_system"] == "ModelBeta"
def test_pagination(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 50
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_test_row()]
mock_db.execute.return_value = execute_result
response = client.get("/quality/tests?limit=1&offset=5")
assert response.status_code == 200
assert response.json()["total"] == 50
# =============================================================================
# HTTP Tests — POST /quality/tests
# =============================================================================
class TestCreateQualityTest:
def test_create_success(self, mock_db):
created_row = make_test_row({"name": "New Test Run"})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/quality/tests",
json={"name": "New Test Run"},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Test Run"
mock_db.commit.assert_called_once()
def test_create_full_test(self, mock_db):
created_row = make_test_row({
"name": "Full Test Suite",
"status": "passed",
"duration": "5.0s",
"ai_system": "Classifier",
"details": "All assertions passed",
})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post("/quality/tests", json={
"name": "Full Test Suite",
"status": "passed",
"duration": "5.0s",
"ai_system": "Classifier",
"details": "All assertions passed",
})
assert response.status_code == 201
data = response.json()
assert data["status"] == "passed"
assert data["duration"] == "5.0s"
def test_create_missing_name_fails(self, mock_db):
response = client.post("/quality/tests", json={"status": "passed"})
assert response.status_code == 422
def test_create_failed_status(self, mock_db):
created_row = make_test_row({"status": "failed"})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/quality/tests",
json={"name": "Failing test", "status": "failed"},
)
assert response.status_code == 201
assert response.json()["status"] == "failed"
# =============================================================================
# HTTP Tests — PUT /quality/tests/{id}
# =============================================================================
class TestUpdateQualityTest:
def test_update_success(self, mock_db):
updated_row = make_test_row({"status": "failed", "details": "Assertion error on line 42"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/quality/tests/{TEST_ID}",
json={"status": "failed", "details": "Assertion error on line 42"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
assert data["details"] == "Assertion error on line 42"
mock_db.commit.assert_called_once()
def test_update_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
response = client.put(
"/quality/tests/nonexistent-id",
json={"status": "passed"},
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_update_no_fields_returns_400(self, mock_db):
response = client.put(f"/quality/tests/{TEST_ID}", json={})
assert response.status_code == 400
def test_update_duration_only(self, mock_db):
updated_row = make_test_row({"duration": "2.8s"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/quality/tests/{TEST_ID}",
json={"duration": "2.8s"},
)
assert response.status_code == 200
assert response.json()["duration"] == "2.8s"
def test_update_with_tenant_header(self, mock_db):
updated_row = make_test_row({"tenant_id": OTHER_TENANT, "status": "warning"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/quality/tests/{TEST_ID}",
json={"status": "warning"},
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 200
assert response.json()["status"] == "warning"
# =============================================================================
# HTTP Tests — DELETE /quality/tests/{id}
# =============================================================================
class TestDeleteQualityTest:
def test_delete_success_204(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(f"/quality/tests/{TEST_ID}")
assert response.status_code == 204
mock_db.commit.assert_called_once()
def test_delete_not_found_returns_404(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 0
mock_db.execute.return_value = delete_result
response = client.delete("/quality/tests/nonexistent-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_delete_with_tenant_header(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(
f"/quality/tests/{TEST_ID}",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 204
assert mock_db.commit.called
# =============================================================================
# Tenant Isolation Tests
# =============================================================================
class TestTenantIsolation:
def test_metrics_tenant_isolation(self, mock_db):
"""Tenant A sees 3 metrics, Tenant B sees 0."""
def side_effect(query, params):
result = MagicMock()
tid = params.get("tenant_id", "")
if tid == DEFAULT_TENANT:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 3
result.fetchone.return_value = count_row
result.fetchall.return_value = [
make_metric_row(),
make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002"}),
make_metric_row({"id": "bbbbbbbb-0003-0003-0003-000000000003"}),
]
else:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
result.fetchone.return_value = count_row
result.fetchall.return_value = []
return result
mock_db.execute.side_effect = side_effect
resp_a = client.get(
"/quality/metrics",
headers={"X-Tenant-Id": DEFAULT_TENANT},
)
assert resp_a.json()["total"] == 3
resp_b = client.get(
"/quality/metrics",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert resp_b.json()["total"] == 0
def test_tests_tenant_isolation(self, mock_db):
"""Tenant A sees 2 tests, Tenant B sees 0."""
def side_effect(query, params):
result = MagicMock()
tid = params.get("tenant_id", "")
if tid == DEFAULT_TENANT:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 2
result.fetchone.return_value = count_row
result.fetchall.return_value = [make_test_row(), make_test_row({"id": "cccccccc-0002-0002-0002-000000000002"})]
else:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
result.fetchone.return_value = count_row
result.fetchall.return_value = []
return result
mock_db.execute.side_effect = side_effect
resp_a = client.get("/quality/tests", headers={"X-Tenant-Id": DEFAULT_TENANT})
assert resp_a.json()["total"] == 2
resp_b = client.get("/quality/tests", headers={"X-Tenant-Id": OTHER_TENANT})
assert resp_b.json()["total"] == 0
def test_invalid_tenant_header_falls_back_to_default(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = []
mock_db.execute.return_value = execute_result
response = client.get(
"/quality/metrics",
headers={"X-Tenant-Id": "bad-uuid"},
)
assert response.status_code == 200
def test_delete_wrong_tenant_returns_404(self, mock_db):
"""Deleting a metric that belongs to a different tenant returns 404."""
delete_result = MagicMock()
delete_result.rowcount = 0
mock_db.execute.return_value = delete_result
response = client.delete(
f"/quality/metrics/{METRIC_ID}",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 404

View File

@@ -0,0 +1,698 @@
"""Tests for Security Backlog routes (security_backlog_routes.py).
Covers:
- Schema validation (SecurityItemCreate, SecurityItemUpdate)
- Helper functions (_row_to_dict, _get_tenant_id)
- HTTP endpoints via FastAPI TestClient with mocked DB session
"""
import pytest
from unittest.mock import MagicMock
from datetime import datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from compliance.api.security_backlog_routes import (
router,
SecurityItemCreate,
SecurityItemUpdate,
_row_to_dict,
_get_tenant_id,
DEFAULT_TENANT_ID,
)
from classroom_engine.database import get_db
# =============================================================================
# TestClient Setup
# =============================================================================
app = FastAPI()
app.include_router(router)
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
OTHER_TENANT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
ITEM_ID = "aaaaaaaa-0001-0001-0001-000000000001"
def make_item_row(overrides=None):
data = {
"id": ITEM_ID,
"tenant_id": DEFAULT_TENANT,
"title": "Test Item",
"description": "Test description",
"type": "vulnerability",
"severity": "medium",
"status": "open",
"source": None,
"cve": None,
"cvss": None,
"affected_asset": None,
"assigned_to": None,
"due_date": None,
"remediation": None,
"created_at": datetime(2024, 1, 1),
"updated_at": datetime(2024, 1, 1),
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
def make_stats_row(overrides=None):
data = {
"open": 3,
"in_progress": 1,
"resolved": 2,
"accepted_risk": 0,
"critical": 1,
"high": 2,
"overdue": 1,
"total": 6,
}
if overrides:
data.update(overrides)
row = MagicMock()
row._mapping = data
return row
@pytest.fixture
def mock_db():
db = MagicMock()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
yield db
app.dependency_overrides.clear()
@pytest.fixture
def test_client():
return TestClient(app)
# Module-level client for simple tests that set up their own mocks per test
client = TestClient(app)
# =============================================================================
# Schema Tests — SecurityItemCreate
# =============================================================================
class TestSecurityItemCreate:
def test_minimal_valid(self):
req = SecurityItemCreate(title="SQL Injection in Login")
assert req.title == "SQL Injection in Login"
assert req.type == "vulnerability"
assert req.severity == "medium"
assert req.status == "open"
assert req.description is None
assert req.source is None
assert req.cve is None
assert req.cvss is None
assert req.affected_asset is None
assert req.assigned_to is None
assert req.due_date is None
assert req.remediation is None
def test_full_values(self):
due = datetime(2026, 6, 30)
req = SecurityItemCreate(
title="CVE-2024-1234",
description="Remote code execution in parser",
type="cve",
severity="critical",
status="in-progress",
source="NVD",
cve="CVE-2024-1234",
cvss=9.8,
affected_asset="api-server",
assigned_to="security-team",
due_date=due,
remediation="Upgrade to v2.1.0",
)
assert req.title == "CVE-2024-1234"
assert req.type == "cve"
assert req.severity == "critical"
assert req.cvss == 9.8
assert req.cve == "CVE-2024-1234"
assert req.assigned_to == "security-team"
def test_serialization_excludes_none(self):
req = SecurityItemCreate(title="Patch missing", severity="high")
data = req.model_dump(exclude_none=True)
assert data["title"] == "Patch missing"
assert data["severity"] == "high"
assert "description" not in data
assert "cve" not in data
def test_serialization_includes_defaults(self):
req = SecurityItemCreate(title="Test")
data = req.model_dump()
assert data["type"] == "vulnerability"
assert data["severity"] == "medium"
assert data["status"] == "open"
# =============================================================================
# Schema Tests — SecurityItemUpdate
# =============================================================================
class TestSecurityItemUpdate:
def test_empty_update(self):
req = SecurityItemUpdate()
data = req.model_dump(exclude_unset=True)
assert data == {}
def test_partial_update_status(self):
req = SecurityItemUpdate(status="resolved")
data = req.model_dump(exclude_unset=True)
assert data == {"status": "resolved"}
def test_partial_update_severity(self):
req = SecurityItemUpdate(severity="critical", assigned_to="john@example.com")
data = req.model_dump(exclude_unset=True)
assert data["severity"] == "critical"
assert data["assigned_to"] == "john@example.com"
assert "title" not in data
def test_full_update(self):
req = SecurityItemUpdate(
title="Updated Title",
description="New desc",
type="misconfiguration",
severity="low",
status="accepted-risk",
remediation="Accept the risk",
)
data = req.model_dump(exclude_unset=True)
assert len(data) == 6
assert data["type"] == "misconfiguration"
assert data["status"] == "accepted-risk"
# =============================================================================
# Helper Tests — _row_to_dict
# =============================================================================
class TestRowToDict:
def test_basic_conversion(self):
row = make_item_row()
result = _row_to_dict(row)
assert result["id"] == ITEM_ID
assert result["title"] == "Test Item"
assert result["severity"] == "medium"
def test_datetime_serialized(self):
ts = datetime(2024, 6, 15, 10, 30, 0)
row = make_item_row({"created_at": ts, "updated_at": ts})
result = _row_to_dict(row)
assert result["created_at"] == ts.isoformat()
assert result["updated_at"] == ts.isoformat()
def test_none_values_preserved(self):
row = make_item_row()
result = _row_to_dict(row)
assert result["source"] is None
assert result["cve"] is None
assert result["cvss"] is None
assert result["affected_asset"] is None
assert result["due_date"] is None
def test_uuid_converted_to_string(self):
import uuid
uid = uuid.UUID(DEFAULT_TENANT)
row = MagicMock()
row._mapping = {"id": uid, "tenant_id": uid}
result = _row_to_dict(row)
assert result["id"] == str(uid)
assert result["tenant_id"] == str(uid)
def test_string_and_numeric_unchanged(self):
row = MagicMock()
row._mapping = {"title": "Test", "cvss": 7.5, "active": True}
result = _row_to_dict(row)
assert result["title"] == "Test"
assert result["cvss"] == 7.5
assert result["active"] is True
# =============================================================================
# Helper Tests — _get_tenant_id
# =============================================================================
class TestGetTenantId:
def test_valid_uuid_returned(self):
result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT)
assert result == DEFAULT_TENANT
def test_none_returns_default(self):
result = _get_tenant_id(x_tenant_id=None)
assert result == DEFAULT_TENANT_ID
def test_invalid_uuid_returns_default(self):
result = _get_tenant_id(x_tenant_id="not-a-uuid")
assert result == DEFAULT_TENANT_ID
def test_empty_string_returns_default(self):
result = _get_tenant_id(x_tenant_id="")
assert result == DEFAULT_TENANT_ID
def test_different_valid_tenant(self):
result = _get_tenant_id(x_tenant_id=OTHER_TENANT)
assert result == OTHER_TENANT
def test_partial_uuid_returns_default(self):
result = _get_tenant_id(x_tenant_id="9282a473-5c95-4b3a")
assert result == DEFAULT_TENANT_ID
# =============================================================================
# HTTP Tests — GET /security-backlog
# =============================================================================
class TestListSecurityItems:
def test_empty_list(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
mock_db.execute.return_value.fetchone.return_value = count_row
mock_db.execute.return_value.fetchall.return_value = []
response = client.get("/security-backlog")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_with_data(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 2
item1 = make_item_row()
item2 = make_item_row({"id": "aaaaaaaa-0002-0002-0002-000000000002", "title": "Second Item"})
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [item1, item2]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
assert data["total"] == 2
assert data["items"][0]["title"] == "Test Item"
def test_filter_by_status(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_item_row({"status": "resolved"})]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog?status=resolved")
assert response.status_code == 200
data = response.json()
assert data["items"][0]["status"] == "resolved"
def test_filter_by_severity(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_item_row({"severity": "critical"})]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog?severity=critical")
assert response.status_code == 200
data = response.json()
assert data["items"][0]["severity"] == "critical"
def test_filter_by_type(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_item_row({"type": "misconfiguration"})]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog?type=misconfiguration")
assert response.status_code == 200
data = response.json()
assert data["items"][0]["type"] == "misconfiguration"
def test_filter_by_search(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 1
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_item_row({"title": "SQL Injection"})]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog?search=SQL")
assert response.status_code == 200
data = response.json()
assert "SQL" in data["items"][0]["title"]
def test_pagination_limit_offset(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 10
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = [make_item_row()]
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog?limit=1&offset=5")
assert response.status_code == 200
data = response.json()
assert data["total"] == 10
assert len(data["items"]) == 1
def test_default_tenant_used_without_header(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = []
mock_db.execute.return_value = execute_result
response = client.get("/security-backlog")
assert response.status_code == 200
# Verify execute was called (tenant was resolved internally)
assert mock_db.execute.called
# =============================================================================
# HTTP Tests — GET /security-backlog/stats
# =============================================================================
class TestGetSecurityStats:
def test_stats_all_zeros_empty(self, mock_db):
zero_row = MagicMock()
zero_row._mapping = {
"open": 0, "in_progress": 0, "resolved": 0, "accepted_risk": 0,
"critical": 0, "high": 0, "overdue": 0, "total": 0,
}
mock_db.execute.return_value.fetchone.return_value = zero_row
response = client.get("/security-backlog/stats")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["open"] == 0
assert data["critical"] == 0
assert data["overdue"] == 0
def test_stats_with_data(self, mock_db):
stats_row = make_stats_row()
mock_db.execute.return_value.fetchone.return_value = stats_row
response = client.get("/security-backlog/stats")
assert response.status_code == 200
data = response.json()
assert data["total"] == 6
assert data["open"] == 3
assert data["critical"] == 1
assert data["high"] == 2
assert data["overdue"] == 1
assert data["in_progress"] == 1
assert data["resolved"] == 2
assert data["accepted_risk"] == 0
def test_stats_none_values_become_zero(self, mock_db):
none_row = MagicMock()
none_row._mapping = {
"open": None, "in_progress": None, "resolved": None, "accepted_risk": None,
"critical": None, "high": None, "overdue": None, "total": None,
}
mock_db.execute.return_value.fetchone.return_value = none_row
response = client.get("/security-backlog/stats")
assert response.status_code == 200
data = response.json()
for key in ("open", "in_progress", "resolved", "accepted_risk", "critical", "high", "overdue", "total"):
assert data[key] == 0
def test_stats_with_tenant_header(self, mock_db):
stats_row = make_stats_row({"total": 1, "open": 1})
mock_db.execute.return_value.fetchone.return_value = stats_row
response = client.get(
"/security-backlog/stats",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 200
assert response.json()["total"] == 1
# =============================================================================
# HTTP Tests — POST /security-backlog
# =============================================================================
class TestCreateSecurityItem:
def test_create_success(self, mock_db):
created_row = make_item_row({"title": "New Vulnerability"})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/security-backlog",
json={"title": "New Vulnerability"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Vulnerability"
assert data["id"] == ITEM_ID
mock_db.commit.assert_called_once()
def test_create_full_item(self, mock_db):
created_row = make_item_row({
"title": "CVE-2024-9999",
"type": "cve",
"severity": "critical",
"status": "open",
"cve": "CVE-2024-9999",
"cvss": 9.8,
})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post("/security-backlog", json={
"title": "CVE-2024-9999",
"type": "cve",
"severity": "critical",
"cve": "CVE-2024-9999",
"cvss": 9.8,
})
assert response.status_code == 201
data = response.json()
assert data["severity"] == "critical"
assert data["cvss"] == 9.8
def test_create_missing_title_fails(self, mock_db):
response = client.post("/security-backlog", json={"severity": "high"})
assert response.status_code == 422
def test_create_with_tenant_header(self, mock_db):
created_row = make_item_row({"tenant_id": OTHER_TENANT})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/security-backlog",
json={"title": "Tenant-specific item"},
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 201
assert mock_db.commit.called
def test_create_default_type_and_severity(self, mock_db):
created_row = make_item_row()
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post("/security-backlog", json={"title": "Basic item"})
assert response.status_code == 201
data = response.json()
assert data["type"] == "vulnerability"
assert data["severity"] == "medium"
assert data["status"] == "open"
# =============================================================================
# HTTP Tests — PUT /security-backlog/{id}
# =============================================================================
class TestUpdateSecurityItem:
def test_update_success(self, mock_db):
updated_row = make_item_row({"status": "resolved", "severity": "low"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/security-backlog/{ITEM_ID}",
json={"status": "resolved", "severity": "low"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "resolved"
assert data["severity"] == "low"
mock_db.commit.assert_called_once()
def test_update_partial_title_only(self, mock_db):
updated_row = make_item_row({"title": "Updated Title"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/security-backlog/{ITEM_ID}",
json={"title": "Updated Title"},
)
assert response.status_code == 200
assert response.json()["title"] == "Updated Title"
def test_update_not_found_returns_404(self, mock_db):
mock_db.execute.return_value.fetchone.return_value = None
response = client.put(
"/security-backlog/nonexistent-id",
json={"status": "resolved"},
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_update_no_fields_returns_400(self, mock_db):
response = client.put(f"/security-backlog/{ITEM_ID}", json={})
assert response.status_code == 400
def test_update_with_tenant_header(self, mock_db):
updated_row = make_item_row({"tenant_id": OTHER_TENANT, "status": "in-progress"})
mock_db.execute.return_value.fetchone.return_value = updated_row
response = client.put(
f"/security-backlog/{ITEM_ID}",
json={"status": "in-progress"},
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 200
assert response.json()["status"] == "in-progress"
# =============================================================================
# HTTP Tests — DELETE /security-backlog/{id}
# =============================================================================
class TestDeleteSecurityItem:
def test_delete_success_204(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(f"/security-backlog/{ITEM_ID}")
assert response.status_code == 204
mock_db.commit.assert_called_once()
def test_delete_not_found_returns_404(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 0
mock_db.execute.return_value = delete_result
response = client.delete("/security-backlog/nonexistent-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_delete_with_tenant_header(self, mock_db):
delete_result = MagicMock()
delete_result.rowcount = 1
mock_db.execute.return_value = delete_result
response = client.delete(
f"/security-backlog/{ITEM_ID}",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 204
assert mock_db.commit.called
# =============================================================================
# Tenant Isolation Tests
# =============================================================================
class TestTenantIsolation:
def test_different_tenant_sees_different_items(self, mock_db):
"""Tenant A has 3 items, Tenant B has 0 — each sees only their own data."""
call_count = 0
def side_effect(query, params):
nonlocal call_count
result = MagicMock()
tid = params.get("tenant_id", "")
if tid == DEFAULT_TENANT:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 3
result.fetchone.return_value = count_row
result.fetchall.return_value = [
make_item_row(),
make_item_row({"id": "aaaaaaaa-0002-0002-0002-000000000002"}),
make_item_row({"id": "aaaaaaaa-0003-0003-0003-000000000003"}),
]
else:
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
result.fetchone.return_value = count_row
result.fetchall.return_value = []
return result
mock_db.execute.side_effect = side_effect
resp_a = client.get(
"/security-backlog",
headers={"X-Tenant-Id": DEFAULT_TENANT},
)
assert resp_a.status_code == 200
assert resp_a.json()["total"] == 3
resp_b = client.get(
"/security-backlog",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert resp_b.status_code == 200
assert resp_b.json()["total"] == 0
def test_invalid_tenant_header_falls_back_to_default(self, mock_db):
count_row = MagicMock()
count_row.__getitem__ = lambda self, i: 0
execute_result = MagicMock()
execute_result.fetchone.return_value = count_row
execute_result.fetchall.return_value = []
mock_db.execute.return_value = execute_result
response = client.get(
"/security-backlog",
headers={"X-Tenant-Id": "not-a-real-uuid"},
)
assert response.status_code == 200
# Should succeed (falls back to DEFAULT_TENANT_ID)
assert "items" in response.json()
def test_create_uses_tenant_from_header(self, mock_db):
created_row = make_item_row({"tenant_id": OTHER_TENANT})
mock_db.execute.return_value.fetchone.return_value = created_row
response = client.post(
"/security-backlog",
json={"title": "Tenant B item"},
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 201
assert mock_db.commit.called
def test_delete_tenant_isolation_not_found_for_wrong_tenant(self, mock_db):
"""Deleting an item that belongs to a different tenant returns 404."""
delete_result = MagicMock()
delete_result.rowcount = 0 # No rows deleted (item belongs to other tenant)
mock_db.execute.return_value = delete_result
response = client.delete(
f"/security-backlog/{ITEM_ID}",
headers={"X-Tenant-Id": OTHER_TENANT},
)
assert response.status_code == 404