Files
govoplan-connectors/tests/test_service_desk_connector.py
T
zemion b277e8d7ac
Module Package Release / publish-packages (push) Successful in 12s
Release Connectors v0.1.22 with service-desk federation
2026-08-22 16:11:07 +02:00

807 lines
29 KiB
Python

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()