"""The reconcilers that used to revoke roles behind the ORM's back. ``location_admin_sync``, ``department_role_sync`` and the leaver guard all revoked with ``session.execute(delete(AuthUserRole)...)``. That form is invisible to ``app.core.access_cycle_listener``, so the holding interval stayed open and every point-in-time query kept answering "yes, still an admin" — indefinitely, and for the leaver path specifically. The AST guard in ``test_access_cycle_ledger_guard`` stops the *pattern* from coming back. These tests prove the *behaviour* of each converted path, so a rewrite that keeps the guard happy but drops the ledger row (loading the rows and deleting them somewhere the listener can't see, say) still fails. """ 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, AuthUserRoleCycle from app.core.access_cycle_listener import register_listener from app.domains.asset.location_admin_sync import ( reconcile_asset_admin_role, sync_asset_admin_role_for_person, ) from app.domains.asset.models import AssetLocation, AssetLocationAdmin register_listener() async def _role_id(db: AsyncSession, name: str) -> uuid.UUID: return ( await db.execute(select(AuthRole.id).where(AuthRole.name == name)) ).scalar_one() async def _open_cycles( db: AsyncSession, user_id: uuid.UUID, role_id: uuid.UUID, ) -> list[AuthUserRoleCycle]: rows = await db.execute( select(AuthUserRoleCycle) .where( AuthUserRoleCycle.user_id == user_id, AuthUserRoleCycle.role_id == role_id, AuthUserRoleCycle.revoked_at.is_(None), ) .execution_options(populate_existing=True) ) return list(rows.scalars().all()) async def _closed_cycles( db: AsyncSession, user_id: uuid.UUID, role_id: uuid.UUID, ) -> list[AuthUserRoleCycle]: rows = await db.execute( select(AuthUserRoleCycle) .where( AuthUserRoleCycle.user_id == user_id, AuthUserRoleCycle.role_id == role_id, AuthUserRoleCycle.revoked_at.is_not(None), ) .execution_options(populate_existing=True) ) return list(rows.scalars().all()) async def _mk_location(db: AsyncSession) -> AssetLocation: location = AssetLocation( code=f"LG{uuid.uuid4().hex[:8]}", name=f"LedgerCity{uuid.uuid4().hex[:6]}", ) db.add(location) await db.flush() return location @pytest.mark.asyncio async def test_location_admin_sync_closes_the_interval_on_revoke( db_session, employee_user, ): employee, _ = employee_user person_id = employee.id role_id = await _role_id(db_session, "asset_admin") location = await _mk_location(db_session) db_session.add( AssetLocationAdmin(location_id=location.id, person_id=person_id), ) await db_session.flush() await sync_asset_admin_role_for_person(db_session, person_id) await db_session.flush() assert len(await _open_cycles(db_session, person_id, role_id)) == 1 # Drop the location admin row; the sync must now revoke the role. link = ( await db_session.execute( select(AssetLocationAdmin).where( AssetLocationAdmin.location_id == location.id, AssetLocationAdmin.person_id == person_id, ) ) ).scalar_one() await db_session.delete(link) await db_session.flush() await sync_asset_admin_role_for_person(db_session, person_id) await db_session.flush() assert await _open_cycles(db_session, person_id, role_id) == [] assert len(await _closed_cycles(db_session, person_id, role_id)) == 1 @pytest.mark.asyncio async def test_asset_admin_reconcile_closes_the_interval_on_revoke( db_session, employee_user, ): """The daily backstop reconciler — the bulk-revoke path. This one revoked a whole set in one statement, so a regression here leaves every stripped admin reading as a current admin. """ employee, _ = employee_user person_id = employee.id role_id = await _role_id(db_session, "asset_admin") # Hold the role with no backing office row: the reconciler must # revoke it. db_session.add(AuthUserRole(user_id=person_id, role_id=role_id)) await db_session.flush() assert len(await _open_cycles(db_session, person_id, role_id)) == 1 await reconcile_asset_admin_role(db_session) await db_session.flush() assert await _open_cycles(db_session, person_id, role_id) == [] closed = await _closed_cycles(db_session, person_id, role_id) assert len(closed) == 1 assert closed[0].role_name == "asset_admin" @pytest.mark.asyncio async def test_department_role_sync_closes_derived_grants( db_session, employee_user, ): from app.identity.department_role_sync import sync_department_roles_for_person employee, _ = employee_user person_id = employee.id role_id = await _role_id(db_session, "finance") # A derived grant with no backing department membership — the next # per-person sync must revoke it and close the interval. db_session.add(AuthUserRole( user_id=person_id, role_id=role_id, grant_source="derived", )) await db_session.flush() assert len(await _open_cycles(db_session, person_id, role_id)) == 1 await sync_department_roles_for_person(db_session, person_id) await db_session.flush() assert await _open_cycles(db_session, person_id, role_id) == [] closed = await _closed_cycles(db_session, person_id, role_id) assert len(closed) == 1 assert closed[0].grant_source == "derived" @pytest.mark.asyncio async def test_manual_grant_survives_derived_reconcile( db_session, employee_user, ): """Never-touch-manual is a pre-existing invariant; assert the ledger agrees. A ledger that closed manual intervals would understate real access — the opposite failure, and just as wrong.""" from app.identity.department_role_sync import sync_department_roles_for_person employee, _ = employee_user person_id = employee.id role_id = await _role_id(db_session, "finance") db_session.add(AuthUserRole( user_id=person_id, role_id=role_id, grant_source="manual", )) await db_session.flush() await sync_department_roles_for_person(db_session, person_id) await db_session.flush() assert len(await _open_cycles(db_session, person_id, role_id)) == 1