"""Provisioning binding lifecycle + dispatch through the new framework. Critical path: admin creates an integration → creates a binding → task executes → driver is invoked with binding_config merged into ProvisioningContext.extra. """ from __future__ import annotations import uuid import pytest from app.domains.asset.models import AssetSoftwareCatalog from app.domains.provisioning.models import ( ACTION_GRANT, EXEC_AUTO_API, SOURCE_REQUEST, STATUS_COMPLETED, STATUS_PENDING, ProvisioningTask, SoftwareConnector, ) from app.integrations.driver_base import ProvisioningResult from app.integrations.models import Integration def _admin_headers(admin_user): _, token = admin_user return {"Authorization": f"Bearer {token}"} async def _create_software(db_session, name: str = "Test SaaS") -> AssetSoftwareCatalog: sw = AssetSoftwareCatalog( id=uuid.uuid4(), name=name, asset_type="SaaS", status="active", ) db_session.add(sw) await db_session.flush() return sw async def _create_integration( db_session, *, kind: str, provider: str, slug: str, name: str = "Test", ) -> Integration: i = Integration( id=uuid.uuid4(), kind=kind, provider=provider, name=name, slug=slug, enabled=True, is_primary=False, config_public={}, ) db_session.add(i) await db_session.flush() return i @pytest.mark.asyncio async def test_create_binding_validates_integration_kind( client, admin_user, db_session, ): sw = await _create_software(db_session) # Try binding to an HRIS integration (wrong kind) hris = await _create_integration( db_session, kind="hris", provider="successfactors", slug="sf-wrong-kind", ) await db_session.commit() r = await client.post( "/api/v1/provisioning/connectors", json={ "software_id": str(sw.id), "integration_id": str(hris.id), "binding_config": {}, }, headers=_admin_headers(admin_user), ) assert r.status_code == 400 assert r.json()["errors"][0]["code"] == "INTEGRATION_WRONG_KIND" @pytest.mark.asyncio async def test_create_binding_rejects_missing_required_binding_field( client, admin_user, db_session, ): sw = await _create_software(db_session) gw = await _create_integration( db_session, kind="software_provisioning", provider="google_workspace", slug="gw-missing-binding", ) await db_session.commit() # Google Workspace requires `operation` (and when group_member also group_email). # Omit operation entirely. r = await client.post( "/api/v1/provisioning/connectors", json={ "software_id": str(sw.id), "integration_id": str(gw.id), "binding_config": {}, # no operation }, headers=_admin_headers(admin_user), ) assert r.status_code == 400 assert r.json()["errors"][0]["code"] == "MISSING_BINDING_CONFIG" @pytest.mark.asyncio async def test_create_binding_respects_visible_when( client, admin_user, db_session, ): """When operation=user_suspend, group_email is NOT required (hidden).""" sw = await _create_software(db_session) gw = await _create_integration( db_session, kind="software_provisioning", provider="google_workspace", slug="gw-visible-when", ) await db_session.commit() r = await client.post( "/api/v1/provisioning/connectors", json={ "software_id": str(sw.id), "integration_id": str(gw.id), "binding_config": {"operation": "user_suspend"}, # group_email is visible_when operation=group_member — not required here. }, headers=_admin_headers(admin_user), ) assert r.status_code == 200, r.text data = r.json()["data"] assert data["integration_provider"] == "google_workspace" assert data["binding_config"] == {"operation": "user_suspend"} @pytest.mark.asyncio async def test_create_binding_one_per_software( client, admin_user, db_session, ): sw = await _create_software(db_session) integ = await _create_integration( db_session, kind="software_provisioning", provider="manual", slug="manual-one-per-software", ) await db_session.commit() payload = { "software_id": str(sw.id), "integration_id": str(integ.id), "binding_config": {}, } r1 = await client.post( "/api/v1/provisioning/connectors", json=payload, headers=_admin_headers(admin_user), ) assert r1.status_code == 200 r2 = await client.post( "/api/v1/provisioning/connectors", json=payload, headers=_admin_headers(admin_user), ) assert r2.status_code == 400 assert r2.json()["errors"][0]["code"] == "CONNECTOR_ALREADY_EXISTS" @pytest.mark.asyncio async def test_provisioning_dispatch_passes_binding_config_to_driver( client, admin_user, db_session, employee_user, monkeypatch, ): """Core dispatch path — grant_seat receives binding_config in ctx.extra. Uses the Manual driver (no external call) so we can watch what the dispatcher constructs + fake its grant_seat to capture the ctx. """ employee, _token = employee_user sw = await _create_software(db_session, name="ManualSoft") integ = await _create_integration( db_session, kind="software_provisioning", provider="manual", slug="manual-dispatch-test", ) binding = SoftwareConnector( id=uuid.uuid4(), software_id=sw.id, integration_id=integ.id, binding_config={"custom_param": "hello"}, is_active=True, ) db_session.add(binding) task = ProvisioningTask( id=uuid.uuid4(), user_id=employee.id, software_id=sw.id, action=ACTION_GRANT, target_type="seat", target_id=None, source=SOURCE_REQUEST, execution_mode=EXEC_AUTO_API, status=STATUS_PENDING, context_data={"some_runtime_key": "runtime_value"}, ) db_session.add(task) await db_session.commit() captured_ctx: dict = {} async def _capturing_grant_seat(self, ctx): captured_ctx["user_email"] = ctx.user_email captured_ctx["extra"] = dict(ctx.extra) return ProvisioningResult(ok=True, external_ref="manual-ref") from app.integrations.drivers.manual_software import ManualSoftwareDriver monkeypatch.setattr( ManualSoftwareDriver, "grant_seat", _capturing_grant_seat, ) # Invoke task execution from app.domains.provisioning.service import ProvisioningService svc = ProvisioningService(db_session) updated = await svc.execute_task( task, trace_id=str(uuid.uuid4()), actor_user_id=admin_user[0].id, ) await db_session.commit() assert updated.status == STATUS_COMPLETED, updated.error_message # Binding_config flowed into ctx.extra assert captured_ctx["extra"]["custom_param"] == "hello" # task.context_data also merged (binding_config takes precedence for same keys) assert captured_ctx["extra"]["some_runtime_key"] == "runtime_value" assert captured_ctx["user_email"] == employee.work_email @pytest.mark.asyncio async def test_provisioning_test_connection_delegates_to_integration( client, admin_user, db_session, monkeypatch, ): """Provisioning's /connectors//test calls the integration driver's test_connection — not the old per-connector registry.""" sw = await _create_software(db_session, name="TestSoft") integ = await _create_integration( db_session, kind="software_provisioning", provider="manual", slug="manual-test-conn", ) binding = SoftwareConnector( id=uuid.uuid4(), software_id=sw.id, integration_id=integ.id, binding_config={}, is_active=True, ) db_session.add(binding) await db_session.commit() r = await client.post( f"/api/v1/provisioning/connectors/{binding.id}/test", headers=_admin_headers(admin_user), ) assert r.status_code == 200 body = r.json()["data"] assert body["success"] is True # Manual always returns ok @pytest.mark.asyncio async def test_connector_types_returns_software_provisioning_only( client, admin_user, ): """Legacy /provisioning/connector-types endpoint now filters to software_provisioning-kind drivers — not HRIS/cloud/approval.""" r = await client.get( "/api/v1/provisioning/connector-types", headers=_admin_headers(admin_user), ) assert r.status_code == 200 data = r.json()["data"] providers = {d["provider"] for d in data} # PR-B (2026-04-21) added zoom / slack / github as # software_provisioning drivers. Assert the expected set is a # subset — if more drivers get added, we want the test to flag # an unexpected addition, not fail on it silently. expected = { "google_workspace", "feishu_group", "manual", "zoom", "slack", "github", "jira", "nexuscrm", "microsoft_graph", } assert providers == expected, ( f"unexpected provider set. got={providers} expected={expected}" ) # All are kind=software_provisioning assert all(d["kind"] == "software_provisioning" for d in data) # Each has both fields and binding_fields populated in the metadata for d in data: assert "fields" in d and "binding_fields" in d