"""Tests for the confirm-gated Jamf offboarding device-action coordinator. Pins the safety contract: * unbind / lock / wipe each route to the right driver method + audit; * WIPE IS REFUSED when the device's asset is held_by_leaver (buyback); * wipe refused when the device can't be resolved in Jamf; * unbind is allowed even for a retained device (reversible metadata). """ from __future__ import annotations import uuid from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.core.coordinators.jamf_device_action_coordinator import ( execute_jamf_device_action, ) from app.core.exceptions import ValidationError from app.domains.asset.models import AssetItem from app.integrations.driver_base import MdmActionResult, MdmDevice def _device(serial: str = "C02ABC") -> MdmDevice: return MdmDevice( external_id="10", device_type="computer", management_id="m-10", name="MAC-10", serial_number=serial, model="MacBook Pro", assigned_user_email="bob@pingcap.com", ) def _driver(device: MdmDevice | None = _device()) -> AsyncMock: d = AsyncMock() d.integration_id = str(uuid.uuid4()) d.get_device = AsyncMock(return_value=device) d.clear_device_assignment = AsyncMock(return_value=MdmActionResult(ok=True)) d.lock_device = AsyncMock(return_value=MdmActionResult(ok=True, external_ref="cmd-1")) d.wipe_device = AsyncMock(return_value=MdmActionResult(ok=True, external_ref="cmd-2")) return d async def _asset(db: AsyncSession, *, serial: str, status: str) -> AssetItem: a = AssetItem( id=uuid.uuid4(), asset_tag=f"TAG-{uuid.uuid4().hex[:6]}", serial_number=serial, status=status, category="laptop", ) db.add(a) await db.flush() return a def _patch(driver: AsyncMock): return patch( "app.core.coordinators.jamf_seat_coordinator.load_jamf_driver", return_value=driver, ) @pytest.mark.asyncio async def test_unbind_calls_driver(db_session: AsyncSession) -> None: drv = _driver() with _patch(drv): res = await execute_jamf_device_action( db_session, external_id="10", device_type="computer", action="unbind", actor_user_id=uuid.uuid4(), trace_id="t1", ) assert res["ok"] and res["action"] == "unbind" drv.clear_device_assignment.assert_awaited_once() drv.wipe_device.assert_not_awaited() @pytest.mark.asyncio async def test_lock_calls_driver(db_session: AsyncSession) -> None: drv = _driver() with _patch(drv): res = await execute_jamf_device_action( db_session, external_id="10", device_type="computer", action="lock", actor_user_id=uuid.uuid4(), trace_id="t2", ) assert res["ok"] and res["external_ref"] == "cmd-1" drv.lock_device.assert_awaited_once() @pytest.mark.asyncio async def test_wipe_allowed_when_not_retained(db_session: AsyncSession) -> None: drv = _driver(_device("C02FREE")) with _patch(drv): res = await execute_jamf_device_action( db_session, external_id="10", device_type="computer", action="wipe", actor_user_id=uuid.uuid4(), trace_id="t3", ) assert res["ok"] and res["retained_by_leaver"] is False drv.wipe_device.assert_awaited_once() @pytest.mark.asyncio async def test_wipe_refused_when_held_by_leaver(db_session: AsyncSession) -> None: await _asset(db_session, serial="C02HELD", status="held_by_leaver") drv = _driver(_device("C02HELD")) with _patch(drv), pytest.raises(ValidationError, match="retained by the leaver"): await execute_jamf_device_action( db_session, external_id="10", device_type="computer", action="wipe", actor_user_id=uuid.uuid4(), trace_id="t4", ) drv.wipe_device.assert_not_awaited() # the refusal is audited as a failure rows = (await db_session.execute( select(AuditLog).where(AuditLog.action == "jamf.device.wipe") )).scalars().all() assert any(r.status == "failure" for r in rows) @pytest.mark.asyncio async def test_unbind_allowed_even_when_retained(db_session: AsyncSession) -> None: await _asset(db_session, serial="C02KEEP", status="held_by_leaver") drv = _driver(_device("C02KEEP")) with _patch(drv): res = await execute_jamf_device_action( db_session, external_id="10", device_type="computer", action="unbind", actor_user_id=uuid.uuid4(), trace_id="t5", ) assert res["ok"] and res["retained_by_leaver"] is True drv.clear_device_assignment.assert_awaited_once() @pytest.mark.asyncio async def test_wipe_refused_when_device_missing(db_session: AsyncSession) -> None: drv = _driver(device=None) with _patch(drv), pytest.raises(ValidationError, match="not found in Jamf"): await execute_jamf_device_action( db_session, external_id="999", device_type="computer", action="wipe", actor_user_id=uuid.uuid4(), trace_id="t6", ) drv.wipe_device.assert_not_awaited() # ── backfill (write asset-known owner back into Jamf) ────────────── async def _person(db: AsyncSession, email: str, status: str = "active"): from app.identity.person_models import 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]}", country="cn", ) db.add(p) await db.flush() return p def _unowned(serial: str) -> MdmDevice: return MdmDevice( external_id=f"id-{serial}", device_type="computer", serial_number=serial, name=f"MAC-{serial}", assigned_user_email=None, ) def _backfill_driver(devices: list[MdmDevice]) -> AsyncMock: d = AsyncMock() d.integration_id = str(uuid.uuid4()) d.list_devices = AsyncMock(return_value=devices) d.set_device_assignment = AsyncMock(return_value=MdmActionResult(ok=True)) return d @pytest.mark.asyncio async def test_backfill_dry_run_active_only_no_writes(db_session: AsyncSession) -> None: from app.core.coordinators.jamf_device_action_coordinator import ( backfill_jamf_assignments_from_assets, ) dave = await _person(db_session, "dave@pingcap.com", "active") erin = await _person(db_session, "erin@pingcap.com", "left") db_session.add(AssetItem( id=uuid.uuid4(), asset_tag="A-S1", serial_number="S1", status="assigned", category="laptop", assigned_to=dave.id, )) db_session.add(AssetItem( id=uuid.uuid4(), asset_tag="A-S2", serial_number="S2", status="claimed", category="laptop", assigned_to=erin.id, )) await db_session.flush() drv = _backfill_driver([_unowned("S1"), _unowned("S2"), _unowned("S3")]) with _patch(drv): res = await backfill_jamf_assignments_from_assets( db_session, actor_user_id=uuid.uuid4(), trace_id="bf1", dry_run=True, ) # only S1 (dave, active) is a candidate — S2 owner is a leaver, S3 has no asset assert res["dry_run"] is True assert res["candidates"] == 1 assert res["plan"][0]["owner_email"] == "dave@pingcap.com" drv.set_device_assignment.assert_not_awaited() @pytest.mark.asyncio async def test_backfill_real_run_writes_active_owner(db_session: AsyncSession) -> None: from app.core.coordinators.jamf_device_action_coordinator import ( backfill_jamf_assignments_from_assets, ) dave = await _person(db_session, "dave2@pingcap.com", "active") db_session.add(AssetItem( id=uuid.uuid4(), asset_tag="A-S5", serial_number="S5", status="assigned", category="laptop", assigned_to=dave.id, )) await db_session.flush() drv = _backfill_driver([_unowned("S5")]) with _patch(drv): res = await backfill_jamf_assignments_from_assets( db_session, actor_user_id=uuid.uuid4(), trace_id="bf2", dry_run=False, ) assert res["dry_run"] is False and res["written"] == 1 drv.set_device_assignment.assert_awaited_once() kwargs = drv.set_device_assignment.await_args.kwargs assert kwargs["external_id"] == "id-S5" assert kwargs["email"] == "dave2@pingcap.com"