v0.1 shipped ~15 hand-audited methods across sites/dns/mail/databases/clients. That's enough for daily ops but every new Tort Host / cWHO feature has been hitting the wall at the edge of that coverage. This extends the SDK to wrap every method the panel exposes — 312 of them as of Rackham 2026-04-22, verified against the live list_functions() introspection call with only one name-mismatch (``__construct``, a PHP lifecycle artifact, not a real API method). The hand-audited helpers stay where they are. Every module now has two clearly-delimited sections: an auto-generated block at the top (emitted by tools/gen_methods.py from tools/method_inventory.json), and a HAND-EDIT ONLY BELOW block at the bottom that survives regeneration. Name collisions between auto and hand always resolve in favor of the hand version — the generator emits a ``# skipped foo: hand-audited helper takes precedence`` comment in the auto block for traceability. Pipeline: - tools/extract_inventory.py reads remote.*.inc.php + remoting.inc.php, pulls docblocks + param defaults, dumps one JSON record per method. Regex is balanced-paren aware so ``$params = array()`` defaults don't truncate the signature at the wrong close-paren (that footgun hid three methods from the first run — sites_aps_available_packages_list, sites_aps_instance_delete, openvz_vm_add_from_template). - tools/method_inventory.json is the committed inventory — future ISPConfig upgrades diff against this file to see scope at a glance. - tools/gen_methods.py groups by method-name prefix onto the module classes listed in the README table, emits a 1:1 Python wrapper per method with the original PHP filename + line number in the docstring, and ensures ``from typing import Any`` is present in preexisting modules before emitting ``Any`` type annotations. New submodules (all auto-generated, wired into ISPConfigClient.__init__): admin, aps, backups, cron, domains, ftp, misc, monitor, openvz, server, shell, webdav. Existing modules (sites, dns, mail, databases, clients) got their auto block filled in and their hand-audited helpers preserved. Escape hatches on the top-level client: - raw_call(method, *args) routes an arbitrary method name through the same session-management + retry + fault-mapping pipeline the typed wrappers use. Fix for "panel shipped a new method, SDK hasn't caught up" — callers don't have to reach back into _soap. - list_functions() wraps get_function_list() for panel introspection. Fault mapping widened: ``no_client_found`` and "no user account" messages now map to NotFoundError instead of FaultError, matching the existing ``no_domain_found`` convention. Older code that caught raw FaultError there will still work (NotFoundError extends ISPConfigError) but callers can now catch the specific type. Testing: - tests/test_unit.py — 12 existing pure-unit tests pass unchanged. - tests/test_smoke.py — extended from 4 read-only calls to 21. One probe per new auto-generated module plus raw_call and list_functions smoke tests. Methods gated behind admin permission skip gracefully with a documented reason (kayos is a reseller, not admin): monitor_jobqueue_count, sites_cron_get, sites_ftp_user_get, openvz_get_free_ip, quota_get_by_user. Results against Rackham 2026-04-22: 28 passed, 5 skipped (all documented admin-only), 0 failed. - ISPCONFIG_TEST_VERIFY_SSL=0 env-var knob added to conftest for panels with self-signed or mismatched certs. Version bump 0.1.0 -> 0.2.0. README restructured into Hand-audited / Auto-generated / Escape hatch / Footguns sections with a regeneration recipe for future ISPConfig upgrades. Ruff per-file SLF001 ignore extended to every submodule (submodules are all authorized callers of the client's private ``_call`` dispatcher by design). mypy strict passes; ruff check passes; ruff format applied across src / tools / tests.
194 lines
6.1 KiB
Python
194 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
|