"""Tests for the department → platform-role auto-derivation. User direction 2026-06-17: Finance BU members auto-hold ``finance``; HR members auto-hold ``hr``. The derivation must: * grant ``derived`` finance/hr from a person's primary org_path segment, * back-fill existing matching people via the full reconciler, * revoke ``derived`` grants on transfer-out / departure, * NEVER touch a ``manual`` grant an admin made (provenance rail), * honour the ``identity.department_role_map`` platform-setting override. """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthRole, AuthUserRole from app.identity.department_role_sync import ( load_department_role_map, reconcile_department_roles, sync_department_roles_for_person, ) from app.identity.person_models import IdentityOrgMembership, IdentityPerson async def _ensure_role(db: AsyncSession, name: str) -> uuid.UUID: row = await db.execute(select(AuthRole).where(AuthRole.name == name)) role = row.scalar_one_or_none() if role is None: role = AuthRole(id=uuid.uuid4(), name=name, is_system=True) db.add(role) await db.commit() return role.id async def _make_person( db: AsyncSession, email: str, *, status: str = "active", ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status=status, source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db.add(p) await db.commit() return p async def _set_dept( db: AsyncSession, person_id: uuid.UUID, org_path: str, leaf: str, ) -> None: m = IdentityOrgMembership( person_id=person_id, org_node_source_id=f"dept-{uuid.uuid4().hex[:8]}", org_node_name=leaf, org_path=org_path, is_primary=True, membership_status="active", ) db.add(m) await db.commit() async def _grant(db: AsyncSession, person_id, role_id, source: str) -> None: db.add(AuthUserRole(user_id=person_id, role_id=role_id, grant_source=source)) await db.commit() async def _has_role(db: AsyncSession, person_id, role_id) -> AuthUserRole | None: row = await db.execute( select(AuthUserRole).where( AuthUserRole.user_id == person_id, AuthUserRole.role_id == role_id, ) ) return row.scalar_one_or_none() @pytest.mark.asyncio async def test_sync_grants_finance_from_org_path(db_session: AsyncSession) -> None: fin = await _ensure_role(db_session, "finance") await _ensure_role(db_session, "hr") p = await _make_person(db_session, "fin1@pingcap.com") await _set_dept(db_session, p.id, "PingCAP / Finance / Accounting", "Accounting") await sync_department_roles_for_person(db_session, p.id) await db_session.commit() row = await _has_role(db_session, p.id, fin) assert row is not None assert row.grant_source == "derived" @pytest.mark.asyncio async def test_sync_grants_hr_when_hr_is_a_chain_segment( db_session: AsyncSession, ) -> None: await _ensure_role(db_session, "finance") hr = await _ensure_role(db_session, "hr") p = await _make_person(db_session, "hr1@pingcap.com") # HR is a mid-chain segment, not the leaf nor the BU. await _set_dept(db_session, p.id, "PingCAP / People Ops / HR / Payroll", "Payroll") await sync_department_roles_for_person(db_session, p.id) await db_session.commit() assert await _has_role(db_session, p.id, hr) is not None @pytest.mark.asyncio async def test_non_matching_department_gets_no_role( db_session: AsyncSession, ) -> None: fin = await _ensure_role(db_session, "finance") hr = await _ensure_role(db_session, "hr") p = await _make_person(db_session, "eng1@pingcap.com") await _set_dept(db_session, p.id, "PingCAP / Engineering / TiDB", "TiDB") await sync_department_roles_for_person(db_session, p.id) await db_session.commit() assert await _has_role(db_session, p.id, fin) is None assert await _has_role(db_session, p.id, hr) is None @pytest.mark.asyncio async def test_reconcile_backfills_and_revokes_derived_only( db_session: AsyncSession, ) -> None: fin = await _ensure_role(db_session, "finance") await _ensure_role(db_session, "hr") # Finance person lacking the role today → should be back-filled. fin_person = await _make_person(db_session, "fin2@pingcap.com") await _set_dept(db_session, fin_person.id, "PingCAP / Finance", "Finance") # Engineer with a MANUAL finance grant → must be preserved. eng_manual = await _make_person(db_session, "eng.manual@pingcap.com") await _set_dept(db_session, eng_manual.id, "PingCAP / Engineering", "Engineering") await _grant(db_session, eng_manual.id, fin, "manual") # Engineer with a stale DERIVED finance grant (transferred out) → # must be revoked. transferred = await _make_person(db_session, "transferred@pingcap.com") await _set_dept(db_session, transferred.id, "PingCAP / Engineering", "Engineering") await _grant(db_session, transferred.id, fin, "derived") summary = await reconcile_department_roles(db_session) await db_session.commit() assert "role_missing" not in summary # back-fill happened. assert await _has_role(db_session, fin_person.id, fin) is not None # manual grant preserved. manual_row = await _has_role(db_session, eng_manual.id, fin) assert manual_row is not None assert manual_row.grant_source == "manual" # stale derived grant revoked. assert await _has_role(db_session, transferred.id, fin) is None # HR untouched here but counters present. assert "finance" in summary["by_role"] assert "hr" in summary["by_role"] @pytest.mark.asyncio async def test_reconcile_excludes_left_employees( db_session: AsyncSession, ) -> None: fin = await _ensure_role(db_session, "finance") await _ensure_role(db_session, "hr") # Active finance hire — gets the role. active = await _make_person(db_session, "fin.active@pingcap.com") await _set_dept(db_session, active.id, "PingCAP / Finance", "Finance") # leaver_pending finance person — excluded. leaving = await _make_person( db_session, "fin.leaving@pingcap.com", status="leaver_pending", ) await _set_dept(db_session, leaving.id, "PingCAP / Finance", "Finance") await reconcile_department_roles(db_session) await db_session.commit() assert await _has_role(db_session, active.id, fin) is not None assert await _has_role(db_session, leaving.id, fin) is None @pytest.mark.asyncio async def test_platform_setting_override_extends_map( db_session: AsyncSession, ) -> None: from app.integrations.models import PlatformSetting await _ensure_role(db_session, "finance") await _ensure_role(db_session, "hr") db_session.add(PlatformSetting( key="identity.department_role_map", value={"Treasury": "finance", "BadRole": "not_a_real_role"}, )) await db_session.commit() role_map = await load_department_role_map(db_session) assert role_map["treasury"] == "finance" # override applied, lower-cased assert role_map["finance"] == "finance" # default still present assert "badrole" not in role_map # invalid role rejected @pytest.mark.asyncio async def test_reconcile_reports_missing_role_rows( db_session: AsyncSession, ) -> None: from sqlalchemy import delete # The session fixture seeds the canonical roles; drop finance + hr so # the defensive "role row missing" branch can be exercised. await db_session.execute( delete(AuthRole).where(AuthRole.name.in_(["finance", "hr"])) ) await db_session.commit() summary = await reconcile_department_roles(db_session) assert summary.get("role_missing") assert set(summary["role_missing"]) == {"finance", "hr"}