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 fastapi import HTTPException from httpx import AsyncClient from sqlalchemy import UUID as SA_UUID, select from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from datetime import datetime, timedelta, timezone import base64 import json import os from app.main import create_app from app.core.config import settings from app.core.deps import get_db_session from app.core.security import create_access_token, decode_token_allow_expired, hash_password, verify_password from app.crud import user as user_crud from app.db.base_class import Base from app.models.audit_log import AuditLog from app.models.email_settings import ( EmailVerificationCode, EmailVerificationPurpose, SmtpSecurity, SystemEmailSettings, ) from app.services import email_service from tests.conftest import GUID from app.models.study_member import StudyMember from app.models.user import User, UserStatus from app.schemas.user import UserRegisterRequest TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" async def mark_register_email_verified(SessionLocal, email: str) -> None: now = datetime.now(timezone.utc) async with SessionLocal() as session: session.add( EmailVerificationCode( email=email, purpose=EmailVerificationPurpose.REGISTER, code_hash=hash_password("123456"), expires_at=now + timedelta(minutes=10), verified_at=now, ) ) await session.commit() async def add_email_settings(SessionLocal, domain: str) -> None: async with SessionLocal() as session: session.add( SystemEmailSettings( register_domain=domain, smtp_host="smtp.test", smtp_port=465, smtp_security=SmtpSecurity.SSL, smtp_username="mailer", smtp_password_encrypted="encrypted", sender_email=f"mailer@{domain}", allowed_register_domain=domain, verification_code_ttl_minutes=10, send_cooldown_seconds=60, max_verify_attempts=5, ) ) await session.commit() @pytest.mark.asyncio async def test_email_domains_come_only_from_email_service_settings(client_and_db): client, SessionLocal = client_and_db empty_response = await client.get("/api/v1/auth/email-domains") assert empty_response.status_code == 200 assert empty_response.json() == {"items": []} assert empty_response.headers["cache-control"] == "no-store" await add_email_settings(SessionLocal, "example.com") configured_response = await client.get("/api/v1/auth/email-domains") assert configured_response.status_code == 200 assert configured_response.json() == {"items": ["example.com"]} assert configured_response.headers["cache-control"] == "no-store" 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) original_column_types = { column: column.type for table in Base.metadata.tables.values() for column in table.columns } for table in Base.metadata.tables.values(): for column in table.columns: if isinstance(column.type, (PG_UUID, SA_UUID)): column.type = GUID() try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) 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", is_admin=True, status=UserStatus.ACTIVE, ) session.add(admin) await session.commit() async with AsyncClient(app=app, base_url="http://test") as client: yield client, TestSessionLocal finally: for column, original_type in original_column_types.items(): column.type = original_type await engine.dispose() @pytest.mark.asyncio async def test_register_creates_active_user(client_and_db): client, SessionLocal = client_and_db payload = { "email": "newuser@test.com", "password": "Password123", "full_name": "New User", "clinical_department": "Clinical", } await mark_register_email_verified(SessionLocal, payload["email"]) 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.ACTIVE assert user.is_admin is False member_rows = ( await session.execute(select(StudyMember).where(StudyMember.user_id == user.id)) ).scalars().all() assert member_rows == [] @pytest.mark.asyncio async def test_register_rejects_duplicate_email(client_and_db): client, SessionLocal = client_and_db payload = { "email": "duplicate@test.com", "password": "Password123", "full_name": "Duplicate User", "clinical_department": "Clinical", } await mark_register_email_verified(SessionLocal, payload["email"]) first_resp = await client.post("/api/v1/auth/register", json=payload) assert first_resp.status_code == 201 second_resp = await client.post("/api/v1/auth/register", json=payload) assert second_resp.status_code == 409 assert second_resp.json()["detail"] == "邮箱已注册" @pytest.mark.asyncio async def test_register_requires_verified_email_code(client_and_db): client, _ = client_and_db payload = { "email": "unverified@test.com", "password": "Password123", "full_name": "Unverified User", "clinical_department": "Clinical", } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code == 400 assert resp.json()["detail"] == "请先完成邮箱验证码校验" @pytest.mark.asyncio async def test_register_email_availability(client_and_db): client, SessionLocal = client_and_db available_resp = await client.get( "/api/v1/auth/register/email-availability", params={"email": "available@test.com"}, ) assert available_resp.status_code == 200 assert available_resp.json() == {"available": True} payload = { "email": "used@test.com", "password": "Password123", "full_name": "Used User", "clinical_department": "Clinical", } await mark_register_email_verified(SessionLocal, payload["email"]) await client.post("/api/v1/auth/register", json=payload) used_resp = await client.get( "/api/v1/auth/register/email-availability", params={"email": payload["email"]}, ) assert used_resp.status_code == 200 assert used_resp.json() == {"available": False} @pytest.mark.asyncio async def test_password_reset_code_is_one_time_and_ignores_link_records(client_and_db): _, SessionLocal = client_and_db email = "reset-once@test.com" now = datetime.now(timezone.utc) await add_email_settings(SessionLocal, "test.com") async with SessionLocal() as session: user = User( email=email, password_hash=hash_password("OldPassword123"), full_name="Reset Once", clinical_department="Clinical", status=UserStatus.ACTIVE, ) session.add(user) session.add_all( [ EmailVerificationCode( email=email, purpose=EmailVerificationPurpose.PASSWORD_RESET, code_hash=hash_password("123456"), expires_at=now + timedelta(minutes=10), created_at=now, ), EmailVerificationCode( email=email, purpose=EmailVerificationPurpose.PASSWORD_RESET_LINK, code_hash="a" * 64, expires_at=now + timedelta(minutes=10), created_at=now + timedelta(seconds=1), ), ] ) await session.commit() await email_service.reset_password_with_code(session, email, "123456", "NewPassword123") await session.refresh(user) assert verify_password("NewPassword123", user.password_hash) with pytest.raises(HTTPException) as exc_info: await email_service.reset_password_with_code(session, email, "123456", "OtherPassword123") assert exc_info.value.status_code == 400 @pytest.mark.asyncio async def test_password_reset_code_verification_issues_one_time_reset_token(client_and_db): client, SessionLocal = client_and_db email = "verified-reset@test.com" now = datetime.now(timezone.utc) await add_email_settings(SessionLocal, "test.com") async with SessionLocal() as session: session.add( User( email=email, password_hash=hash_password("OldPassword123"), full_name="Verified Reset", clinical_department="Clinical", status=UserStatus.ACTIVE, ) ) session.add( EmailVerificationCode( email=email, purpose=EmailVerificationPurpose.PASSWORD_RESET, code_hash=hash_password("654321"), expires_at=now + timedelta(minutes=10), ) ) await session.commit() verify_response = await client.post( "/api/v1/auth/password-reset/email-code/verify", json={"email": email, "code": "654321"}, ) assert verify_response.status_code == 200 assert verify_response.json()["verified"] is True reset_token = verify_response.json()["reset_token"] reused_code_response = await client.post( "/api/v1/auth/password-reset/email-code/verify", json={"email": email, "code": "654321"}, ) assert reused_code_response.status_code == 400 reset_response = await client.post( "/api/v1/auth/password-reset-link", json={"token": reset_token, "password": "NewPassword123"}, ) assert reset_response.status_code == 200 reused_token_response = await client.post( "/api/v1/auth/password-reset-link", json={"token": reset_token, "password": "OtherPassword123"}, ) assert reused_token_response.status_code == 400 async with SessionLocal() as session: user = await user_crud.get_by_email(session, email) assert user is not None assert verify_password("NewPassword123", user.password_hash) @pytest.mark.asyncio async def test_password_reset_code_send_reports_missing_account(client_and_db): client, _ = client_and_db response = await client.post( "/api/v1/auth/password-reset/email-code/send", json={"email": "missing@test.com"}, ) assert response.status_code == 404 assert response.json()["detail"] == "该邮箱未注册" @pytest.mark.asyncio async def test_password_reset_link_uses_configured_frontend_url(client_and_db, monkeypatch): client, _ = client_and_db captured: dict[str, str] = {} async def fake_send_password_reset_link(db, email: str, *, frontend_origin: str) -> None: captured["email"] = email captured["frontend_origin"] = frontend_origin monkeypatch.setattr(email_service, "send_password_reset_link", fake_send_password_reset_link) monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com") response = await client.post( "/api/v1/auth/password-reset-link/send", json={"email": "victim@test.com"}, headers={"Origin": "https://attacker.example"}, ) assert response.status_code == 200 assert captured == { "email": "victim@test.com", "frontend_origin": "https://ctms.example.com", } @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_registered_user_can_login_after_email_verification(client_and_db): client, SessionLocal = client_and_db payload = { "email": "active-register@test.com", "password": "Password123", "full_name": "Active Registered User", "clinical_department": "Safety", } await mark_register_email_verified(SessionLocal, payload["email"]) await client.post("/api/v1/auth/register", json=payload) resp = await encrypted_login(client, payload["email"], payload["password"]) assert resp.status_code == 200 assert resp.json()["access_token"] @pytest.mark.asyncio async def test_desktop_login_uses_30_day_token_without_changing_web_login(client_and_db): client, _ = client_and_db web_resp = await encrypted_login(client, "admin@test.com", "admin123") desktop_resp = await client.post( "/api/v1/auth/login", json=await encrypted_auth_payload(client, "admin@test.com", "admin123"), headers={"X-CTMS-Client-Type": "desktop"}, ) assert web_resp.status_code == 200 assert desktop_resp.status_code == 200 web_payload = decode_token_allow_expired(web_resp.json()["access_token"]) desktop_payload = decode_token_allow_expired(desktop_resp.json()["access_token"]) assert web_payload["client_type"] == "web" assert desktop_payload["client_type"] == "desktop" assert web_payload["exp"] - web_payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60 assert desktop_payload["exp"] - desktop_payload["iat"] == settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600 @pytest.mark.asyncio async def test_web_token_extension_cannot_be_upgraded_with_desktop_header(client_and_db): client, _ = client_and_db web_resp = await encrypted_login(client, "admin@test.com", "admin123") token = web_resp.json()["access_token"] resp = await client.post( "/api/v1/auth/extend", headers={ "Authorization": f"Bearer {token}", "X-CTMS-Client-Type": "desktop", }, ) assert resp.status_code == 200 payload = decode_token_allow_expired(resp.json()["accessToken"]) assert payload["client_type"] == "web" assert payload["exp"] - payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60 @pytest.mark.asyncio async def test_desktop_token_extension_rejects_sessions_after_30_days(client_and_db): client, SessionLocal = client_and_db async with SessionLocal() as session: admin = await user_crud.get_by_email(session, "admin@test.com") session_start = datetime.now(timezone.utc) - timedelta(days=settings.DESKTOP_SESSION_MAX_DAYS, seconds=1) token = create_access_token( user_id=str(admin.id), expires_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60, session_start=session_start, max_age_seconds=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600, client_type="desktop", ) resp = await client.post( "/api/v1/auth/extend", headers={ "Authorization": f"Bearer {token}", "X-CTMS-Client-Type": "desktop", }, ) assert resp.status_code == 401 assert "会话已到期" in resp.json().get("detail", "") @pytest.mark.asyncio async def test_admin_created_user_is_active_by_default(client_and_db): client, SessionLocal = client_and_db admin_login = await encrypted_login(client, "admin@test.com", "admin123") token = admin_login.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} payload = { "email": "admin-created@test.com", "password": "Password123", "full_name": "Admin Created", "clinical_department": "Supply", } resp = await client.post("/api/v1/users/", json=payload, headers=headers) assert resp.status_code == 201 assert resp.json()["status"] == "ACTIVE" async with SessionLocal() as session: user = await user_crud.get_by_email(session, payload["email"]) assert user.status == UserStatus.ACTIVE @pytest.mark.asyncio async def test_admin_users_list_filters_by_keyword_and_status(client_and_db): client, SessionLocal = client_and_db async with SessionLocal() as session: session.add_all( [ User( email="active-filter@test.com", password_hash=hash_password("Password123"), full_name="Active Filter", clinical_department="Oncology", status=UserStatus.ACTIVE, ), User( email="pending-filter@test.com", password_hash=hash_password("Password123"), full_name="Pending Filter", clinical_department="Cardiology", status=UserStatus.PENDING, ), User( email="disabled-filter@test.com", password_hash=hash_password("Password123"), full_name="Disabled Filter", clinical_department="Oncology", status=UserStatus.DISABLED, ), ] ) await session.commit() admin_login = await encrypted_login(client, "admin@test.com", "admin123") token = admin_login.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} active_resp = await client.get("/api/v1/users/?status=ACTIVE", headers=headers) assert active_resp.status_code == 200 active_items = active_resp.json()["items"] assert all(item["status"] == "ACTIVE" for item in active_items) assert {item["email"] for item in active_items} >= {"admin@test.com", "active-filter@test.com"} assert "pending-filter@test.com" not in {item["email"] for item in active_items} keyword_resp = await client.get("/api/v1/users/?keyword=Oncology", headers=headers) assert keyword_resp.status_code == 200 keyword_data = keyword_resp.json() assert keyword_data["total"] == 2 assert {item["email"] for item in keyword_data["items"]} == { "active-filter@test.com", "disabled-filter@test.com", } combined_resp = await client.get("/api/v1/users/?keyword=Filter&status=PENDING", headers=headers) assert combined_resp.status_code == 200 combined_data = combined_resp.json() assert combined_data["total"] == 1 assert combined_data["items"][0]["email"] == "pending-filter@test.com" @pytest.mark.asyncio async def test_admin_delete_user_with_audit_history_returns_400(client_and_db): client, SessionLocal = client_and_db async with SessionLocal() as session: user = User( email="audited-delete@test.com", password_hash=hash_password("Password123"), full_name="Audited Delete", clinical_department="Medical", status=UserStatus.ACTIVE, ) session.add(user) await session.flush() session.add( AuditLog( entity_type="user", entity_id=user.id, action="LOGIN", operator_id=user.id, operator_role="USER", ) ) await session.commit() 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.delete(f"/api/v1/users/{user_id}", headers=headers) assert resp.status_code == 400 assert resp.json()["detail"] == "该账号已有审计或权限访问记录,请停用账号以保留历史追溯" async with SessionLocal() as session: assert await user_crud.get_by_id(session, user_id) is not None def test_register_request_does_not_expose_role_input(): assert "role" not in UserRegisterRequest.model_fields @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_dev_login_allows_plaintext_only_in_development(client_and_db): client, _ = client_and_db original_env = settings.ENV settings.ENV = "development" try: resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) finally: settings.ENV = original_env assert resp.status_code == 200 assert resp.json()["token_type"] == "bearer" assert resp.json()["access_token"] @pytest.mark.asyncio async def test_avatar_upload_rejects_non_image_files(client_and_db): client, _ = client_and_db original_env = settings.ENV settings.ENV = "development" try: login_resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) finally: settings.ENV = original_env token = login_resp.json()["access_token"] resp = await client.post( "/api/v1/auth/me/avatar", files={"file": ("avatar.txt", b"not an image", "text/plain")}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 400 assert resp.json()["detail"] == "头像仅支持图片格式" @pytest.mark.asyncio async def test_dev_login_is_disabled_outside_development(client_and_db): client, _ = client_and_db original_env = settings.ENV settings.ENV = "production" try: resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) finally: settings.ENV = original_env assert resp.status_code == 404 @pytest.mark.asyncio async def test_dev_login_rejects_inactive_users(client_and_db): client, SessionLocal = client_and_db original_env = settings.ENV settings.ENV = "development" try: async with SessionLocal() as session: session.add( User( email="pending-dev-login@test.com", password_hash=hash_password("Password123"), full_name="Pending Dev Login", clinical_department="Clinical", status=UserStatus.PENDING, ) ) await session.commit() resp = await client.post( "/api/v1/auth/dev-login", json={"email": "pending-dev-login@test.com", "password": "Password123"}, ) finally: settings.ENV = original_env assert resp.status_code == 401 assert "账号未审核" in resp.json().get("detail", "") @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