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>
This commit is contained in:
937
klausur-service/backend/tests/test_byoeh.py
Normal file
937
klausur-service/backend/tests/test_byoeh.py
Normal file
@@ -0,0 +1,937 @@
|
||||
"""
|
||||
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"])
|
||||
Reference in New Issue
Block a user