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>
938 lines
32 KiB
Python
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"])
|