"""``ProvisioningService._resolve_execution_mode`` target-awareness. Account / api_key revoke tasks carry ``software_id=None`` by design (they aren't tied to a single catalog row). The pre-fix resolver keyed ONLY off ``software_id`` and so downgraded every such task to ``manual_notify`` — which silently turned Jira leaver full-access revoke (#537) into "DM an admin to do it by hand" and left the leaver with access. These guard the target-aware resolution. The account→auto_api positive path (with a real Jira integration + driver build) is covered end-to-end in ``test_offboarding/test_jira_full_access_revoke_2026_05_27.py``. """ from __future__ import annotations import uuid import pytest from app.domains.asset.models import AssetSoftwareCatalog from app.domains.provisioning.models import ( ACTION_REVOKE, EXEC_AUTO_API, EXEC_MANUAL_NOTIFY, SOURCE_OFFBOARDING, SoftwareConnector, ) from app.domains.provisioning.schemas import ProvisioningTaskCreate from app.domains.provisioning.service import ProvisioningService from app.identity.person_models import IdentityPerson from app.integrations.models import Integration @pytest.mark.asyncio async def test_api_key_target_resolves_auto_api(db_session) -> None: """api_key revoke (software_id=None): _invoke_connector short-circuits OK and the real cloud-credential teardown runs in _post_success_side_effects, so the task must be auto_api — not a manual notification.""" svc = ProvisioningService(db_session) mode = await svc._resolve_execution_mode( software_id=None, target_type="api_key", target_id=uuid.uuid4(), ) assert mode == EXEC_AUTO_API @pytest.mark.asyncio async def test_account_target_without_integration_id_falls_back_manual( db_session, ) -> None: """An account task with no target_id can't resolve a driver, so it degrades to manual_notify rather than creating an auto task that would always fail at dispatch.""" svc = ProvisioningService(db_session) mode = await svc._resolve_execution_mode( software_id=None, target_type="account", target_id=None, ) assert mode == EXEC_MANUAL_NOTIFY @pytest.mark.asyncio async def test_account_target_unknown_integration_falls_back_manual( db_session, ) -> None: """A target_id that resolves no driver (build raises) must degrade to manual_notify, never raise — a misconfigured provider still needs a human, not a crash in create_task.""" svc = ProvisioningService(db_session) mode = await svc._resolve_execution_mode( software_id=None, target_type="account", target_id=uuid.uuid4(), ) assert mode == EXEC_MANUAL_NOTIFY @pytest.mark.asyncio async def test_software_seat_without_binding_still_manual(db_session) -> None: """Catalog-software path is unchanged: software_id=None with no account/api_key target type stays manual_notify.""" svc = ProvisioningService(db_session) mode = await svc._resolve_execution_mode( software_id=None, target_type="seat", target_id=uuid.uuid4(), ) assert mode == EXEC_MANUAL_NOTIFY @pytest.mark.asyncio async def test_seat_bound_to_non_provisioning_integration_is_manual( db_session, ) -> None: """A catalog row bound to a non-provisioning integration (Jamf, kind='mdm', seat==device, no seat grant/revoke API) must resolve to manual_notify — NOT auto_api. The old resolver keyed only off ``provider != 'manual'`` and forced auto_api, so the seat task hit ``_invoke_connector`` and raised ``Binding references non-provisioning integration (kind='mdm', provider='jamf')``, landing FAILED and wedging the offboarding blocker. Gate on the driver type instead.""" jamf = Integration( id=uuid.uuid4(), kind="mdm", provider="jamf", name="Jamf Pro (MDM)", slug="jamf-prod", enabled=True, is_primary=True, config_public={ "base_url": "https://pingcap.jamfcloud.com", "client_id": "test-client", }, ) db_session.add(jamf) await db_session.flush() sw = AssetSoftwareCatalog( id=uuid.uuid4(), name="MacBook (Jamf-managed)", asset_type="SaaS", status="active", offboarding_policy="auto_revoke", ) db_session.add(sw) await db_session.flush() db_session.add(SoftwareConnector( id=uuid.uuid4(), software_id=sw.id, integration_id=jamf.id, is_active=True, binding_config={}, )) await db_session.flush() svc = ProvisioningService(db_session) mode = await svc._resolve_execution_mode( software_id=sw.id, target_type="seat", target_id=uuid.uuid4(), ) assert mode == EXEC_MANUAL_NOTIFY @pytest.mark.asyncio async def test_offboarding_software_revoke_assigns_catalog_owner(db_session) -> None: """Software cleanup is routed to that software's owner, not a global fixed recipient or the IT-admin fan-out fallback. This covers the Jamf production symptom: the catalog had an owner but the offboarding task's assignee_id was NULL, so every IT admin received the manual-notify push and the task list showed "—". """ owner = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id="OWNER-1", employee_no="E-OWNER", work_email="owner@example.com", display_name="Software Owner", employment_status="active", ) leaver = IdentityPerson( id=uuid.uuid4(), source_system="test", hris_person_id="LEAVER-1", employee_no="E-LEAVER", work_email="leaver@example.com", display_name="Leaver", employment_status="left", ) software = AssetSoftwareCatalog( id=uuid.uuid4(), name="Jamf", asset_type="SaaS", status="active", owner_person_id=owner.id, offboarding_policy="sso_cascade", ) db_session.add_all([owner, leaver]) await db_session.flush() db_session.add(software) await db_session.flush() task = await ProvisioningService(db_session).create_task( ProvisioningTaskCreate( user_id=leaver.id, software_id=software.id, action=ACTION_REVOKE, target_type="seat", target_id=uuid.uuid4(), source=SOURCE_OFFBOARDING, execution_mode=EXEC_MANUAL_NOTIFY, ), ) assert task.assignee_id == owner.id