"""Unit tests for the Microsoft Graph software-provisioning driver. All HTTP is mocked via respx. Covers: - metadata registration + config/binding schema - test_connection (ok / error) - UPN resolution (localpart + upn_suffix; fallback) - grant_seat: needs-usageLocation PATCH, already-set, already-licensed idempotent, user-not-found, no-seats (countViolation), token refresh - revoke_seat: success, already-not-licensed, user-gone idempotent - list_subscribed_skus / list_managed_devices (pagination) """ from __future__ import annotations import re import httpx import pytest import respx from app.integrations.driver_base import ProvisioningContext from app.integrations.drivers.microsoft_graph import ( MSGRAPH_TAG_NO_SEATS, MSGRAPH_TAG_NO_USAGE_LOCATION, MSGRAPH_TAG_USER_NOT_FOUND, MicrosoftGraphDriver, ) from app.integrations.registry import get_driver_class _TENANT = "test-tenant" _SKU = "00e1ec7b-e4a3-40d1-9441-b69b597ab222" _CONFIG = { "tenant_id": _TENANT, "client_id": "test-client", "client_secret": "test-secret", } _BINDING = { "operation": "assign_license", "sku_id": _SKU, "upn_suffix": "pingcapjp.onmicrosoft.com", "usage_location": "JP", } _TOKEN_URL = f"https://login.microsoftonline.com/{_TENANT}/oauth2/v2.0/token" _USERS_RE = re.compile(r"https://graph\.microsoft\.com/v1\.0/users/[^/]+$") # The tenant-wide confirmation lookup (#2131) — no path segment, # so it deliberately does NOT match ``_USERS_RE``. _USERS_LOOKUP = "https://graph.microsoft.com/v1.0/users" _ASSIGN_RE = re.compile(r"https://graph\.microsoft\.com/v1\.0/users/[^/]+/assignLicense$") def _driver(cfg: dict | None = None) -> MicrosoftGraphDriver: return MicrosoftGraphDriver(integration_id="msgraph-test", config=cfg or _CONFIG) def _ctx(**extra) -> ProvisioningContext: merged = {**_BINDING, **extra} return ProvisioningContext(user_email="shuai.feng@pingcap.com", extra=merged) def _mock_token() -> None: respx.post(_TOKEN_URL).mock( return_value=httpx.Response( 200, json={"access_token": "tok-123", "expires_in": 3600}, ), ) # ── metadata ───────────────────────────────────────────────────── def test_registered_in_registry() -> None: cls = get_driver_class("software_provisioning", "microsoft_graph") assert cls is MicrosoftGraphDriver def test_metadata() -> None: meta = MicrosoftGraphDriver.metadata() assert meta.kind == "software_provisioning" assert meta.provider == "microsoft_graph" assert MicrosoftGraphDriver.supports_seat is True assert MicrosoftGraphDriver.requires_successor_on_revoke is False assert any(f.key == "client_secret" and f.is_secret for f in meta.fields) assert any(f.key == "sku_id" and f.required for f in meta.binding_fields) # ── upn resolution ─────────────────────────────────────────────── def test_resolve_upn_with_suffix() -> None: d = _driver() assert ( d._resolve_upn(_ctx()) == "shuai.feng@pingcapjp.onmicrosoft.com" ) def test_resolve_upn_fallback_no_suffix() -> None: d = _driver() ctx = ProvisioningContext( user_email="Alice@pingcap.com", extra={"sku_id": _SKU}, ) assert d._resolve_upn(ctx) == "alice@pingcap.com" # ── test_connection ────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_connection_ok() -> None: _mock_token() respx.get("https://graph.microsoft.com/v1.0/subscribedSkus").mock( return_value=httpx.Response(200, json={"value": [{"skuId": _SKU}]}), ) res = await _driver().test_connection() assert res.ok assert res.detail["sku_count"] == 1 @pytest.mark.asyncio @respx.mock async def test_connection_auth_error() -> None: respx.post(_TOKEN_URL).mock( return_value=httpx.Response(401, text="invalid_client"), ) res = await _driver().test_connection() assert not res.ok # ── grant_seat ─────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_grant_seat_sets_usage_location_then_assigns() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "usageLocation": None, "assignedLicenses": []}, ), ) patch_route = respx.patch(url__regex=_USERS_RE).mock( return_value=httpx.Response(204), ) assign_route = respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response(200, json={}), ) res = await _driver().grant_seat(_ctx()) assert res.ok assert res.external_ref == f"{_SKU}:u1" assert patch_route.called assert assign_route.called @pytest.mark.asyncio @respx.mock async def test_grant_seat_already_licensed_idempotent() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={ "id": "u1", "usageLocation": "JP", "assignedLicenses": [{"skuId": _SKU}], }, ), ) res = await _driver().grant_seat(_ctx()) assert res.ok assert res.raw_response["already_licensed"] is True @pytest.mark.asyncio @respx.mock async def test_grant_seat_user_not_found() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock(return_value=httpx.Response(404, json={})) res = await _driver().grant_seat(_ctx()) assert not res.ok assert res.raw_response["error_tag"] == MSGRAPH_TAG_USER_NOT_FOUND @pytest.mark.asyncio @respx.mock async def test_grant_seat_no_usage_location_no_default() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "usageLocation": None, "assignedLicenses": []}, ), ) # binding without a usage_location default res = await _driver().grant_seat(_ctx(usage_location="")) assert not res.ok assert res.raw_response["error_tag"] == MSGRAPH_TAG_NO_USAGE_LOCATION @pytest.mark.asyncio @respx.mock async def test_grant_seat_no_seats_count_violation() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "usageLocation": "JP", "assignedLicenses": []}, ), ) respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response( 400, json={"error": {"message": "countViolation: not enough"}}, ), ) res = await _driver().grant_seat(_ctx()) assert not res.ok assert res.raw_response["error_tag"] == MSGRAPH_TAG_NO_SEATS @pytest.mark.asyncio @respx.mock async def test_grant_seat_subscription_only_reports_no_seats_no_retry() -> None: """#1667: a "subscription"-only 400 previously no-op'd through the outer branch (which only returned for countViolation) and got retried 4× (~12s) for a permanent error. It must report NO_SEATS and stop retrying.""" _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "usageLocation": "JP", "assignedLicenses": []}, ), ) assign_route = respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response( 400, json={"error": {"message": "Subscription is not active for SKU"}}, ), ) res = await _driver().grant_seat(_ctx()) assert not res.ok assert res.raw_response["error_tag"] == MSGRAPH_TAG_NO_SEATS # Permanent error → single attempt, no 4× retry loop. assert assign_route.call_count == 1 # ── revoke_seat ────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_revoke_seat_success() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "assignedLicenses": [{"skuId": _SKU}]}, ), ) route = respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response(200, json={}), ) res = await _driver().revoke_seat(_ctx()) assert res.ok assert res.raw_response["revoked"] is True assert route.called @pytest.mark.asyncio @respx.mock async def test_revoke_seat_already_not_licensed() -> None: _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "assignedLicenses": []}, ), ) res = await _driver().revoke_seat(_ctx()) assert res.ok assert res.raw_response["already_not_licensed"] is True @pytest.mark.asyncio @respx.mock async def test_revoke_group_inherited_license_downgrades_to_manual() -> None: """A license INHERITED from a group cannot be removed per-user; Graph returns 400 'inherited from a group'. This must downgrade to capability_unsupported (→ manual_notify), not dead-letter as a permanent FAILED that pins the leaver's offboarding blocker.""" _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "assignedLicenses": [{"skuId": _SKU}]}, ), ) respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response( 400, json={"error": {"message": ( "License cannot be removed because it is inherited from a group." )}}, ), ) res = await _driver().revoke_seat(_ctx()) assert not res.ok assert res.capability_unsupported is True async def _noop_sleep(*_a, **_k) -> None: return None @pytest.mark.asyncio @respx.mock async def test_grant_seat_bare_subscription_mention_is_not_no_seats(monkeypatch) -> None: """Regression: a 400 whose body merely MENTIONS 'subscription' (e.g. a doc-URL) with no unavailability qualifier must NOT be misreported as NO_SEATS — that stopped the replication-lag retry and sent admins to buy licenses that were never the problem. It should retry as a generic 400.""" import app.integrations.drivers.microsoft_graph as mg monkeypatch.setattr(mg.asyncio, "sleep", _noop_sleep) _mock_token() respx.get(url__regex=_USERS_RE).mock( return_value=httpx.Response( 200, json={"id": "u1", "usageLocation": "JP", "assignedLicenses": []}, ), ) route = respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response( 400, json={"error": {"message": ( "Bad request; see https://learn.microsoft.com/subscription/reference" )}}, ), ) res = await _driver().grant_seat(_ctx()) assert not res.ok assert (res.raw_response or {}).get("error_tag") != MSGRAPH_TAG_NO_SEATS # Generic 400 → the retry loop ran (not short-circuited as permanent). assert route.call_count > 1 @pytest.mark.asyncio @respx.mock async def test_revoke_seat_user_gone_idempotent() -> None: """Genuinely absent user → idempotent success. Still true after #2131 — but the 404 alone is no longer enough. The driver now confirms with a tenant-wide mail/proxyAddresses lookup before declaring the licence revoked, so this test mocks that lookup returning an empty result set (= the tenant really has no such person). """ _mock_token() respx.get(url__regex=_USERS_RE).mock(return_value=httpx.Response(404, json={})) respx.get(_USERS_LOOKUP).mock( return_value=httpx.Response(200, json={"value": []}), ) res = await _driver().revoke_seat(_ctx()) assert res.ok assert res.raw_response["user_gone"] is True assert res.raw_response["confirmed_absent_by_mail_lookup"] is True @pytest.mark.asyncio @respx.mock async def test_revoke_seat_404_on_a_misderived_upn_retries_the_real_one() -> None: """#2131 — the whole point of the confirmation lookup. ``_resolve_upn`` is string surgery (work_email local-part + binding ``upn_suffix``). A wrong suffix, or an M365 naming convention that differs from the Helix local-part, yields a UPN nobody has — and the old code read that 404 as "already revoked", marking the task successful and clearing the offboarding blocker while the leaver kept the licence. """ _mock_token() real_upn = "s.feng@pingcap.onmicrosoft.com" respx.get(_USERS_LOOKUP).mock( return_value=httpx.Response( 200, json={"value": [{"id": "u1", "userPrincipalName": real_upn}]}, ), ) route = respx.get(url__regex=_USERS_RE) route.side_effect = [ httpx.Response(404, json={}), # derived UPN httpx.Response(200, json={ # real UPN "id": "u1", "assignedLicenses": [{"skuId": _SKU}], }), ] respx.post(url__regex=_ASSIGN_RE).mock( return_value=httpx.Response(200, json={"id": "u1"}), ) res = await _driver().revoke_seat(_ctx()) assert res.ok assert not res.raw_response.get("user_gone"), ( "a mis-derived UPN was reported as 'user already gone' — the " "leaver keeps the M365 licence and the blocker clears" ) @pytest.mark.asyncio @respx.mock async def test_revoke_seat_404_with_an_inconclusive_lookup_fails() -> None: """#2131 — never claim success on an unverified 404. If the confirmation lookup itself cannot run (missing ``User.Read.All``, transient error), the honest answer is "unknown". A false "revoked" is the one outcome offboarding must never record. """ _mock_token() respx.get(url__regex=_USERS_RE).mock(return_value=httpx.Response(404, json={})) respx.get(_USERS_LOOKUP).mock(return_value=httpx.Response(403, json={})) res = await _driver().revoke_seat(_ctx()) assert not res.ok assert res.raw_response["lookup_inconclusive"] is True # ── read helpers ───────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_list_subscribed_skus() -> None: _mock_token() respx.get("https://graph.microsoft.com/v1.0/subscribedSkus").mock( return_value=httpx.Response( 200, json={"value": [{ "skuId": _SKU, "skuPartNumber": "BP", "prepaidUnits": {"enabled": 35}, "consumedUnits": 30, }]}, ), ) skus = await _driver().list_subscribed_skus() assert skus == [{ "sku_id": _SKU, "sku_part_number": "BP", "enabled": 35, "consumed": 30, }] @pytest.mark.asyncio @respx.mock async def test_list_managed_devices_paginates() -> None: _mock_token() base = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" next_link = base + "?$skiptoken=PAGE2" respx.get(url__regex=re.escape(base) + r"\?\$select").mock( return_value=httpx.Response( 200, json={"value": [{"id": "d1"}], "@odata.nextLink": next_link}, ), ) respx.get(next_link).mock( return_value=httpx.Response(200, json={"value": [{"id": "d2"}]}), ) devices = await _driver().list_managed_devices() assert [d["id"] for d in devices] == ["d1", "d2"]