Python 3.10+ SDK wrapping the ISPConfig remote SOAP API so we stop writing
throwaway PHP snippets every time we need to touch a site, zone, or mailbox.
Why no zeep: ISPConfig's /remote/index.php exposes PHP SoapServer in non-WSDL
mode and refuses WSDL generation (?wsdl returns a fault). zeep requires WSDL,
so the stated dependency wouldn't actually work. Instead we hand-roll SOAP
envelopes with the stdlib (urllib + xml.etree). Zero runtime deps.
Structure:
- src/ispconfig/_soap.py — envelope encode/decode, fault surfacing
- src/ispconfig/client.py — ISPConfigClient context manager, retry
- src/ispconfig/exceptions.py — ISPConfigError / Auth / Permission / NotFound / Fault
- src/ispconfig/sites.py — web_domain get/add/update/delete + enable_php / enable_letsencrypt helpers
- src/ispconfig/dns.py — zones + A/CNAME/MX/TXT records, dns_a_add type-column workaround
- src/ispconfig/mail.py — mail domains, users, forwards, create_mailbox helper
- src/ispconfig/databases.py — convenience facade over sites_database_*
- src/ispconfig/clients.py — client + sys_groupid lookups
- src/ispconfig/types.py — TypedDicts for response shapes (no pydantic)
Institutional knowledge baked into docstrings + README footgun list:
- sites_web_domain_update 2nd arg is client_id (not primary_id); admin = 0
- sys_groupid=1 -> pass client_id=0 on update, else ownership churns
- fastcgi_php_version vs server_php_id depending on panel version
- dns_a_add type-column bug (<= 3.2.11) — wrapper issues follow-up update
- dns_zone_get_id wants origin WITHOUT trailing dot on 3.2.11+ (contrary to
what the older snippets say). Verified live against Rackham 2026-04-22.
- mail_user_get returns a bare map on exactly-one-match filter dicts —
wrapper normalizes to list
- session timeouts mid-op: client detects + re-auths once (max_retries knob)
Tests:
- tests/test_unit.py — 12 unit tests against a fake transport
- tests/test_smoke.py — live read-only smoke test, gated on env vars:
ISPCONFIG_TEST_URL, ISPCONFIG_TEST_USER, ISPCONFIG_TEST_PASS
Covers login, web_domain_get(156), dns_zone_get_id, mail_user_get filter.
Tooling:
- mypy strict-ish (disallow untyped defs, warn-return-any, no implicit optional)
- ruff with E/F/W/I/B/UP/N/SLF/RUF lint sets
- pip install -e .[dev] for pytest / mypy / ruff
187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
"""Pure-unit tests — no network, no ISPConfig.
|
|
|
|
We swap in a fake transport so the client-level logic (session management,
|
|
retry on expired session, fault translation) can be tested in isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from ispconfig import (
|
|
AuthError,
|
|
FaultError,
|
|
ISPConfigClient,
|
|
NotFoundError,
|
|
PermissionError,
|
|
)
|
|
from ispconfig._soap import SoapFault, SoapTransport
|
|
|
|
|
|
class _FakeTransport:
|
|
def __init__(self, scripted: list[Any]) -> None:
|
|
self.scripted = list(scripted)
|
|
self.calls: list[tuple[str, tuple[tuple[str, Any], ...]]] = []
|
|
|
|
def call(self, method: str, args: Iterable[tuple[str, Any]]) -> Any:
|
|
self.calls.append((method, tuple(args)))
|
|
if not self.scripted:
|
|
raise AssertionError(f"unexpected call {method}")
|
|
result = self.scripted.pop(0)
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
return result
|
|
|
|
|
|
def _make_client(transport: _FakeTransport) -> ISPConfigClient:
|
|
c = ISPConfigClient("http://fake/remote/index.php", "user", "pass")
|
|
c._transport = transport # type: ignore[assignment]
|
|
return c
|
|
|
|
|
|
def test_login_stores_session_and_logout_clears() -> None:
|
|
t = _FakeTransport(["sid-abc", True])
|
|
c = _make_client(t)
|
|
c.login()
|
|
assert c.session_id == "sid-abc"
|
|
assert c.logout() is True
|
|
assert c.session_id is None
|
|
|
|
|
|
def test_context_manager_auto_login_logout() -> None:
|
|
t = _FakeTransport(["sid-123", {"domain": "x.com"}, True])
|
|
c = _make_client(t)
|
|
with c:
|
|
assert c.session_id == "sid-123"
|
|
# first positional arg passed through is the session id.
|
|
result = c.sites.web_domain_get(1)
|
|
assert result == {"domain": "x.com"}
|
|
assert c.session_id is None
|
|
assert [call[0] for call in t.calls] == ["login", "sites_web_domain_get", "logout"]
|
|
|
|
|
|
def test_session_expired_retry() -> None:
|
|
t = _FakeTransport([
|
|
"sid-first", # login
|
|
SoapFault("Server", "Session not valid"), # first _call fails
|
|
"sid-second", # re-login
|
|
{"domain": "x.com"}, # retry succeeds
|
|
])
|
|
c = _make_client(t)
|
|
c.login()
|
|
result = c.sites.web_domain_get(1)
|
|
assert result == {"domain": "x.com"}
|
|
# 4 transport calls: login, failed get, login, successful get.
|
|
assert [call[0] for call in t.calls] == [
|
|
"login", "sites_web_domain_get", "login", "sites_web_domain_get",
|
|
]
|
|
|
|
|
|
def test_session_expired_no_retry_when_disabled() -> None:
|
|
t = _FakeTransport([
|
|
"sid-first",
|
|
SoapFault("Server", "Session expired"),
|
|
])
|
|
c = ISPConfigClient("http://fake/", "u", "p", max_retries=0)
|
|
c._transport = t # type: ignore[assignment]
|
|
c.login()
|
|
with pytest.raises(AuthError):
|
|
c.sites.web_domain_get(1)
|
|
|
|
|
|
def test_fault_mapping_auth() -> None:
|
|
t = _FakeTransport([SoapFault("Server", "Login failed")])
|
|
c = _make_client(t)
|
|
with pytest.raises(AuthError):
|
|
c.login()
|
|
|
|
|
|
def test_fault_mapping_permission() -> None:
|
|
t = _FakeTransport(["sid", SoapFault("Server", "Permission denied")])
|
|
c = _make_client(t)
|
|
c.login()
|
|
with pytest.raises(PermissionError):
|
|
c.sites.web_domain_get(1)
|
|
|
|
|
|
def test_fault_mapping_not_found() -> None:
|
|
t = _FakeTransport(["sid", SoapFault("Server", "No records found")])
|
|
c = _make_client(t)
|
|
c.login()
|
|
with pytest.raises(NotFoundError):
|
|
c.sites.web_domain_get(999)
|
|
|
|
|
|
def test_fault_mapping_generic() -> None:
|
|
t = _FakeTransport(["sid", SoapFault("Server", "something weird")])
|
|
c = _make_client(t)
|
|
c.login()
|
|
with pytest.raises(FaultError):
|
|
c.sites.web_domain_get(1)
|
|
|
|
|
|
def test_update_client_id_footgun_passes_through() -> None:
|
|
"""``web_domain_update`` must send ``client_id`` as the 2nd positional arg."""
|
|
t = _FakeTransport(["sid", 1])
|
|
c = _make_client(t)
|
|
c.login()
|
|
c.sites.web_domain_update(0, 156, {"php": "fast-cgi"})
|
|
_, args = t.calls[-1]
|
|
assert args[0][0] == "session_id"
|
|
assert args[1] == ("client_id", 0)
|
|
assert args[2] == ("primary_id", 156)
|
|
assert args[3][0] == "params"
|
|
|
|
|
|
def test_envelope_encoding_map_and_scalars() -> None:
|
|
"""Smoke test for the XML encoder — catches regressions."""
|
|
xml = SoapTransport._build_envelope(
|
|
"sites_web_domain_update",
|
|
(
|
|
("session_id", "abc"),
|
|
("client_id", 0),
|
|
("primary_id", 156),
|
|
("params", {"php": "fast-cgi", "active": "y"}),
|
|
),
|
|
)
|
|
assert "<session_id" in xml and ">abc<" in xml
|
|
assert '<client_id xsi:type="xsd:int">0</client_id>' in xml
|
|
assert 'ns2:Map' in xml
|
|
assert '<key xsi:type="xsd:string">php</key>' in xml
|
|
assert '<value xsi:type="xsd:string">fast-cgi</value>' in xml
|
|
|
|
|
|
def test_response_parsing_map() -> None:
|
|
body = b'''<?xml version="1.0"?>
|
|
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
|
|
xmlns:ns1="/remote/index.php"
|
|
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xmlns:ns2="http://xml.apache.org/xml-soap">
|
|
<SOAP-ENV:Body>
|
|
<ns1:sites_web_domain_getResponse>
|
|
<return xsi:type="ns2:Map">
|
|
<item><key xsi:type="xsd:string">domain</key><value xsi:type="xsd:string">mcb.com</value></item>
|
|
<item><key xsi:type="xsd:string">active</key><value xsi:type="xsd:string">y</value></item>
|
|
</return>
|
|
</ns1:sites_web_domain_getResponse>
|
|
</SOAP-ENV:Body>
|
|
</SOAP-ENV:Envelope>'''
|
|
result = SoapTransport._parse_response("sites_web_domain_get", body)
|
|
assert result == {"domain": "mcb.com", "active": "y"}
|
|
|
|
|
|
def test_response_parsing_fault() -> None:
|
|
body = b'''<?xml version="1.0"?>
|
|
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<SOAP-ENV:Body><SOAP-ENV:Fault>
|
|
<faultcode>SOAP-ENV:Server</faultcode>
|
|
<faultstring>Login failed.</faultstring>
|
|
</SOAP-ENV:Fault></SOAP-ENV:Body>
|
|
</SOAP-ENV:Envelope>'''
|
|
with pytest.raises(SoapFault) as excinfo:
|
|
SoapTransport._parse_response("login", body)
|
|
assert "Login failed" in excinfo.value.faultstring
|