"""Unit tests for the Jamf Pro MDM driver. All HTTP is mocked via respx. Covers: - metadata registration + config schema (mdm kind, secret field) - test_connection (ok / error) backed by seat usage - get_seat_usage (computer + mobile totalCount) - list_devices: computer + mobile field flattening, pagination - lock_device / wipe_device: managementId resolution + command body - clear_device_assignment: PATCH body, 404 idempotency - token refresh on 401 """ from __future__ import annotations import re import httpx import pytest import respx from app.integrations import _url_safety from app.integrations._url_safety import UnsafeURLError from app.integrations.drivers.jamf import JamfDriver from app.integrations.registry import get_driver_class _BASE = "https://jamf.test" _CONFIG = { "base_url": _BASE, "client_id": "test-client", "client_secret": "test-secret", } _TOKEN_URL = f"{_BASE}/api/oauth/token" _COMPUTERS_RE = re.compile(r"https://jamf\.test/api/v1/computers-inventory(\?.*)?$") _MOBILE_RE = re.compile(r"https://jamf\.test/api/v2/mobile-devices(\?.*)?$") @pytest.fixture(autouse=True) def _public_test_dns(monkeypatch): monkeypatch.setattr( _url_safety.socket, "getaddrinfo", lambda *_args, **_kwargs: [(2, 1, 6, "", ("1.1.1.1", 443))], ) def _driver(cfg: dict | None = None) -> JamfDriver: return JamfDriver(integration_id="jamf-test", config=cfg or _CONFIG) def _mock_token() -> None: respx.post(_TOKEN_URL).mock( return_value=httpx.Response( 200, json={"access_token": "tok-123", "expires_in": 1800}, ), ) def _computer(cid: str, *, email: str | None, serial: str) -> dict: return { "id": cid, "general": { "name": f"MAC-{cid}", "managementId": f"mgmt-{cid}", "lastContactTime": "2026-06-25T08:30:00.000Z", "remoteManagement": {"managed": True}, }, "hardware": {"serialNumber": serial, "model": "MacBook Pro (14-inch)"}, "operatingSystem": {"version": "14.5"}, "userAndLocation": { "username": "shuai.feng", "realname": "Shuai Feng", "email": email or "", }, } # ── metadata ───────────────────────────────────────────────────── def test_registered_in_registry() -> None: assert get_driver_class("mdm", "jamf") is JamfDriver def test_metadata() -> None: meta = JamfDriver.metadata() assert meta.kind == "mdm" assert meta.provider == "jamf" assert any(f.key == "base_url" and f.required for f in meta.fields) assert any(f.key == "client_secret" and f.is_secret for f in meta.fields) # ── seat usage / test_connection ───────────────────────────────── @respx.mock async def test_get_seat_usage() -> None: _mock_token() respx.get(url__regex=_COMPUTERS_RE).mock( return_value=httpx.Response(200, json={"totalCount": 45, "results": []}), ) respx.get(url__regex=_MOBILE_RE).mock( return_value=httpx.Response(200, json={"totalCount": 3, "results": []}), ) usage = await _driver().get_seat_usage() assert usage.computers == 45 assert usage.mobile_devices == 3 assert usage.total_devices == 48 @respx.mock async def test_test_connection_ok() -> None: _mock_token() respx.get(url__regex=_COMPUTERS_RE).mock( return_value=httpx.Response(200, json={"totalCount": 45, "results": []}), ) respx.get(url__regex=_MOBILE_RE).mock( return_value=httpx.Response(200, json={"totalCount": 0, "results": []}), ) result = await _driver().test_connection() assert result.ok is True assert result.detail["total_devices"] == 45 @respx.mock async def test_test_connection_auth_error() -> None: respx.post(_TOKEN_URL).mock( return_value=httpx.Response(401, json={"error": "invalid_client"}), ) result = await _driver().test_connection() assert result.ok is False # ── inventory ──────────────────────────────────────────────────── @respx.mock async def test_list_devices_flattens_fields() -> None: _mock_token() respx.get(url__regex=_COMPUTERS_RE).mock( return_value=httpx.Response( 200, json={ "totalCount": 1, "results": [_computer("10", email="a@pingcap.com", serial="C02XYZ")], }, ), ) respx.get(url__regex=_MOBILE_RE).mock( return_value=httpx.Response( 200, json={ "totalCount": 1, "results": [ { "id": "20", "name": "iPhone", "serialNumber": "DNQ1", "model": "iPhone 15", "osVersion": "17.5", "username": "bob", "managementId": "mgmt-20", }, ], }, ), ) devices = await _driver().list_devices() assert len(devices) == 2 mac = next(d for d in devices if d.device_type == "computer") assert mac.external_id == "10" assert mac.serial_number == "C02XYZ" assert mac.assigned_user_email == "a@pingcap.com" assert mac.management_id == "mgmt-10" assert mac.last_contact_at is not None mob = next(d for d in devices if d.device_type == "mobile") assert mob.serial_number == "DNQ1" assert mob.assigned_username == "bob" @respx.mock async def test_list_computers_pagination(monkeypatch: pytest.MonkeyPatch) -> None: from app.integrations.drivers import jamf as jamf_mod monkeypatch.setattr(jamf_mod, "_PAGE_SIZE", 1) _mock_token() def _computers(request: httpx.Request) -> httpx.Response: page = int(request.url.params.get("page", "0")) if page == 0: results = [_computer("1", email="x@p.com", serial="S1")] elif page == 1: results = [_computer("2", email="y@p.com", serial="S2")] else: results = [] return httpx.Response(200, json={"totalCount": 2, "results": results}) respx.get(url__regex=_COMPUTERS_RE).mock(side_effect=_computers) respx.get(url__regex=_MOBILE_RE).mock( return_value=httpx.Response(200, json={"totalCount": 0, "results": []}), ) devices = await _driver().list_devices() assert [d.external_id for d in devices] == ["1", "2"] # ── commands ───────────────────────────────────────────────────── @respx.mock async def test_lock_device_resolves_management_id() -> None: _mock_token() respx.get( url__regex=re.compile(r".*/api/v1/computers-inventory-detail/10.*"), ).mock( return_value=httpx.Response( 200, json={"general": {"managementId": "mgmt-10"}}, ), ) cmd_route = respx.post(f"{_BASE}/api/v2/mdm/commands").mock( return_value=httpx.Response(201, json=[{"id": "cmd-1"}]), ) res = await _driver().lock_device(device_type="computer", external_id="10") assert res.ok is True assert res.external_ref == "cmd-1" sent = cmd_route.calls.last.request import json as _json body = _json.loads(sent.content) assert body["clientData"][0]["managementId"] == "mgmt-10" assert body["commandData"]["commandType"] == "DEVICE_LOCK" @respx.mock async def test_wipe_device_sends_erase() -> None: _mock_token() respx.get( url__regex=re.compile(r".*/api/v1/computers-inventory-detail/10.*"), ).mock( return_value=httpx.Response( 200, json={"general": {"managementId": "mgmt-10"}}, ), ) cmd_route = respx.post(f"{_BASE}/api/v2/mdm/commands").mock( return_value=httpx.Response(201, json=[{"id": "cmd-2"}]), ) res = await _driver().wipe_device(device_type="computer", external_id="10") assert res.ok is True import json as _json body = _json.loads(cmd_route.calls.last.request.content) assert body["commandData"]["commandType"] == "ERASE_DEVICE" @respx.mock async def test_clear_assignment_computer() -> None: _mock_token() route = respx.patch( f"{_BASE}/api/v1/computers-inventory-detail/10", ).mock(return_value=httpx.Response(200, json={})) res = await _driver().clear_device_assignment( device_type="computer", external_id="10", ) assert res.ok is True import json as _json body = _json.loads(route.calls.last.request.content) assert body["userAndLocation"]["username"] == "" @respx.mock async def test_clear_assignment_404_idempotent() -> None: _mock_token() respx.patch( f"{_BASE}/api/v1/computers-inventory-detail/99", ).mock(return_value=httpx.Response(404, json={})) res = await _driver().clear_device_assignment( device_type="computer", external_id="99", ) assert res.ok is True assert res.already_done is True # ── token refresh ──────────────────────────────────────────────── @respx.mock async def test_token_refresh_on_401() -> None: respx.post(_TOKEN_URL).mock( return_value=httpx.Response( 200, json={"access_token": "tok-123", "expires_in": 1800}, ), ) calls = {"n": 0} def _computers(request: httpx.Request) -> httpx.Response: calls["n"] += 1 if calls["n"] == 1: return httpx.Response(401, json={"error": "expired"}) return httpx.Response(200, json={"totalCount": 7, "results": []}) respx.get(url__regex=_COMPUTERS_RE).mock(side_effect=_computers) count = await _driver()._total_count("/api/v1/computers-inventory") assert count == 7 assert calls["n"] == 2 # ── SSRF guard (#1648) ─────────────────────────────────────────── @respx.mock @pytest.mark.parametrize( "base_url", [ "http://169.254.169.254", # link-local / cloud IMDS, also non-https "https://169.254.169.254", # link-local over https "https://10.0.0.1", # RFC1918 private "http://127.0.0.1:8443", # loopback ], ) async def test_ssrf_guard_rejects_private_base_url(base_url: str) -> None: """#1648: a driver configured with a private / link-local / loopback ``base_url`` must raise ``UnsafeURLError`` from the token fetch BEFORE any HTTP request leaves the box — the driver mints a bearer token and can fire destructive MDM wipe commands, so an operator-supplied host pointing at internal infra is an SSRF + credential-exfil vector. We register a catch-all respx route that fails the test if hit — the guard must short-circuit before the token POST.""" cfg = {**_CONFIG, "base_url": base_url} def _boom(_request: httpx.Request) -> httpx.Response: raise AssertionError( "SSRF guard did not fire — an HTTP request escaped to " f"{base_url}", ) respx.route().mock(side_effect=_boom) # get_seat_usage → _total_count → _request → _get_token, all of which # assert on the resolved URL. The token fetch is first to touch the # network, so the guard fires there. with pytest.raises(UnsafeURLError): await _driver(cfg).get_seat_usage() @respx.mock async def test_ssrf_guard_rejects_private_url_on_request_path() -> None: """The guard is enforced on the request URL too (``_request``), not only the token URL — covers a caller-supplied absolute http path. Here the token host is safe (mocked) but a wipe command aimed at a private absolute URL must still be rejected before the POST.""" # Token endpoint on the safe mock host succeeds. _mock_token() def _boom(_request: httpx.Request) -> httpx.Response: raise AssertionError("SSRF guard did not fire on the request path") # Any non-token request that reaches the network is a failure. respx.post(f"{_BASE}/api/v2/mdm/commands").mock(side_effect=_boom) drv = _driver() with pytest.raises(UnsafeURLError): # An absolute private URL fed straight to _request must be blocked. await drv._request("GET", "http://169.254.169.254/latest/meta-data/") @respx.mock @pytest.mark.asyncio async def test_wipe_device_transient_5xx_not_retried() -> None: """Round-5 CRITICAL fix: a destructive MDM command POST must NOT be blind-retried on a transient 5xx — Jamf may have already queued the ERASE, so a retry would double-wipe. Exactly ONE POST is issued.""" _mock_token() respx.get( url__regex=re.compile(r".*/api/v1/computers-inventory-detail/10.*"), ).mock( return_value=httpx.Response( 200, json={"general": {"managementId": "mgmt-10"}}, ), ) cmd_route = respx.post(f"{_BASE}/api/v2/mdm/commands").mock( return_value=httpx.Response(503, text="Service Unavailable"), ) res = await _driver().wipe_device(device_type="computer", external_id="10") assert res.ok is False # No retry — the command was sent once, not 1 + len(backoff) times. assert cmd_route.calls.call_count == 1 @respx.mock @pytest.mark.asyncio async def test_wipe_device_gone_is_idempotent_success() -> None: """Round-5 fix: if the device is already gone from Jamf inventory (404 on the managementId lookup — already erased + unenrolled), wipe is an idempotent success (no command POST), so an offboarding retry after a successful wipe doesn't hard-fail and pin the leaver's blocker.""" _mock_token() respx.get( url__regex=re.compile(r".*/api/v1/computers-inventory-detail/10.*"), ).mock(return_value=httpx.Response(404, text="not found")) cmd_route = respx.post(f"{_BASE}/api/v2/mdm/commands").mock( return_value=httpx.Response(201, json=[{"id": "cmd-x"}]), ) res = await _driver().wipe_device(device_type="computer", external_id="10") assert res.ok is True assert res.already_done is True # No destructive command was sent for a device that's already gone. assert cmd_route.calls.call_count == 0