import pytest import pytest_asyncio from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from httpx import AsyncClient from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.compiler import compiles import base64 import json import os from app.main import create_app from app.core.deps import get_db_session from app.core.security import hash_password from app.crud import user as user_crud from app.models.user import User, UserRole, UserStatus TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @compiles(UUID, "sqlite") def compile_uuid_for_sqlite(_type, _compiler, **_kw): return "CHAR(32)" async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict: key_resp = await client.get("/api/v1/auth/login-key") assert key_resp.status_code == 200 login_key = key_resp.json() public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) plaintext = json.dumps( { "email": email, "password": password, "challenge": login_key["challenge"], }, separators=(",", ":"), ).encode("utf-8") aes_key = AESGCM.generate_key(bit_length=256) iv = os.urandom(12) encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) encrypted_key = public_key.encrypt( aes_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None, ), ) return { "key_id": login_key["key_id"], "challenge": login_key["challenge"], "ciphertext": base64.b64encode( json.dumps( { "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), "iv": base64.b64encode(iv).decode("ascii"), "data": base64.b64encode(encrypted_data).decode("ascii"), }, separators=(",", ":"), ).encode("utf-8") ).decode("ascii"), } async def encrypted_login(client: AsyncClient, email: str, password: str): return await client.post("/api/v1/auth/login", json=await encrypted_auth_payload(client, email, password)) @pytest_asyncio.fixture async def client_and_db(): engine = create_async_engine(TEST_DATABASE_URL, future=True) TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False) async with engine.begin() as conn: await conn.run_sync(User.__table__.create) async def override_get_db(): async with TestSessionLocal() as session: yield session app = create_app() app.dependency_overrides[get_db_session] = override_get_db async with TestSessionLocal() as session: admin = User( email="admin@test.com", password_hash=hash_password("admin123"), full_name="Admin", clinical_department="Admin", role=UserRole.ADMIN, status=UserStatus.ACTIVE, ) session.add(admin) await session.commit() async with AsyncClient(app=app, base_url="http://test") as client: yield client, TestSessionLocal await engine.dispose() @pytest.mark.asyncio async def test_register_creates_pending_user(client_and_db): client, SessionLocal = client_and_db payload = { "email": "newuser@test.com", "password": "Password123", "full_name": "New User", "role": "CRA", "clinical_department": "Clinical", } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code == 201 async with SessionLocal() as session: user = await user_crud.get_by_email(session, payload["email"]) assert user is not None assert user.status == UserStatus.PENDING @pytest.mark.asyncio async def test_root_returns_service_metadata(client_and_db): client, _ = client_and_db resp = await client.get("/") assert resp.status_code == 200 assert resp.json() == { "service": "ctms-backend", "status": "ok", "health": "/health", "docs": "/docs", "api": "/api/v1", } @pytest.mark.asyncio async def test_login_blocked_before_approval(client_and_db): client, SessionLocal = client_and_db payload = { "email": "pending@test.com", "password": "Password123", "full_name": "Pending User", "role": "PV", "clinical_department": "Safety", } await client.post("/api/v1/auth/register", json=payload) resp = await encrypted_login(client, payload["email"], payload["password"]) assert resp.status_code == 401 assert "账号未审核" in resp.json().get("detail", "") @pytest.mark.asyncio async def test_admin_can_approve_user(client_and_db): client, SessionLocal = client_and_db payload = { "email": "approve@test.com", "password": "Password123", "full_name": "Approve Target", "role": "IMP", "clinical_department": "Supply", } await client.post("/api/v1/auth/register", json=payload) async with SessionLocal() as session: user = await user_crud.get_by_email(session, payload["email"]) user_id = user.id admin_login = await encrypted_login(client, "admin@test.com", "admin123") token = admin_login.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} resp = await client.post(f"/api/v1/admin/users/{user_id}/approve", json={"action": "approve"}, headers=headers) assert resp.status_code == 200 async with SessionLocal() as session: refreshed = await user_crud.get_by_email(session, payload["email"]) assert refreshed.status == UserStatus.ACTIVE assert refreshed.approved_by is not None @pytest.mark.asyncio async def test_admin_role_cannot_register(client_and_db): client, _ = client_and_db payload = { "email": "admin-register@test.com", "password": "Password123", "full_name": "Bad Admin", "role": "ADMIN", "clinical_department": "IT", } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code in (400, 422) @pytest.mark.asyncio async def test_plaintext_login_is_rejected(client_and_db): client, _ = client_and_db resp = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"}) assert resp.status_code == 422 @pytest.mark.asyncio async def test_login_challenge_cannot_be_reused(client_and_db): client, _ = client_and_db key_resp = await client.get("/api/v1/auth/login-key") login_key = key_resp.json() public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) plaintext = json.dumps( { "email": "admin@test.com", "password": "admin123", "challenge": login_key["challenge"], }, separators=(",", ":"), ).encode("utf-8") aes_key = AESGCM.generate_key(bit_length=256) iv = os.urandom(12) encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) encrypted_key = public_key.encrypt( aes_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None, ) ) ciphertext = base64.b64encode( json.dumps( { "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), "iv": base64.b64encode(iv).decode("ascii"), "data": base64.b64encode(encrypted_data).decode("ascii"), }, separators=(",", ":"), ).encode("utf-8") ).decode("ascii") payload = { "key_id": login_key["key_id"], "challenge": login_key["challenge"], "ciphertext": ciphertext, } first = await client.post("/api/v1/auth/login", json=payload) second = await client.post("/api/v1/auth/login", json=payload) assert first.status_code == 200 assert second.status_code == 401 @pytest.mark.asyncio async def test_unlock_requires_encrypted_password(client_and_db): client, _ = client_and_db plaintext = await client.post("/api/v1/auth/unlock", json={"email": "admin@test.com", "password": "admin123"}) encrypted = await client.post( "/api/v1/auth/unlock", json=await encrypted_auth_payload(client, "admin@test.com", "admin123"), ) assert plaintext.status_code == 422 assert encrypted.status_code == 200