from __future__ import annotations import json from types import SimpleNamespace import unittest from unittest.mock import patch from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.search import SearchAuthorizationRequest, SearchBackfillRequest from govoplan_core.core.recovery import RecoveryOperation, RecoveryStatus from govoplan_core.core.runtime_coordination import ( RuntimeIdentity, bind_process_runtime_identity, ) from govoplan_core.db.base import Base from govoplan_connectors.backend.db.models import ( ConnectorConfiguration, ConnectorDefinition, ConnectorDefinitionRevision, ConnectorServiceDeskObject, ConnectorServiceDeskProfile, ConnectorServiceDeskSyncRun, ) from govoplan_connectors.backend.service_desk_connector import ( SERVICE_DESK_PROVIDER_ID, SERVICE_DESK_RESOURCE_TYPE, ServiceDeskConnectorError, create_profile, discover_profile, list_objects, synchronize_profile, update_profile, update_ticket, ) from govoplan_connectors.backend.service_desk_schemas import ( ServiceDeskDynamicFieldMapping, ServiceDeskProfileCreateRequest, ServiceDeskProfileUpdateRequest, ServiceDeskQueueMapping, ServiceDeskRouteMapping, ServiceDeskSyncRequest, ServiceDeskTicketUpdateRequest, ) from govoplan_connectors.backend.service_desk_search import ExternalServiceDeskSearchSource from govoplan_connectors.backend.service_desk_transport import ( ServiceDeskChangeBatch, ServiceDeskTransportError, ServiceDeskUpdateResult, ) ALL_SCOPES = frozenset( { "connectors:service_desk:read", "connectors:service_desk:admin", "connectors:service_desk:sync", "connectors:service_desk:update", } ) def principal( tenant_id: str = "tenant-1", *, groups: frozenset[str] = frozenset({"agents"}), ) -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id="account-1", membership_id="membership-1", tenant_id=tenant_id, scopes=ALL_SCOPES, group_ids=groups, ), account=SimpleNamespace(id="account-1"), user=SimpleNamespace(id="account-1"), ) def ticket( *, ticket_id: str = "42", revision: str = "2026-08-22T10:00:00Z", queue: str = "Residents", acl: list[str] | None = None, ) -> dict[str, object]: return { "TicketID": ticket_id, "TicketNumber": f"20260822{ticket_id}", "Title": "Resident parking inquiry", "Queue": queue, "QueueID": "3", "State": "open", "StateID": "4", "Priority": "3 normal", "PriorityID": "3", "Owner": "agent.a", "OwnerID": "7", "CustomerUserID": "citizen-17", "CustomerID": "organization-9", "Changed": revision, "GovOPlaNVisibility": "restricted", "GovOPlaNACL": acl or ["group:agents"], "DynamicField": [{"Name": "PermitKind", "Value": "resident"}], "Article": [ { "ArticleID": "71", "Subject": "Question", "Body": "Please verify the submitted address.", "Created": "2026-08-22T09:58:00Z", "Attachment": [ { "AttachmentID": "91", "Filename": "address.pdf", "Filesize": 1234, "ContentType": "application/pdf", "ContentBase64": "not-retained", } ], } ], } class StaticTransport: def __init__(self) -> None: self.batches: list[ServiceDeskChangeBatch] = [] self.change_calls: list[dict[str, object]] = [] self.update_calls = 0 self.update_result = ServiceDeskUpdateResult( ticket={ **ticket(revision="2026-08-22T11:00:00Z"), "State": "pending reminder", }, revision="2026-08-22T11:00:00Z", evidence={"verified": True}, ) self.update_error: ServiceDeskTransportError | None = None def discover(self, **kwargs): del kwargs return { "product": "znuny", "product_version": "7.1.4", "api_family": "generic_interface_rest", "capabilities": [ "discover", "link", "search", "read", "synchronize", "publish", ], "maturity": "synchronize", "health_status": "healthy", "revision": "discovery-1", "diagnostics": [], "evidence": {"fixture": True}, } def changes(self, **kwargs): self.change_calls.append(dict(kwargs)) if not self.batches: raise AssertionError("No deterministic service-desk batch remains") return self.batches.pop(0) def update_ticket(self, **kwargs): del kwargs self.update_calls += 1 if self.update_error is not None: raise self.update_error return self.update_result class RecordingSearchWriter: def __init__(self) -> None: self.upserts: list[object] = [] self.deletes: list[str] = [] def upsert_document(self, _session, _principal, *, document) -> None: self.upserts.append(document) def delete_document( self, _session, _principal, *, tenant_id, module_id, resource_type, resource_id, ) -> bool: del tenant_id, module_id, resource_type self.deletes.append(resource_id) return True def enqueue_change(self, _session, *, change) -> bool: del change return True class SearchRegistry: def __init__(self, writer: RecordingSearchWriter) -> None: self.writer = writer def has_capability(self, name: str) -> bool: return name == "search.index_writer" def capability(self, name: str): if not self.has_capability(name): raise KeyError(name) return self.writer class ServiceDeskConnectorTests(unittest.TestCase): def setUp(self) -> None: bind_process_runtime_identity( RuntimeIdentity( installation_id="service-desk-connector-tests", node_id="node-1", incarnation="incarnation-1", role="worker", software_version="test", composition_hash="a" * 64, ) ) self.engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(self.engine) self.session = Session(self.engine) self.audit = patch("govoplan_connectors.backend.service_desk_connector.audit_event") self.audit.start() self.credential = patch( "govoplan_connectors.backend.service_desk_connector._credential", return_value={"user_login": "connector", "password": "secret"}, ) self.credential.start() self.transport = StaticTransport() self.configuration_id = self._seed_configuration() self.profile_id = self._create_profile() def tearDown(self) -> None: bind_process_runtime_identity(None) self.credential.stop() self.audit.stop() self.session.close() self.engine.dispose() def _seed_configuration(self) -> str: definition = ConnectorDefinition( id="definition-1", tenant_id="tenant-1", definition_key="service-desk.znuny", name="Znuny", status="active", current_revision=1, local_definition=True, ) self.session.add(definition) self.session.add( ConnectorDefinitionRevision( id="definition-revision-1", definition_id=definition.id, revision=1, specification={"provider": "znuny", "protocol": "generic_interface_rest"}, definition_hash="definition-hash", origin="local", created_by="account-1", ) ) configuration = ConnectorConfiguration( id="configuration-1", tenant_id="tenant-1", definition_id=definition.id, name="Institutional service desk", status="active", endpoint_url="https://support.example.invalid/znuny/nph-genericinterface.pl/Webservice/GovOPlaN", credential_ref="credential-envelope-1", base_definition_revision=1, local_overrides={}, protected_paths=[], effective_configuration={ "provider": "znuny", "protocol": "generic_interface_rest", }, effective_hash="configuration-hash", resource_revision=1, ambiguity_policy="manual_review", updated_by="account-1", ) self.session.add(configuration) self.session.flush() return configuration.id def _create_profile(self) -> str: item = create_profile( self.session, principal(), ServiceDeskProfileCreateRequest( configuration_id=self.configuration_id, integration_mode="synchronize", desired_maturity="synchronize", source_authority_mode="governed_sync", default_visibility="restricted", default_acl_tokens=["group:service-desk-managers"], routes=ServiceDeskRouteMapping( update_path="/Ticket/{ticket_id}", ticket_web_url_template="https://support.example.invalid/ticket/{ticket_id}", ), queue_mappings=[ ServiceDeskQueueMapping( source_queue="Residents", target_queue_ref="helpdesk:residents", visibility="restricted", acl_tokens=["group:agents"], ) ], dynamic_field_mappings=[ ServiceDeskDynamicFieldMapping( source_name="PermitKind", target_name="permit_kind", ) ], ), ) return item.id def _discover(self) -> None: discover_profile( self.session, principal(), profile_id=self.profile_id, transport=self.transport, ) def _sync(self, raw: list[dict[str, object]], *, key: str, complete: bool = True): self.transport.batches.append( ServiceDeskChangeBatch( changes=tuple(raw), next_cursor=( None if complete else json.dumps( {"kind": "full", "offset": len(raw), "fingerprint": "fixture"} ) ), complete=complete, high_watermark="2026-08-22T10:00:00Z", live_ids=tuple(str(value["TicketID"]) for value in raw) if complete else None, evidence={"fixture": True}, ) ) return synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest(idempotency_key=key), transport=self.transport, registry=None, ) def test_mapping_replay_cursor_transition_and_search_acl(self) -> None: self._discover() run = self._sync([ticket()], key="sync-1") self.assertEqual({"created": 1}, run.counts) self.assertEqual("delta", json.loads(run.cursor_after or "{}").get("kind")) self.assertIn("attachment_content_omitted", {item.code for item in run.diagnostics}) objects, _cursor = list_objects(self.session, principal(), profile_id=self.profile_id) self.assertEqual(1, len(objects)) item = objects[0] self.assertEqual("helpdesk:residents", item.mapped_data["target_queue_ref"]) self.assertEqual("resident", item.mapped_data["dynamic_fields"]["permit_kind"]) self.assertFalse(item.mapped_data["attachments"][0]["content_retained"]) self.assertEqual("article", item.mapped_data["articles"][0]["reference"]["object_type"]) replay = synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest(idempotency_key="sync-1"), transport=self.transport, registry=None, ) self.assertEqual(run.id, replay.id) self.assertEqual(1, len(self.transport.change_calls)) source = ExternalServiceDeskSearchSource() document = source.backfill( self.session, request=SearchBackfillRequest( tenant_id="tenant-1", provider_id=SERVICE_DESK_PROVIDER_ID, resource_type=SERVICE_DESK_RESOURCE_TYPE, rebuild_id="rebuild-1", ), ).documents[0] authorization = SearchAuthorizationRequest( reference=document.reference, source_revision=document.source_revision, ) self.assertTrue(source.authorize(self.session, principal(), requests=[authorization])[document.reference.key]) self.assertFalse( source.authorize( self.session, principal(groups=frozenset({"other"})), requests=[authorization], )[document.reference.key] ) def test_paged_full_stays_full_then_switches_to_delta(self) -> None: self._discover() first = self._sync([ticket()], key="page-1", complete=False) self.assertEqual("full", json.loads(first.cursor_after or "{}").get("kind")) second = self._sync([ticket(ticket_id="43")], key="page-2", complete=True) self.assertEqual("delta", json.loads(second.cursor_after or "{}").get("kind")) self.assertTrue(self.transport.change_calls[0]["force_full"]) self.assertTrue(self.transport.change_calls[1]["force_full"]) def test_profile_updates_preserve_provider_acl_until_the_next_sync(self) -> None: self._discover() self._sync([ticket(acl=["group:provider-agents"])], key="provider-acl") profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=profile.resource_revision, default_visibility="tenant", default_acl_tokens=[], ), registry=None, ) stored = self.session.scalar( select(ConnectorServiceDeskObject).where( ConnectorServiceDeskObject.profile_id == self.profile_id, ConnectorServiceDeskObject.external_id == "42", ) ) self.assertEqual("restricted", stored.visibility) self.assertEqual(["group:provider-agents"], stored.acl_tokens) self.assertEqual("provider", stored.mapped_data["permission_source"]) def test_unchanged_editor_payload_does_not_reset_discovery_or_cursor(self) -> None: self._discover() self._sync([ticket()], key="before-noop-save") profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) cursor_before = profile.last_sync_cursor discovered_at = profile.discovered_at update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=profile.resource_revision, integration_mode=profile.integration_mode, desired_maturity=profile.desired_maturity, source_authority_mode=profile.source_authority_mode, default_visibility=profile.default_visibility, default_acl_tokens=list(profile.default_acl_tokens), routes=ServiceDeskRouteMapping.model_validate(profile.routes), queue_mappings=[ ServiceDeskQueueMapping.model_validate(value) for value in profile.queue_mappings ], dynamic_field_mappings=[ ServiceDeskDynamicFieldMapping.model_validate(value) for value in profile.dynamic_field_mappings ], ), registry=None, ) self.assertEqual(cursor_before, profile.last_sync_cursor) self.assertEqual(discovered_at, profile.discovered_at) def test_route_or_configuration_changes_require_rediscovery(self) -> None: self._discover() self._sync([ticket()], key="before-route-change") profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=profile.resource_revision, routes=ServiceDeskRouteMapping( search_path="/GovOPlaN/Ticket/Search", ticket_path="/Ticket/{ticket_id}", update_path="/Ticket/{ticket_id}", ), ), registry=None, ) self.assertIsNone(profile.discovered_at) self.assertEqual([], profile.capabilities) stored = self.session.scalar( select(ConnectorServiceDeskObject).where( ConnectorServiceDeskObject.profile_id == self.profile_id, ConnectorServiceDeskObject.external_id == "42", ) ) self.assertEqual("deleted", stored.status) with self.assertRaisesRegex(ServiceDeskConnectorError, "Discover the provider"): synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest(idempotency_key="route-stale"), transport=self.transport, registry=None, ) self._discover() self._sync([ticket()], key="after-route-rediscovery") self.assertEqual("active", stored.status) configuration = self.session.get(ConnectorConfiguration, self.configuration_id) configuration.resource_revision += 1 configuration.effective_hash = "configuration-hash-changed" self.session.commit() self.assertEqual( (), ExternalServiceDeskSearchSource() .backfill( self.session, request=SearchBackfillRequest( tenant_id="tenant-1", provider_id=SERVICE_DESK_PROVIDER_ID, resource_type=SERVICE_DESK_RESOURCE_TYPE, rebuild_id="stale-configuration", ), ) .documents, ) with self.assertRaisesRegex(ServiceDeskConnectorError, "configuration changed"): synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest(idempotency_key="configuration-stale"), transport=self.transport, registry=None, ) self._discover() self.assertEqual("deleted", stored.status) self.assertIsNone(profile.last_sync_cursor) def test_full_sync_reprojects_unchanged_tickets_into_search(self) -> None: self._discover() self._sync([ticket()], key="initial-search-projection") writer = RecordingSearchWriter() self.transport.batches.append( ServiceDeskChangeBatch( changes=(ticket(),), next_cursor=None, complete=True, high_watermark="2026-08-22T10:00:00Z", live_ids=("42",), evidence={"fixture": True}, ) ) run = synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest( idempotency_key="full-search-reprojection", mode="full", ), transport=self.transport, registry=SearchRegistry(writer), ) self.assertEqual({"unchanged": 1}, run.counts) self.assertEqual(1, len(writer.upserts)) self.assertIsNone(self.transport.change_calls[-1]["cursor"]) def test_delta_cannot_bootstrap_without_a_completed_full_sync(self) -> None: self._discover() with self.assertRaisesRegex(ServiceDeskConnectorError, "completed full"): synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest( idempotency_key="unsafe-delta-bootstrap", mode="delta", ), transport=self.transport, registry=None, ) self.assertEqual([], self.transport.change_calls) def test_link_mode_omits_top_level_attachment_metadata(self) -> None: self._discover() profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=profile.resource_revision, integration_mode="link", desired_maturity="link", source_authority_mode="linked_reference", ), registry=None, ) raw = ticket() raw.pop("Article") raw.pop("DynamicField") raw["Attachment"] = [ { "AttachmentID": "top-1", "Filename": "metadata-only.pdf", "Filesize": 42, } ] run = self._sync([raw], key="link-refresh") objects, _cursor = list_objects( self.session, principal(), profile_id=self.profile_id, ) self.assertEqual([], objects[0].mapped_data["attachments"]) self.assertIn("link_mode_content_omitted", {item.code for item in run.diagnostics}) self.assertTrue(self.transport.change_calls[-1]["routes"]["_identity_only"]) def test_excluded_queue_removes_projection_and_profile_policy_fails_closed(self) -> None: self._discover() self._sync([ticket()], key="sync-active") profile = self.session.get(ConnectorServiceDeskProfile, self.profile_id) update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=profile.resource_revision, queue_mappings=[ ServiceDeskQueueMapping( source_queue="Residents", include=False, visibility="restricted", acl_tokens=["group:agents"], ) ], ), registry=None, ) stored = self.session.scalar( select(ConnectorServiceDeskObject).where( ConnectorServiceDeskObject.profile_id == self.profile_id, ConnectorServiceDeskObject.external_id == "42", ) ) self.assertEqual("deleted", stored.status) self.assertEqual( (), ExternalServiceDeskSearchSource() .backfill( self.session, request=SearchBackfillRequest( tenant_id="tenant-1", provider_id=SERVICE_DESK_PROVIDER_ID, resource_type=SERVICE_DESK_RESOURCE_TYPE, rebuild_id="excluded-rebuild", ), ) .documents, ) self.transport.batches.append( ServiceDeskChangeBatch( changes=(ticket(revision="2026-08-22T10:30:00Z"),), next_cursor=json.dumps( {"kind": "delta", "changed": "2026-08-22T10:30:00Z", "seen": ["42"]} ), complete=True, high_watermark="2026-08-22T10:30:00Z", live_ids=None, evidence={"fixture": True}, ) ) run = synchronize_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskSyncRequest(idempotency_key="sync-excluded"), transport=self.transport, registry=None, ) self.assertEqual({"ignored": 1}, run.counts) self.assertEqual("deleted", stored.status) with self.assertRaises(ServiceDeskConnectorError): update_profile( self.session, principal(), profile_id=self.profile_id, payload=ServiceDeskProfileUpdateRequest( expected_resource_revision=self.session.get( ConnectorServiceDeskProfile, self.profile_id ).resource_revision, integration_mode="link", desired_maturity="synchronize", source_authority_mode="linked_reference", ), registry=None, ) def test_governed_update_replay_and_unknown_outcome(self) -> None: self._discover() self._sync([ticket()], key="sync-before-update") payload = ServiceDeskTicketUpdateRequest( idempotency_key="update-1", expected_external_revision="2026-08-22T10:00:00Z", state="pending reminder", ) result = update_ticket( self.session, principal(), profile_id=self.profile_id, external_ticket_id="42", payload=payload, transport=self.transport, registry=None, durable_recovery=True, ) replay = update_ticket( self.session, principal(), profile_id=self.profile_id, external_ticket_id="42", payload=payload, transport=self.transport, registry=None, durable_recovery=False, ) self.assertTrue(result.accepted) self.assertEqual(result.run.id, replay.run.id) self.assertEqual(1, self.transport.update_calls) recovery = self.session.scalar( select(RecoveryOperation).where( RecoveryOperation.resource_type == SERVICE_DESK_RESOURCE_TYPE, RecoveryOperation.resource_id == "42", ) ) self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status) self.transport.update_error = ServiceDeskTransportError( "provider_unavailable", "No conclusive provider response.", retryable=True, outcome_unknown=True, ) with self.assertRaisesRegex(ServiceDeskConnectorError, "outcome is unknown"): update_ticket( self.session, principal(), profile_id=self.profile_id, external_ticket_id="42", payload=ServiceDeskTicketUpdateRequest( idempotency_key="update-unknown", expected_external_revision="2026-08-22T11:00:00Z", priority="4 high", ), transport=self.transport, registry=None, durable_recovery=False, ) unresolved = self.session.scalar( select(ConnectorServiceDeskSyncRun).where( ConnectorServiceDeskSyncRun.idempotency_key == "update-unknown" ) ) self.assertEqual("outcome_unknown", unresolved.status) def test_tenant_isolation(self) -> None: with self.assertRaisesRegex(ServiceDeskConnectorError, "not found"): list_objects(self.session, principal("tenant-2"), profile_id=self.profile_id) def test_malformed_continuous_batch_is_atomic_and_evidenced(self) -> None: self._discover() malformed = ticket() malformed.pop("Changed") with self.assertRaisesRegex(ServiceDeskConnectorError, "change timestamp"): self._sync([malformed], key="sync-malformed") self.assertIsNone( self.session.scalar( select(ConnectorServiceDeskObject).where( ConnectorServiceDeskObject.profile_id == self.profile_id ) ) ) failed = self.session.scalar( select(ConnectorServiceDeskSyncRun).where( ConnectorServiceDeskSyncRun.idempotency_key == "sync-malformed" ) ) self.assertEqual("failed", failed.status) self.assertEqual("change_timestamp_missing", failed.diagnostics[0]["code"]) if __name__ == "__main__": unittest.main()