Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user