ispconfig-py/tests/test_unit.py
Kayos 9438b4e751 feat: initial ispconfig-py SDK (sites / dns / mail / databases / clients)
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
2026-04-22 13:24:58 -07:00

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