""" Tests for Klausur API Routes. Verifies: - API endpoint behavior - Request validation - Response format - Privacy guarantees at API level """ import pytest from unittest.mock import MagicMock, patch, AsyncMock from fastapi.testclient import TestClient from fastapi import FastAPI from klausur.routes import router from klausur.db_models import SessionStatus, DocumentStatus @pytest.fixture def app(): """Create test FastAPI app.""" app = FastAPI() app.include_router(router, prefix="/api") return app @pytest.fixture def client(app): """Create test client.""" return TestClient(app) class TestSessionEndpoints: """Tests for session-related endpoints.""" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_create_session_returns_201(self, mock_get_db, mock_repo_class, client): """Creating a session should return 201.""" # Setup mocks mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_session.id = "session-123" mock_session.name = "Test Klausur" mock_session.subject = "Mathe" mock_session.class_name = "10a" mock_session.total_points = 100 mock_session.status = SessionStatus.CREATED mock_session.document_count = 0 mock_session.processed_count = 0 mock_session.created_at = "2024-01-15T10:00:00" mock_session.completed_at = None mock_session.retention_until = "2024-02-15T10:00:00" mock_repo.create_session.return_value = mock_session response = client.post("/api/klausur/sessions", json={ "name": "Test Klausur", "subject": "Mathe", "class_name": "10a" }) assert response.status_code == 201 data = response.json() assert data["name"] == "Test Klausur" assert data["status"] == "created" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_create_session_validates_name(self, mock_get_db, mock_repo_class, client): """Session name is required and must not be empty.""" response = client.post("/api/klausur/sessions", json={ "name": "", # Empty name "subject": "Mathe" }) assert response.status_code == 422 # Validation error @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_list_sessions_returns_array(self, mock_get_db, mock_repo_class, client): """Listing sessions should return an array.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_repo.list_sessions.return_value = [] response = client.get("/api/klausur/sessions") assert response.status_code == 200 data = response.json() assert "sessions" in data assert isinstance(data["sessions"], list) @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_get_session_404_when_not_found(self, mock_get_db, mock_repo_class, client): """Getting non-existent session should return 404.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_repo.get_session.return_value = None response = client.get("/api/klausur/sessions/nonexistent-123") assert response.status_code == 404 class TestQREndpoints: """Tests for QR code generation endpoints.""" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_pseudonymizer') @patch('klausur.routes.get_db') def test_generate_qr_batch_creates_tokens( self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client ): """QR batch generation should create correct number of tokens.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_repo.get_session.return_value = mock_session mock_batch = MagicMock() mock_batch.id = "batch-123" mock_batch.student_count = 5 mock_repo.create_qr_batch.return_value = mock_batch mock_pseudonymizer = MagicMock() mock_pseudonymizer.generate_batch_tokens.return_value = [ "token-1", "token-2", "token-3", "token-4", "token-5" ] mock_get_pseudonymizer.return_value = mock_pseudonymizer response = client.post("/api/klausur/sessions/session-123/qr-batch", json={ "student_count": 5 }) assert response.status_code == 200 data = response.json() assert len(data["generated_tokens"]) == 5 @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_qr_batch_validates_student_count(self, mock_get_db, mock_repo_class, client): """Student count must be within valid range.""" # Too many students response = client.post("/api/klausur/sessions/session-123/qr-batch", json={ "student_count": 200 # Max is 100 }) assert response.status_code == 422 class TestUploadEndpoints: """Tests for document upload endpoints.""" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_pseudonymizer') @patch('klausur.routes.get_db') def test_upload_applies_redaction_by_default( self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client ): """Upload should apply header redaction by default.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_repo.get_session.return_value = mock_session mock_doc = MagicMock() mock_doc.doc_token = "doc-token-123" mock_doc.session_id = "session-123" mock_doc.status = DocumentStatus.UPLOADED mock_doc.page_number = 1 mock_doc.total_pages = 1 mock_doc.ocr_confidence = 0 mock_doc.ai_score = None mock_doc.ai_grade = None mock_doc.ai_feedback = None mock_doc.created_at = "2024-01-15T10:00:00" mock_doc.processing_completed_at = None mock_repo.create_document.return_value = mock_doc mock_pseudonymizer = MagicMock() mock_pseudonymizer.detect_qr_code.return_value = MagicMock(doc_token=None) mock_pseudonymizer.generate_doc_token.return_value = "doc-token-123" mock_pseudonymizer.smart_redact_header.return_value = MagicMock( redaction_applied=True, redacted_image=b"redacted", redacted_height=300 ) mock_get_pseudonymizer.return_value = mock_pseudonymizer # Create a minimal file upload response = client.post( "/api/klausur/sessions/session-123/upload", files={"file": ("test.png", b"fake image data", "image/png")} ) # Verify redaction was called mock_pseudonymizer.smart_redact_header.assert_called_once() class TestResultsEndpoints: """Tests for results endpoints.""" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_results_only_return_pseudonymized_data( self, mock_get_db, mock_repo_class, client ): """Results should only contain doc_tokens, not names.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_session.total_points = 100 mock_repo.get_session.return_value = mock_session mock_doc = MagicMock() mock_doc.doc_token = "anonymous-token-123" mock_doc.status = DocumentStatus.COMPLETED mock_doc.ai_score = 85 mock_doc.ai_grade = "2+" mock_doc.ai_feedback = "Good work" mock_doc.ai_details = {} mock_repo.list_documents.return_value = [mock_doc] response = client.get("/api/klausur/sessions/session-123/results") assert response.status_code == 200 data = response.json() # Results should use doc_token, not student name assert len(data) == 1 assert "doc_token" in data[0] assert "student_name" not in data[0] assert "name" not in data[0] class TestIdentityMapEndpoints: """Tests for identity map (vault) endpoints.""" @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_store_identity_map_accepts_encrypted_data( self, mock_get_db, mock_repo_class, client ): """Identity map endpoint should accept encrypted data.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_repo.update_session_identity_map.return_value = mock_session # Base64 encoded "encrypted" data import base64 encrypted = base64.b64encode(b"encrypted identity map").decode() response = client.post("/api/klausur/sessions/session-123/identity-map", json={ "encrypted_data": encrypted, "iv": "base64iv==" }) assert response.status_code == 204 @patch('klausur.routes.KlausurRepository') @patch('klausur.routes.get_db') def test_get_identity_map_returns_encrypted_blob( self, mock_get_db, mock_repo_class, client ): """Getting identity map should return encrypted blob.""" mock_db = MagicMock() mock_get_db.return_value = iter([mock_db]) mock_repo = MagicMock() mock_repo_class.return_value = mock_repo mock_session = MagicMock() mock_session.encrypted_identity_map = b"encrypted data" mock_session.identity_map_iv = "ivvalue" mock_repo.get_session.return_value = mock_session response = client.get("/api/klausur/sessions/session-123/identity-map") assert response.status_code == 200 data = response.json() assert "encrypted_data" in data assert "iv" in data class TestPrivacyAtAPILevel: """Tests verifying privacy guarantees at API level.""" def test_no_student_names_in_any_response_schema(self): """Verify response schemas don't include student names.""" from klausur.routes import ( SessionResponse, DocumentResponse, CorrectionResultResponse ) # Check all response model fields session_fields = SessionResponse.model_fields.keys() doc_fields = DocumentResponse.model_fields.keys() result_fields = CorrectionResultResponse.model_fields.keys() all_fields = list(session_fields) + list(doc_fields) + list(result_fields) # Should not contain student-name-related fields # Note: "name" alone is allowed (e.g., session/exam name like "Mathe Klausur") forbidden = ["student_name", "schueler_name", "student", "pupil", "schueler"] for field in all_fields: assert field.lower() not in forbidden, f"Field '{field}' may contain PII" def test_identity_map_request_requires_encryption(self): """Identity map must be encrypted before storage.""" from klausur.routes import IdentityMapUpdate # Check that schema requires encrypted_data, not plain names fields = IdentityMapUpdate.model_fields.keys() assert "encrypted_data" in fields assert "names" not in fields assert "student_names" not in fields