A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""
|
|
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
|