from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.core.security import hash_password from app.db.base_class import Base from app.models.auth_session import AuthSession from app.models.employee import Employee from app.models.tenant import Tenant, TenantMembership from app.schemas.auth import LoginRequest from app.schemas.employee import EmployeeUpdate from app.services.auth import AuthenticatedUser, AuthService from app.services.auth_sessions import AuthSessionService from app.services.employee import EmployeeService @pytest.fixture() def factory() -> sessionmaker[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) result = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) try: yield result finally: Base.metadata.drop_all(engine) engine.dispose() def _seed_two_tenants(db: Session) -> tuple[Employee, Employee]: db.add_all( [ Tenant( tenant_id="tenant-a", tenant_code="company-a", name="企业 A", status="active", ), Tenant( tenant_id="tenant-b", tenant_code="company-b", name="企业 B", status="active", ), ] ) employee_a = Employee( tenant_id="tenant-a", employee_no="E-SAME", name="A 员工", email="same@example.com", password_hash=hash_password("secure-password"), employment_status="在职", ) employee_b = Employee( tenant_id="tenant-b", employee_no="E-SAME", name="B 员工", email="same@example.com", password_hash=hash_password("secure-password"), employment_status="在职", ) db.add_all([employee_a, employee_b]) db.flush() db.add_all( [ TenantMembership( tenant_id="tenant-a", employee_id=employee_a.id, status="active", is_primary=True, ), TenantMembership( tenant_id="tenant-b", employee_id=employee_b.id, status="active", is_primary=True, ), ] ) db.commit() return employee_a, employee_b def test_same_email_login_is_bound_to_authenticated_membership( factory: sessionmaker[Session], ) -> None: with factory() as db: employee_a, employee_b = _seed_two_tenants(db) auth = AuthService(db) with pytest.raises(ValueError, match="关联多个企业"): auth.login( LoginRequest( username="same@example.com", password="secure-password", ) ) login_a = auth.login( LoginRequest( username="same@example.com", password="secure-password", tenantId="company-a", ) ) login_b = auth.login( LoginRequest( username="same@example.com", password="secure-password", tenantId="tenant-b", ) ) assert login_a.user.tenantId == "tenant-a" assert login_b.user.tenantId == "tenant-b" sessions = list(db.scalars(select(AuthSession).order_by(AuthSession.created_at))) assert {item.tenant_id for item in sessions} == {"tenant-a", "tenant-b"} assert {item.employee_id for item in sessions} == {employee_a.id, employee_b.id} def test_employee_service_hides_cross_tenant_resource_ids( factory: sessionmaker[Session], ) -> None: with factory() as db: employee_a, employee_b = _seed_two_tenants(db) service_a = EmployeeService(db, tenant_id="tenant-a") assert service_a.get_employee(employee_a.id) is not None assert service_a.get_employee(employee_b.id) is None with pytest.raises(LookupError, match="Employee not found"): service_a.update_employee( employee_b.id, EmployeeUpdate(name="被 A 修改"), ) db.refresh(employee_b) assert employee_b.name == "B 员工" def test_session_tenant_cannot_be_rebound_to_another_employee( factory: sessionmaker[Session], ) -> None: with factory() as db: employee_a, _employee_b = _seed_two_tenants(db) mismatched = AuthSession( token_hash="mismatched-token-hash", tenant_id="tenant-b", principal_type="employee", employee_id=employee_a.id, username=employee_a.email, metric_session_id="metric-a", issued_at=datetime.now(UTC), expires_at=datetime.now(UTC) + timedelta(minutes=30), last_seen_at=datetime.now(UTC), ) db.add(mismatched) db.commit() assert AuthService(db).get_session_user(mismatched) is None def test_session_issue_rejects_missing_tenant( factory: sessionmaker[Session], ) -> None: with factory() as db: user = AuthenticatedUser( username="missing-tenant@example.com", name="无租户用户", role="使用者", department="", position="", grade="", employee_no="", manager_name="", location="", cost_center="", finance_owner_name="", risk_profile={}, role_codes=["user"], email="missing-tenant@example.com", avatar="无", tenant_id="", ) with pytest.raises(ValueError, match="tenant_id"): AuthSessionService(db).issue(user, metric_session_id="metric-missing")