Files
breakpilot-lehrer/klausur-service/backend/tests/test_byoeh.py
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

938 lines
32 KiB
Python

"""
Unit Tests for BYOEH (Bring-Your-Own-Expectation-Horizon) Module
Tests cover:
- EH upload and storage
- Key sharing system
- Invitation flow (Invite, Accept, Decline, Revoke)
- Klausur linking
- RAG query functionality
- Audit logging
"""
import pytest
import json
import uuid
import hashlib
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
# Import the main app and data structures (post-refactoring modular imports)
import sys
sys.path.insert(0, '..')
from main import app
from storage import (
eh_db,
eh_key_shares_db,
eh_klausur_links_db,
eh_audit_db,
eh_invitations_db,
klausuren_db,
)
from models.eh import (
Erwartungshorizont,
EHKeyShare,
EHKlausurLink,
EHShareInvitation,
)
from models.exam import Klausur
from models.enums import KlausurModus
# =============================================
# FIXTURES
# =============================================
@pytest.fixture
def client():
"""Test client for FastAPI app."""
return TestClient(app)
@pytest.fixture
def auth_headers():
"""JWT auth headers for teacher user."""
import jwt
token = jwt.encode(
{
"user_id": "test-teacher-001",
"email": "teacher@school.de",
"role": "admin",
"tenant_id": "school-001"
},
"your-super-secret-jwt-key-change-in-production",
algorithm="HS256"
)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def second_examiner_headers():
"""JWT auth headers for second examiner (non-admin teacher)."""
import jwt
token = jwt.encode(
{
"user_id": "test-examiner-002",
"email": "examiner2@school.de",
"role": "teacher", # Non-admin to test access control
"tenant_id": "school-001"
},
"your-super-secret-jwt-key-change-in-production",
algorithm="HS256"
)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def sample_eh():
"""Create a sample Erwartungshorizont."""
eh_id = str(uuid.uuid4())
eh = Erwartungshorizont(
id=eh_id,
tenant_id="school-001",
teacher_id="test-teacher-001",
title="Deutsch LK Abitur 2025",
subject="deutsch",
niveau="eA",
year=2025,
aufgaben_nummer="Aufgabe 1",
encryption_key_hash="abc123" + "0" * 58, # 64 char hash
salt="def456" * 5 + "0" * 2, # 32 char salt
encrypted_file_path=f"/app/eh-uploads/school-001/{eh_id}/encrypted.bin",
file_size_bytes=1024000,
original_filename="erwartungshorizont.pdf",
rights_confirmed=True,
rights_confirmed_at=datetime.now(timezone.utc),
status="indexed",
chunk_count=10,
indexed_at=datetime.now(timezone.utc),
error_message=None,
training_allowed=False,
created_at=datetime.now(timezone.utc),
deleted_at=None
)
eh_db[eh_id] = eh
return eh
@pytest.fixture
def sample_klausur():
"""Create a sample Klausur."""
klausur_id = str(uuid.uuid4())
klausur = Klausur(
id=klausur_id,
title="Deutsch LK Q1",
subject="deutsch",
modus=KlausurModus.VORABITUR,
class_id="class-001",
year=2025,
semester="Q1",
erwartungshorizont=None,
students=[],
created_at=datetime.now(timezone.utc),
teacher_id="test-teacher-001"
)
klausuren_db[klausur_id] = klausur
return klausur
@pytest.fixture(autouse=True)
def cleanup():
"""Clean up databases after each test."""
yield
eh_db.clear()
eh_key_shares_db.clear()
eh_klausur_links_db.clear()
eh_audit_db.clear()
eh_invitations_db.clear()
klausuren_db.clear()
@pytest.fixture
def sample_invitation(sample_eh):
"""Create a sample invitation."""
invitation_id = str(uuid.uuid4())
invitation = EHShareInvitation(
id=invitation_id,
eh_id=sample_eh.id,
inviter_id="test-teacher-001",
invitee_id="",
invitee_email="examiner2@school.de",
role="second_examiner",
klausur_id=None,
message="Bitte EH fuer Zweitkorrektur nutzen",
status="pending",
expires_at=datetime.now(timezone.utc) + timedelta(days=14),
created_at=datetime.now(timezone.utc),
accepted_at=None,
declined_at=None
)
eh_invitations_db[invitation_id] = invitation
return invitation
# =============================================
# EH CRUD TESTS
# =============================================
class TestEHList:
"""Tests for GET /api/v1/eh"""
def test_list_empty(self, client, auth_headers):
"""List returns empty when no EH exist."""
response = client.get("/api/v1/eh", headers=auth_headers)
assert response.status_code == 200
assert response.json() == []
def test_list_own_eh(self, client, auth_headers, sample_eh):
"""List returns only user's own EH."""
response = client.get("/api/v1/eh", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["id"] == sample_eh.id
assert data[0]["title"] == sample_eh.title
def test_list_filter_by_subject(self, client, auth_headers, sample_eh):
"""List can filter by subject."""
response = client.get("/api/v1/eh?subject=deutsch", headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) == 1
response = client.get("/api/v1/eh?subject=englisch", headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) == 0
def test_list_filter_by_year(self, client, auth_headers, sample_eh):
"""List can filter by year."""
response = client.get("/api/v1/eh?year=2025", headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) == 1
response = client.get("/api/v1/eh?year=2024", headers=auth_headers)
assert response.status_code == 200
assert len(response.json()) == 0
class TestEHGet:
"""Tests for GET /api/v1/eh/{id}"""
def test_get_existing_eh(self, client, auth_headers, sample_eh):
"""Get returns EH details."""
response = client.get(f"/api/v1/eh/{sample_eh.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == sample_eh.id
assert data["title"] == sample_eh.title
assert data["subject"] == sample_eh.subject
def test_get_nonexistent_eh(self, client, auth_headers):
"""Get returns 404 for non-existent EH."""
response = client.get(f"/api/v1/eh/{uuid.uuid4()}", headers=auth_headers)
assert response.status_code == 404
class TestEHDelete:
"""Tests for DELETE /api/v1/eh/{id}"""
def test_delete_own_eh(self, client, auth_headers, sample_eh):
"""Owner can delete their EH."""
response = client.delete(f"/api/v1/eh/{sample_eh.id}", headers=auth_headers)
assert response.status_code == 200
assert response.json()["status"] == "deleted"
# Verify soft delete
assert eh_db[sample_eh.id].deleted_at is not None
def test_delete_others_eh(self, client, second_examiner_headers, sample_eh):
"""Non-owner cannot delete EH."""
response = client.delete(f"/api/v1/eh/{sample_eh.id}", headers=second_examiner_headers)
assert response.status_code == 403
def test_delete_nonexistent_eh(self, client, auth_headers):
"""Delete returns 404 for non-existent EH."""
response = client.delete(f"/api/v1/eh/{uuid.uuid4()}", headers=auth_headers)
assert response.status_code == 404
# =============================================
# KEY SHARING TESTS
# =============================================
class TestEHSharing:
"""Tests for EH key sharing system."""
def test_share_eh_with_examiner(self, client, auth_headers, sample_eh):
"""Owner can share EH with another examiner."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=auth_headers,
json={
"user_id": "test-examiner-002",
"role": "second_examiner",
"encrypted_passphrase": "encrypted-secret-123",
"passphrase_hint": "Das uebliche Passwort"
}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "shared"
assert data["shared_with"] == "test-examiner-002"
assert data["role"] == "second_examiner"
def test_share_invalid_role(self, client, auth_headers, sample_eh):
"""Sharing with invalid role fails."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=auth_headers,
json={
"user_id": "test-examiner-002",
"role": "invalid_role",
"encrypted_passphrase": "encrypted-secret-123"
}
)
assert response.status_code == 400
def test_share_others_eh_fails(self, client, second_examiner_headers, sample_eh):
"""Non-owner cannot share EH."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=second_examiner_headers,
json={
"user_id": "test-examiner-003",
"role": "third_examiner",
"encrypted_passphrase": "encrypted-secret-123"
}
)
assert response.status_code == 403
def test_list_shares(self, client, auth_headers, sample_eh):
"""Owner can list shares."""
# Create a share first
share = EHKeyShare(
id=str(uuid.uuid4()),
eh_id=sample_eh.id,
user_id="test-examiner-002",
encrypted_passphrase="encrypted",
passphrase_hint="hint",
granted_by="test-teacher-001",
granted_at=datetime.now(timezone.utc),
role="second_examiner",
klausur_id=None,
active=True
)
eh_key_shares_db[sample_eh.id] = [share]
response = client.get(f"/api/v1/eh/{sample_eh.id}/shares", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["user_id"] == "test-examiner-002"
def test_revoke_share(self, client, auth_headers, sample_eh):
"""Owner can revoke a share."""
share_id = str(uuid.uuid4())
share = EHKeyShare(
id=share_id,
eh_id=sample_eh.id,
user_id="test-examiner-002",
encrypted_passphrase="encrypted",
passphrase_hint="hint",
granted_by="test-teacher-001",
granted_at=datetime.now(timezone.utc),
role="second_examiner",
klausur_id=None,
active=True
)
eh_key_shares_db[sample_eh.id] = [share]
response = client.delete(
f"/api/v1/eh/{sample_eh.id}/shares/{share_id}",
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["status"] == "revoked"
# Verify share is inactive
assert not eh_key_shares_db[sample_eh.id][0].active
def test_get_shared_with_me(self, client, second_examiner_headers, sample_eh):
"""User can see EH shared with them."""
share = EHKeyShare(
id=str(uuid.uuid4()),
eh_id=sample_eh.id,
user_id="test-examiner-002",
encrypted_passphrase="encrypted",
passphrase_hint="hint",
granted_by="test-teacher-001",
granted_at=datetime.now(timezone.utc),
role="second_examiner",
klausur_id=None,
active=True
)
eh_key_shares_db[sample_eh.id] = [share]
response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["eh"]["id"] == sample_eh.id
# =============================================
# KLAUSUR LINKING TESTS
# =============================================
class TestEHKlausurLinking:
"""Tests for EH-Klausur linking."""
def test_link_eh_to_klausur(self, client, auth_headers, sample_eh, sample_klausur):
"""Owner can link EH to Klausur."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/link-klausur",
headers=auth_headers,
json={"klausur_id": sample_klausur.id}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "linked"
assert data["eh_id"] == sample_eh.id
assert data["klausur_id"] == sample_klausur.id
def test_get_linked_eh(self, client, auth_headers, sample_eh, sample_klausur):
"""Get linked EH for a Klausur."""
link = EHKlausurLink(
id=str(uuid.uuid4()),
eh_id=sample_eh.id,
klausur_id=sample_klausur.id,
linked_by="test-teacher-001",
linked_at=datetime.now(timezone.utc)
)
eh_klausur_links_db[sample_klausur.id] = [link]
response = client.get(
f"/api/v1/klausuren/{sample_klausur.id}/linked-eh",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["eh"]["id"] == sample_eh.id
assert data[0]["is_owner"] is True
def test_unlink_eh_from_klausur(self, client, auth_headers, sample_eh, sample_klausur):
"""Owner can unlink EH from Klausur."""
link = EHKlausurLink(
id=str(uuid.uuid4()),
eh_id=sample_eh.id,
klausur_id=sample_klausur.id,
linked_by="test-teacher-001",
linked_at=datetime.now(timezone.utc)
)
eh_klausur_links_db[sample_klausur.id] = [link]
response = client.delete(
f"/api/v1/eh/{sample_eh.id}/link-klausur/{sample_klausur.id}",
headers=auth_headers
)
assert response.status_code == 200
assert response.json()["status"] == "unlinked"
# =============================================
# AUDIT LOG TESTS
# =============================================
class TestAuditLog:
"""Tests for audit logging."""
def test_audit_log_on_share(self, client, auth_headers, sample_eh):
"""Sharing creates audit log entry."""
initial_count = len(eh_audit_db)
client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=auth_headers,
json={
"user_id": "test-examiner-002",
"role": "second_examiner",
"encrypted_passphrase": "encrypted-secret-123"
}
)
assert len(eh_audit_db) > initial_count
latest = eh_audit_db[-1]
assert latest.action == "share"
assert latest.eh_id == sample_eh.id
def test_get_audit_log(self, client, auth_headers, sample_eh):
"""Can retrieve audit log."""
# Create some audit entries by sharing
client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=auth_headers,
json={
"user_id": "test-examiner-002",
"role": "second_examiner",
"encrypted_passphrase": "encrypted-secret-123"
}
)
response = client.get(
f"/api/v1/eh/audit-log?eh_id={sample_eh.id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) > 0
# =============================================
# RIGHTS TEXT TESTS
# =============================================
class TestRightsText:
"""Tests for rights confirmation text."""
def test_get_rights_text(self, client, auth_headers):
"""Can retrieve rights confirmation text."""
response = client.get("/api/v1/eh/rights-text", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "text" in data
assert "version" in data
assert "Urheberrecht" in data["text"]
# =============================================
# ENCRYPTION SERVICE TESTS
# =============================================
class TestEncryptionUtils:
"""Tests for encryption utilities (eh_pipeline.py)."""
def test_hash_key(self):
"""Key hashing produces consistent results."""
from eh_pipeline import hash_key
import os
passphrase = "test-secret-passphrase"
salt_hex = os.urandom(16).hex()
hash1 = hash_key(passphrase, salt_hex)
hash2 = hash_key(passphrase, salt_hex)
assert hash1 == hash2
assert len(hash1) == 64 # SHA-256 hex
def test_verify_key_hash(self):
"""Key hash verification works correctly."""
from eh_pipeline import hash_key, verify_key_hash
import os
passphrase = "test-secret-passphrase"
salt_hex = os.urandom(16).hex()
key_hash = hash_key(passphrase, salt_hex)
assert verify_key_hash(passphrase, salt_hex, key_hash) is True
assert verify_key_hash("wrong-passphrase", salt_hex, key_hash) is False
def test_chunk_text(self):
"""Text chunking produces correct overlap."""
from eh_pipeline import chunk_text
text = "A" * 2000 # 2000 characters
chunks = chunk_text(text, chunk_size=1000, overlap=200)
assert len(chunks) >= 2
# Check overlap
assert chunks[0][-200:] == chunks[1][:200]
def test_encrypt_decrypt_text(self):
"""Text encryption and decryption round-trip."""
from eh_pipeline import encrypt_text, decrypt_text
plaintext = "Dies ist ein geheimer Text."
passphrase = "geheim123"
salt = "a" * 32 # 32 hex chars = 16 bytes
encrypted = encrypt_text(plaintext, passphrase, salt)
decrypted = decrypt_text(encrypted, passphrase, salt)
assert decrypted == plaintext
# =============================================
# INVITATION FLOW TESTS
# =============================================
class TestEHInvitationFlow:
"""Tests for the Invite/Accept/Decline/Revoke workflow."""
def test_invite_to_eh(self, client, auth_headers, sample_eh):
"""Owner can send invitation to share EH."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "zweitkorrektor@school.de",
"role": "second_examiner",
"message": "Bitte EH fuer Zweitkorrektur nutzen",
"expires_in_days": 14
}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "invited"
assert data["invitee_email"] == "zweitkorrektor@school.de"
assert data["role"] == "second_examiner"
assert "invitation_id" in data
assert "expires_at" in data
def test_invite_invalid_role(self, client, auth_headers, sample_eh):
"""Invitation with invalid role fails."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "zweitkorrektor@school.de",
"role": "invalid_role"
}
)
assert response.status_code == 400
def test_invite_by_non_owner_fails(self, client, second_examiner_headers, sample_eh):
"""Non-owner cannot send invitation."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=second_examiner_headers,
json={
"invitee_email": "drittkorrektor@school.de",
"role": "third_examiner"
}
)
assert response.status_code == 403
def test_duplicate_pending_invitation_fails(self, client, auth_headers, sample_eh, sample_invitation):
"""Cannot send duplicate pending invitation to same user."""
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "examiner2@school.de", # Same email as sample_invitation
"role": "second_examiner"
}
)
assert response.status_code == 409 # Conflict
def test_list_pending_invitations(self, client, second_examiner_headers, sample_eh, sample_invitation):
"""User can see pending invitations addressed to them."""
response = client.get("/api/v1/eh/invitations/pending", headers=second_examiner_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["invitation"]["id"] == sample_invitation.id
assert data[0]["eh"]["title"] == sample_eh.title
def test_list_sent_invitations(self, client, auth_headers, sample_eh, sample_invitation):
"""Inviter can see sent invitations."""
response = client.get("/api/v1/eh/invitations/sent", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["invitation"]["id"] == sample_invitation.id
def test_accept_invitation(self, client, second_examiner_headers, sample_eh, sample_invitation):
"""Invitee can accept invitation and get access."""
response = client.post(
f"/api/v1/eh/invitations/{sample_invitation.id}/accept",
headers=second_examiner_headers,
json={"encrypted_passphrase": "encrypted-secret-key-for-zk"}
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "accepted"
assert data["eh_id"] == sample_eh.id
assert "share_id" in data
# Verify invitation status updated
assert eh_invitations_db[sample_invitation.id].status == "accepted"
# Verify key share created
assert sample_eh.id in eh_key_shares_db
assert len(eh_key_shares_db[sample_eh.id]) == 1
def test_accept_invitation_wrong_user(self, client, auth_headers, sample_eh, sample_invitation):
"""Only invitee can accept invitation."""
# auth_headers is for teacher, not the invitee
response = client.post(
f"/api/v1/eh/invitations/{sample_invitation.id}/accept",
headers=auth_headers,
json={"encrypted_passphrase": "encrypted-secret"}
)
assert response.status_code == 403
def test_accept_expired_invitation(self, client, second_examiner_headers, sample_eh):
"""Cannot accept expired invitation."""
# Create expired invitation
invitation_id = str(uuid.uuid4())
expired_invitation = EHShareInvitation(
id=invitation_id,
eh_id=sample_eh.id,
inviter_id="test-teacher-001",
invitee_id="",
invitee_email="examiner2@school.de",
role="second_examiner",
klausur_id=None,
message=None,
status="pending",
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # Expired
created_at=datetime.now(timezone.utc) - timedelta(days=15),
accepted_at=None,
declined_at=None
)
eh_invitations_db[invitation_id] = expired_invitation
response = client.post(
f"/api/v1/eh/invitations/{invitation_id}/accept",
headers=second_examiner_headers,
json={"encrypted_passphrase": "encrypted-secret"}
)
assert response.status_code == 400
assert "expired" in response.json()["detail"].lower()
def test_decline_invitation(self, client, second_examiner_headers, sample_invitation):
"""Invitee can decline invitation."""
response = client.post(
f"/api/v1/eh/invitations/{sample_invitation.id}/decline",
headers=second_examiner_headers
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "declined"
# Verify status updated
assert eh_invitations_db[sample_invitation.id].status == "declined"
def test_decline_invitation_wrong_user(self, client, auth_headers, sample_invitation):
"""Only invitee can decline invitation."""
response = client.post(
f"/api/v1/eh/invitations/{sample_invitation.id}/decline",
headers=auth_headers
)
assert response.status_code == 403
def test_revoke_invitation(self, client, auth_headers, sample_invitation):
"""Inviter can revoke pending invitation."""
response = client.delete(
f"/api/v1/eh/invitations/{sample_invitation.id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "revoked"
# Verify status updated
assert eh_invitations_db[sample_invitation.id].status == "revoked"
def test_revoke_invitation_wrong_user(self, client, second_examiner_headers, sample_invitation):
"""Only inviter can revoke invitation."""
response = client.delete(
f"/api/v1/eh/invitations/{sample_invitation.id}",
headers=second_examiner_headers
)
assert response.status_code == 403
def test_revoke_non_pending_invitation(self, client, auth_headers, sample_eh):
"""Cannot revoke already accepted invitation."""
invitation_id = str(uuid.uuid4())
accepted_invitation = EHShareInvitation(
id=invitation_id,
eh_id=sample_eh.id,
inviter_id="test-teacher-001",
invitee_id="test-examiner-002",
invitee_email="examiner2@school.de",
role="second_examiner",
klausur_id=None,
message=None,
status="accepted",
expires_at=datetime.now(timezone.utc) + timedelta(days=14),
created_at=datetime.now(timezone.utc),
accepted_at=datetime.now(timezone.utc),
declined_at=None
)
eh_invitations_db[invitation_id] = accepted_invitation
response = client.delete(
f"/api/v1/eh/invitations/{invitation_id}",
headers=auth_headers
)
assert response.status_code == 400
def test_get_access_chain(self, client, auth_headers, sample_eh, sample_invitation):
"""Owner can see complete access chain."""
# Add a key share
share = EHKeyShare(
id=str(uuid.uuid4()),
eh_id=sample_eh.id,
user_id="test-examiner-003",
encrypted_passphrase="encrypted",
passphrase_hint="",
granted_by="test-teacher-001",
granted_at=datetime.now(timezone.utc),
role="third_examiner",
klausur_id=None,
active=True
)
eh_key_shares_db[sample_eh.id] = [share]
response = client.get(f"/api/v1/eh/{sample_eh.id}/access-chain", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["eh_id"] == sample_eh.id
assert data["owner"]["user_id"] == "test-teacher-001"
assert len(data["active_shares"]) == 1
assert len(data["pending_invitations"]) == 1 # sample_invitation
class TestInvitationWorkflow:
"""Integration tests for complete invitation workflow."""
def test_complete_invite_accept_workflow(
self, client, auth_headers, second_examiner_headers, sample_eh, sample_klausur
):
"""Test complete workflow: invite -> accept -> access."""
# 1. Owner invites ZK
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "examiner2@school.de",
"role": "second_examiner",
"klausur_id": sample_klausur.id,
"message": "Bitte fuer Zweitkorrektur nutzen"
}
)
assert response.status_code == 200
invitation_id = response.json()["invitation_id"]
# 2. ZK sees pending invitation
response = client.get("/api/v1/eh/invitations/pending", headers=second_examiner_headers)
assert response.status_code == 200
pending = response.json()
assert len(pending) == 1
assert pending[0]["invitation"]["id"] == invitation_id
# 3. ZK accepts invitation
response = client.post(
f"/api/v1/eh/invitations/{invitation_id}/accept",
headers=second_examiner_headers,
json={"encrypted_passphrase": "encrypted-key-for-zk"}
)
assert response.status_code == 200
# 4. ZK can now see EH in shared list
response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers)
assert response.status_code == 200
shared = response.json()
assert len(shared) == 1
assert shared[0]["eh"]["id"] == sample_eh.id
# 5. EK sees invitation as accepted
response = client.get("/api/v1/eh/invitations/sent", headers=auth_headers)
assert response.status_code == 200
sent = response.json()
assert len(sent) == 1
assert sent[0]["invitation"]["status"] == "accepted"
def test_invite_decline_reinvite_workflow(
self, client, auth_headers, second_examiner_headers, sample_eh
):
"""Test workflow: invite -> decline -> re-invite."""
# 1. Owner invites ZK
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "examiner2@school.de",
"role": "second_examiner"
}
)
assert response.status_code == 200
invitation_id = response.json()["invitation_id"]
# 2. ZK declines
response = client.post(
f"/api/v1/eh/invitations/{invitation_id}/decline",
headers=second_examiner_headers
)
assert response.status_code == 200
# 3. Owner can send new invitation (declined invitation doesn't block)
response = client.post(
f"/api/v1/eh/{sample_eh.id}/invite",
headers=auth_headers,
json={
"invitee_email": "examiner2@school.de",
"role": "second_examiner",
"message": "Zweiter Versuch"
}
)
assert response.status_code == 200 # New invitation allowed
# =============================================
# INTEGRATION TESTS
# =============================================
class TestEHWorkflow:
"""Integration tests for complete EH workflow."""
def test_complete_sharing_workflow(
self, client, auth_headers, second_examiner_headers, sample_eh, sample_klausur
):
"""Test complete workflow: upload -> link -> share -> access."""
# 1. Link EH to Klausur
response = client.post(
f"/api/v1/eh/{sample_eh.id}/link-klausur",
headers=auth_headers,
json={"klausur_id": sample_klausur.id}
)
assert response.status_code == 200
# 2. Share with second examiner
response = client.post(
f"/api/v1/eh/{sample_eh.id}/share",
headers=auth_headers,
json={
"user_id": "test-examiner-002",
"role": "second_examiner",
"encrypted_passphrase": "encrypted-secret",
"klausur_id": sample_klausur.id
}
)
assert response.status_code == 200
# 3. Second examiner can see shared EH
response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers)
assert response.status_code == 200
shared = response.json()
assert len(shared) == 1
assert shared[0]["eh"]["id"] == sample_eh.id
# 4. Second examiner can see linked EH for Klausur
response = client.get(
f"/api/v1/klausuren/{sample_klausur.id}/linked-eh",
headers=second_examiner_headers
)
assert response.status_code == 200
linked = response.json()
assert len(linked) == 1
assert linked[0]["is_owner"] is False
assert linked[0]["share"] is not None
if __name__ == "__main__":
pytest.main([__file__, "-v"])