Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 16:11:07 +02:00
parent 79e2315e89
commit b277e8d7ac
22 changed files with 7348 additions and 13 deletions
+4 -1
View File
@@ -24,7 +24,7 @@ class ConnectorsMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"b9e0f1a2c3d4",
"c0f1a2b3c4d5",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertTrue(
@@ -39,6 +39,9 @@ class ConnectorsMigrationTests(unittest.TestCase):
"connector_knowledge_profiles",
"connector_knowledge_objects",
"connector_knowledge_sync_runs",
"connector_service_desk_profiles",
"connector_service_desk_objects",
"connector_service_desk_sync_runs",
}.issubset(inspect(connection).get_table_names())
)
finally:
+101 -1
View File
@@ -7,9 +7,13 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_connectors.backend.db.models import (
ConnectorConfiguration,
ConnectorKnowledgeObject,
ConnectorKnowledgeProfile,
ConnectorKnowledgeSyncRun,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
ConnectorServiceDeskSyncRun,
ConnectorSanctionsAcquisitionRun,
ConnectorSanctionsSnapshot,
ConnectorTabularSource,
@@ -18,9 +22,11 @@ from govoplan_connectors.backend.manifest import manifest
from govoplan_connectors.backend.provider_state import (
KNOWLEDGE_PROVIDER_ID,
SANCTIONS_PROVIDER_ID,
SERVICE_DESK_PROVIDER_ID,
TABULAR_PROVIDER_ID,
knowledge_provider_states,
sanctions_provider_states,
service_desk_provider_states,
tabular_provider_states,
)
from govoplan_core.core.provider_governance import ExternalProviderStateContext
@@ -109,7 +115,12 @@ class ConnectorsProviderStateTests(unittest.TestCase):
self.assertNotIn("secret-source-name", rendered)
self.assertNotIn("source.example.test", rendered)
self.assertEqual(
{TABULAR_PROVIDER_ID, SANCTIONS_PROVIDER_ID, KNOWLEDGE_PROVIDER_ID},
{
TABULAR_PROVIDER_ID,
SANCTIONS_PROVIDER_ID,
KNOWLEDGE_PROVIDER_ID,
SERVICE_DESK_PROVIDER_ID,
},
{item.provider_id for item in manifest.external_provider_state_providers},
)
@@ -172,6 +183,95 @@ class ConnectorsProviderStateTests(unittest.TestCase):
self.assertNotIn("group:secret-acl", rendered)
self.assertNotIn("configuration-secret", rendered)
def test_service_desk_state_reports_recovery_without_ticket_or_acl_data(self) -> None:
now = datetime.now(UTC)
configuration = ConnectorConfiguration(
id="configuration-secret",
tenant_id="tenant-1",
definition_id="definition-1",
name="Secret service desk",
status="active",
base_definition_revision=1,
local_overrides={},
protected_paths=[],
effective_configuration={},
effective_hash="configuration-hash",
resource_revision=3,
ambiguity_policy="manual_review",
)
profile = ConnectorServiceDeskProfile(
id="service-desk-profile-1",
tenant_id="tenant-1",
configuration_id="configuration-secret",
status="active",
integration_mode="synchronize",
product="znuny",
product_version="7.1.4",
desired_maturity="synchronize",
discovered_maturity="synchronize",
source_authority_mode="governed_sync",
default_visibility="restricted",
default_acl_tokens=["group:secret-acl"],
routes={"secret": "route"},
queue_mappings=[{"secret": "queue"}],
dynamic_field_mappings=[{"secret": "field"}],
capabilities=["read", "search", "synchronize", "publish"],
discovered_configuration_revision=3,
discovered_configuration_hash="configuration-hash",
health_status="healthy",
discovered_at=now,
)
ticket = ConnectorServiceDeskObject(
id="service-desk-object-1",
tenant_id="tenant-1",
profile_id=profile.id,
object_type="ticket",
external_id="42",
external_ticket_number="secret-number",
title="Secret ticket title",
status="active",
source_revision="2026-08-22T10:00:00Z",
content_hash="e" * 64,
visibility="restricted",
acl_tokens=["group:secret-acl"],
mapped_data={"secret": "ticket content"},
provenance={"secret": "provider evidence"},
observed_at=now,
)
run = ConnectorServiceDeskSyncRun(
id="service-desk-run-1",
tenant_id="tenant-1",
profile_id=profile.id,
mode="update",
idempotency_key="secret-key",
request_hash="f" * 64,
status="outcome_unknown",
started_at=now,
finished_at=now,
)
self.session.add_all((configuration, profile, ticket, run))
self.session.commit()
state = service_desk_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual(SERVICE_DESK_PROVIDER_ID, state.provider_id)
self.assertTrue(state.metrics["discovery_current"])
self.assertEqual("pending", state.conflict)
self.assertEqual("attention", state.recovery)
rendered = str(state.to_dict())
for secret in (
"configuration-secret",
"secret-number",
"Secret ticket title",
"group:secret-acl",
"ticket content",
"provider evidence",
"secret-key",
):
self.assertNotIn(secret, rendered)
if __name__ == "__main__":
unittest.main()
+806
View File
@@ -0,0 +1,806 @@
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()
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import json
from urllib.parse import parse_qs, urlsplit
import unittest
from unittest.mock import patch
from govoplan_core.security.http_fetch import HttpFetchResponse
from govoplan_connectors.backend.service_desk_transport import (
HttpServiceDeskTransport,
ServiceDeskTransportError,
)
from govoplan_connectors.backend.service_desk_schemas import ServiceDeskRouteMapping
def response(payload: dict[str, object], *, headers: dict[str, str] | None = None):
return HttpFetchResponse(
status=200,
headers={"Content-Type": "application/json", **(headers or {})},
body=json.dumps(payload).encode(),
)
class ServiceDeskTransportTests(unittest.TestCase):
def setUp(self) -> None:
self.transport = HttpServiceDeskTransport()
self.endpoint = "https://support.example.test/znuny/nph-genericinterface.pl/Webservice/GovOPlaN"
self.routes = {
"search_path": "/Ticket/Search",
"ticket_path": "/Ticket/{ticket_id}",
"search_method": "POST",
"ticket_method": "GET",
}
def test_governed_routes_reject_embedded_authentication_controls(self) -> None:
for field, value in (
("search_path", "/Ticket/Search?Password=secret"),
(
"ticket_web_url_template",
"https://desk.example.test/ticket/{ticket_id}?SessionID=secret",
),
):
with self.subTest(field=field), self.assertRaisesRegex(
ValueError, "authentication controls"
):
ServiceDeskRouteMapping(**{field: value})
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_discovery_uses_governed_route_header_auth_and_version(self, fetch) -> None:
fetch.return_value = response(
{"TicketID": []}, headers={"X-Znuny-Version": "7.1.4"}
)
discovery = self.transport.discover(
endpoint_url=self.endpoint,
credential={"user_login": "connector", "password": "secret"},
routes={**self.routes, "search_filters": {"QueueIDs": [3, 7]}},
)
call = fetch.call_args
self.assertEqual("POST", call.kwargs["method"])
self.assertTrue(call.args[0].endswith("/Ticket/Search"))
self.assertEqual("connector", call.kwargs["headers"]["X-OTRS-Header-UserLogin"])
self.assertIn(
"X-OTRS-Header-Password",
call.kwargs["redirect_sensitive_headers"],
)
self.assertNotIn("secret", call.args[0])
self.assertEqual([3, 7], json.loads(call.kwargs["body"])["QueueIDs"])
self.assertEqual("znuny", discovery["product"])
self.assertEqual("synchronize", discovery["maturity"])
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_get_ticket_flags_are_query_parameters_without_secrets(self, fetch) -> None:
fetch.side_effect = (
response({"TicketID": ["42"]}),
response({"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]}),
)
batch = self.transport.changes(
endpoint_url=self.endpoint,
credential={"user_login": "connector", "password": "secret"},
routes=self.routes,
cursor=None,
limit=100,
force_full=True,
)
query = parse_qs(urlsplit(fetch.call_args_list[1].args[0]).query)
self.assertEqual(["1"], query["AllArticles"])
self.assertEqual(["0"], query["GetAttachmentContents"])
self.assertNotIn("UserLogin", query)
self.assertEqual(1, len(batch.changes))
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_link_identity_reads_do_not_request_articles_attachments_or_dynamic_fields(
self, fetch
) -> None:
fetch.side_effect = (
response({"TicketID": ["42"]}),
response(
{"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]}
),
)
self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes={**self.routes, "_identity_only": True},
cursor=None,
limit=100,
force_full=True,
)
query = parse_qs(urlsplit(fetch.call_args_list[1].args[0]).query)
self.assertEqual(["0"], query["AllArticles"])
self.assertEqual(["0"], query["Attachments"])
self.assertEqual(["0"], query["DynamicFields"])
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_paged_full_cursor_preserves_cumulative_high_watermark(self, fetch) -> None:
fetch.side_effect = (
response({"TicketID": ["41", "42"]}),
response({"Ticket": [{"TicketID": "41", "Changed": "2026-08-22T12:00:00Z"}]}),
response({"TicketID": ["41", "42"]}),
response({"Ticket": [{"TicketID": "42", "Changed": "2026-08-22T10:00:00Z"}]}),
)
first = self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes=self.routes,
cursor=None,
limit=1,
force_full=True,
)
second = self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes=self.routes,
cursor=first.next_cursor,
limit=1,
force_full=True,
)
self.assertFalse(first.complete)
self.assertTrue(second.complete)
self.assertEqual("2026-08-22T12:00:00Z", second.high_watermark)
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_delta_overlaps_and_pages_all_ids_at_one_timestamp(self, fetch) -> None:
shared_revision = "2026-08-22T10:00:00Z"
fetch.side_effect = (
response({"TicketID": ["41", "42"]}),
response({"Ticket": [{"TicketID": "41", "Changed": shared_revision}]}),
response({"Ticket": [{"TicketID": "42", "Changed": shared_revision}]}),
response({"TicketID": ["41", "42"]}),
response({"Ticket": [{"TicketID": "41", "Changed": shared_revision}]}),
response({"Ticket": [{"TicketID": "42", "Changed": shared_revision}]}),
)
first = self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes=self.routes,
cursor=json.dumps(
{"kind": "delta", "changed": "2026-08-22T09:59:59Z", "seen": []}
),
limit=1,
force_full=False,
)
second = self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes=self.routes,
cursor=first.next_cursor,
limit=1,
force_full=False,
)
self.assertEqual(["41"], [item["TicketID"] for item in first.changes])
self.assertEqual(["42"], [item["TicketID"] for item in second.changes])
second_search = json.loads(fetch.call_args_list[3].kwargs["body"])
self.assertEqual(
"2026-08-22T09:59:59Z",
second_search["TicketChangeTimeNewerDate"],
)
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_delta_does_not_suppress_a_seen_ticket_that_changed_again(self, fetch) -> None:
fetch.side_effect = (
response({"TicketID": ["41"]}),
response(
{
"Ticket": [
{"TicketID": "41", "Changed": "2026-08-22T10:05:00Z"}
]
}
),
)
batch = self.transport.changes(
endpoint_url=self.endpoint,
credential=None,
routes=self.routes,
cursor=json.dumps(
{
"kind": "delta",
"changed": "2026-08-22T10:00:00Z",
"seen": ["41"],
}
),
limit=10,
force_full=False,
)
self.assertEqual(["41"], [item["TicketID"] for item in batch.changes])
self.assertEqual("2026-08-22T10:05:00Z", batch.high_watermark)
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_body_authentication_is_never_put_in_get_url(self, fetch) -> None:
fetch.return_value = response({"TicketID": ["42"]})
with self.assertRaisesRegex(ServiceDeskTransportError, "cannot be used with a GET"):
self.transport.changes(
endpoint_url=self.endpoint,
credential={
"auth_mode": "body",
"user_login": "connector",
"password": "secret",
},
routes={**self.routes, "search_method": "GET"},
cursor=None,
limit=1,
force_full=True,
)
fetch.assert_not_called()
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_legacy_body_authentication_is_post_only_and_not_duplicated(self, fetch) -> None:
fetch.return_value = response({"TicketID": []})
self.transport.discover(
endpoint_url=self.endpoint,
credential={
"auth_mode": "body",
"user_login": "connector",
"password": "secret",
},
routes=self.routes,
)
call = fetch.call_args
payload = json.loads(call.kwargs["body"])
self.assertEqual("connector", payload["UserLogin"])
self.assertEqual("secret", payload["Password"])
self.assertNotIn("X-OTRS-Header-UserLogin", call.kwargs["headers"])
self.assertNotIn("secret", call.args[0])
@patch("govoplan_connectors.backend.service_desk_transport.fetch_http")
def test_update_requires_revision_and_requested_field_verification(self, fetch) -> None:
fetch.side_effect = (
response(
{
"Ticket": [
{
"TicketID": "42",
"State": "open",
"Changed": "2026-08-22T10:00:00Z",
}
]
}
),
response({"Success": 1}),
response(
{
"Ticket": [
{
"TicketID": "42",
"State": "open",
"Changed": "2026-08-22T10:05:00Z",
}
]
}
),
)
with self.assertRaises(ServiceDeskTransportError) as raised:
self.transport.update_ticket(
endpoint_url=self.endpoint,
credential=None,
routes={**self.routes, "update_path": "/Ticket/{ticket_id}"},
ticket_id="42",
expected_revision="2026-08-22T10:00:00Z",
changes={"State": "pending reminder"},
)
self.assertTrue(raised.exception.outcome_unknown)
self.assertEqual("update_verification_failed", raised.exception.code)
if __name__ == "__main__":
unittest.main()