feat: full ISPConfig remote API coverage + re-runnable generator (v0.2)
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.
This commit is contained in:
parent
9438b4e751
commit
44ce76cb44
28 changed files with 14173 additions and 184 deletions
|
|
@ -3,6 +3,9 @@
|
|||
Live-smoke tests read ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``, and
|
||||
``ISPCONFIG_TEST_PASS`` from the environment. If any is missing, those tests
|
||||
are skipped — so the default ``pytest`` run on a laptop never phones home.
|
||||
|
||||
Set ``ISPCONFIG_TEST_VERIFY_SSL=0`` for panels with self-signed or
|
||||
mismatched certs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -19,4 +22,5 @@ def live_creds() -> dict[str, str]:
|
|||
password = os.environ.get("ISPCONFIG_TEST_PASS")
|
||||
if not (url and user and password):
|
||||
pytest.skip("live smoke test: set ISPCONFIG_TEST_URL/USER/PASS to enable")
|
||||
return {"url": url, "user": user, "password": password}
|
||||
verify = os.environ.get("ISPCONFIG_TEST_VERIFY_SSL", "1")
|
||||
return {"url": url, "user": user, "password": password, "verify": verify}
|
||||
|
|
|
|||
|
|
@ -5,23 +5,40 @@ Gated on env vars: ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``,
|
|||
|
||||
They are **read-only** — no ``_add`` / ``_update`` / ``_delete`` calls. Safe
|
||||
to run against production (Rackham).
|
||||
|
||||
Every new auto-generated module gets at least one read-only call here so we
|
||||
know the wrappers actually wire up against a live panel. Methods that the
|
||||
API user lacks permission for (admin-only, etc.) are documented skips —
|
||||
see the README's "Known admin-only" list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from ispconfig import ISPConfigClient
|
||||
from ispconfig import ISPConfigClient, NotFoundError, PermissionError
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(live_creds: dict[str, str]) -> ISPConfigClient:
|
||||
with ISPConfigClient(live_creds["url"], live_creds["user"], live_creds["password"]) as c:
|
||||
yield c # type: ignore[misc]
|
||||
def client(live_creds: dict[str, str]) -> Iterator[ISPConfigClient]:
|
||||
verify = live_creds.get("verify", "1") not in ("0", "false", "False")
|
||||
with ISPConfigClient(
|
||||
live_creds["url"],
|
||||
live_creds["user"],
|
||||
live_creds["password"],
|
||||
verify_ssl=verify,
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- hand-audited modules (first pass) -----------------------------------
|
||||
|
||||
|
||||
def test_login_returns_session(live_creds: dict[str, str]) -> None:
|
||||
c = ISPConfigClient(live_creds["url"], live_creds["user"], live_creds["password"])
|
||||
verify = live_creds.get("verify", "1") not in ("0", "false", "False")
|
||||
c = ISPConfigClient(live_creds["url"], live_creds["user"], live_creds["password"], verify_ssl=verify)
|
||||
c.login()
|
||||
assert c.session_id and len(c.session_id) > 10
|
||||
assert c.logout() is True
|
||||
|
|
@ -45,3 +62,193 @@ def test_mail_users_under_mcbindustrial(client: ISPConfigClient) -> None:
|
|||
# Don't assert count — just shape. Zero mailboxes is a valid state.
|
||||
for u in users:
|
||||
assert "email" in u
|
||||
|
||||
|
||||
# ---- auto-generated modules: one read-only probe each -------------------
|
||||
#
|
||||
# These prove the wrappers encode/decode correctly against a real panel.
|
||||
# Each test tolerates the method being restricted to admin (``kayos`` is a
|
||||
# reseller, not an admin) — those skip with a clear reason.
|
||||
|
||||
|
||||
def test_raw_call_list_functions(client: ISPConfigClient) -> None:
|
||||
"""``list_functions`` is the introspection escape hatch — sanity check it."""
|
||||
funcs = client.list_functions()
|
||||
assert "sites_web_domain_get" in funcs
|
||||
assert "mail_user_get" in funcs
|
||||
# 300+ is typical for a modern ISPConfig.
|
||||
assert len(funcs) > 200
|
||||
|
||||
|
||||
def test_raw_call_escape_hatch(client: ISPConfigClient) -> None:
|
||||
"""``raw_call`` must route through the same retry/fault-mapping pipeline."""
|
||||
# ``client_get_all`` is a no-arg read.
|
||||
ids = client.raw_call("client_get_all")
|
||||
assert ids is None or isinstance(ids, (list, dict))
|
||||
|
||||
|
||||
def test_clients_client_get_all(client: ISPConfigClient) -> None:
|
||||
ids = client.clients.get_all()
|
||||
assert isinstance(ids, list)
|
||||
# Don't assert count — panel may be empty of managed clients.
|
||||
|
||||
|
||||
def test_clients_templates_get_all(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
tpls = client.clients.client_templates_get_all()
|
||||
except PermissionError:
|
||||
pytest.skip("client_templates_get_all: admin-only on this panel")
|
||||
assert tpls is None or isinstance(tpls, (list, dict))
|
||||
|
||||
|
||||
def test_server_get_all(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
servers = client.server.server_get_all()
|
||||
except PermissionError:
|
||||
pytest.skip("server_get_all: admin-only on this panel")
|
||||
assert servers is None or isinstance(servers, (list, dict))
|
||||
|
||||
|
||||
def test_server_get_functions(client: ISPConfigClient) -> None:
|
||||
"""Pick the first server id and ask which modules it runs."""
|
||||
try:
|
||||
servers = client.server.server_get_all()
|
||||
except PermissionError:
|
||||
pytest.skip("server_get_all: admin-only on this panel")
|
||||
if not servers:
|
||||
pytest.skip("no servers visible to this API user")
|
||||
# ``server_get_all`` returns a list of server records. Grab the first id.
|
||||
first = servers[0] if isinstance(servers, list) else next(iter(servers.values()))
|
||||
server_id = int(first.get("server_id") if isinstance(first, dict) else first)
|
||||
try:
|
||||
fns = client.server.server_get_functions(server_id)
|
||||
except PermissionError:
|
||||
pytest.skip("server_get_functions: admin-only on this panel")
|
||||
assert fns is None or isinstance(fns, (list, dict))
|
||||
|
||||
|
||||
def test_monitor_jobqueue_count(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
n = client.monitor.monitor_jobqueue_count()
|
||||
except PermissionError:
|
||||
pytest.skip("monitor_jobqueue_count: admin-only on this panel")
|
||||
# Returns an int-ish; ISPConfig may stringify.
|
||||
assert n is None or isinstance(n, (int, str, dict, list))
|
||||
|
||||
|
||||
def test_admin_system_config_get(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
cfg = client.admin.system_config_get("mail")
|
||||
except PermissionError:
|
||||
pytest.skip("system_config_get: admin-only on this panel")
|
||||
assert cfg is None or isinstance(cfg, (dict, list, str))
|
||||
|
||||
|
||||
def test_ftp_user_get_missing(client: ISPConfigClient) -> None:
|
||||
"""Nonexistent primary_id → ``NotFoundError`` via the fault-map path."""
|
||||
try:
|
||||
result = client.ftp.sites_ftp_user_get(999_999_999)
|
||||
except NotFoundError:
|
||||
return # expected
|
||||
except PermissionError:
|
||||
pytest.skip("sites_ftp_user_get: admin-only on this panel")
|
||||
# Some panels return None/empty instead of a fault.
|
||||
assert result in (None, {}, "", [])
|
||||
|
||||
|
||||
def test_shell_user_get_missing(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
result = client.shell.sites_shell_user_get(999_999_999)
|
||||
except NotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
pytest.skip("sites_shell_user_get: admin-only on this panel")
|
||||
assert result in (None, {}, "", [])
|
||||
|
||||
|
||||
def test_webdav_user_get_missing(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
result = client.webdav.sites_webdav_user_get(999_999_999)
|
||||
except NotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
pytest.skip("sites_webdav_user_get: admin-only on this panel")
|
||||
assert result in (None, {}, "", [])
|
||||
|
||||
|
||||
def test_cron_get_missing(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
result = client.cron.sites_cron_get(999_999_999)
|
||||
except NotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
pytest.skip("sites_cron_get: admin-only on this panel")
|
||||
assert result in (None, {}, "", [])
|
||||
|
||||
|
||||
def test_backups_list(client: ISPConfigClient) -> None:
|
||||
"""``sites_web_domain_backup_list`` on a known domain."""
|
||||
try:
|
||||
result = client.backups.sites_web_domain_backup_list(156)
|
||||
except PermissionError:
|
||||
pytest.skip("sites_web_domain_backup_list: admin-only on this panel")
|
||||
except NotFoundError:
|
||||
pytest.skip("no backups configured for domain 156")
|
||||
assert result is None or isinstance(result, (list, dict))
|
||||
|
||||
|
||||
def test_aps_available_packages_list(client: ISPConfigClient) -> None:
|
||||
try:
|
||||
pkgs = client.aps.sites_aps_available_packages_list()
|
||||
except PermissionError:
|
||||
pytest.skip("sites_aps_available_packages_list: admin-only on this panel")
|
||||
except NotFoundError:
|
||||
pytest.skip("APS not initialized on this panel")
|
||||
assert pkgs is None or isinstance(pkgs, (list, dict))
|
||||
|
||||
|
||||
def test_domains_get_all_by_user(client: ISPConfigClient) -> None:
|
||||
"""``domains`` is the (optional) domain-module — may not be installed."""
|
||||
try:
|
||||
# group 1 = admin
|
||||
domains = client.domains.domains_get_all_by_user(1)
|
||||
except PermissionError:
|
||||
pytest.skip("domains_get_all_by_user: admin-only or module disabled")
|
||||
except NotFoundError:
|
||||
pytest.skip("domains module not installed")
|
||||
assert domains is None or isinstance(domains, (list, dict))
|
||||
|
||||
|
||||
def test_openvz_get_free_ip(client: ISPConfigClient) -> None:
|
||||
"""OpenVZ module may not be installed — skip cleanly if so."""
|
||||
try:
|
||||
ip = client.openvz.openvz_get_free_ip()
|
||||
except PermissionError:
|
||||
pytest.skip("openvz_get_free_ip: admin-only or OpenVZ not installed")
|
||||
except NotFoundError:
|
||||
pytest.skip("OpenVZ not installed / no free IPs")
|
||||
assert ip is None or isinstance(ip, (str, dict, list))
|
||||
|
||||
|
||||
def test_misc_quota_get_by_user(client: ISPConfigClient) -> None:
|
||||
"""``quota_get_by_user`` — look up one visible client, query its quota."""
|
||||
try:
|
||||
ids = client.clients.get_all()
|
||||
except PermissionError:
|
||||
pytest.skip("client_get_all: admin-only on this panel")
|
||||
if not ids:
|
||||
pytest.skip("no clients visible to this API user")
|
||||
client_id = int(ids[0])
|
||||
try:
|
||||
group_id = client.clients.get_groupid(client_id)
|
||||
except (PermissionError, NotFoundError):
|
||||
pytest.skip("client_get_groupid: unavailable")
|
||||
if not group_id:
|
||||
pytest.skip("couldn't resolve group id for first visible client")
|
||||
try:
|
||||
quota = client.misc.quota_get_by_user(group_id)
|
||||
except PermissionError:
|
||||
pytest.skip("quota_get_by_user: admin-only on this panel")
|
||||
except NotFoundError:
|
||||
pytest.skip("no quota record for this user")
|
||||
assert quota is None or isinstance(quota, (list, dict))
|
||||
|
|
|
|||
|
|
@ -64,27 +64,34 @@ def test_context_manager_auto_login_logout() -> None:
|
|||
|
||||
|
||||
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
|
||||
])
|
||||
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",
|
||||
"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"),
|
||||
])
|
||||
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()
|
||||
|
|
@ -149,13 +156,13 @@ def test_envelope_encoding_map_and_scalars() -> None:
|
|||
)
|
||||
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 "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"?>
|
||||
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"
|
||||
|
|
@ -169,19 +176,19 @@ def test_response_parsing_map() -> None:
|
|||
</return>
|
||||
</ns1:sites_web_domain_getResponse>
|
||||
</SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>'''
|
||||
</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"?>
|
||||
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>'''
|
||||
</SOAP-ENV:Envelope>"""
|
||||
with pytest.raises(SoapFault) as excinfo:
|
||||
SoapTransport._parse_response("login", body)
|
||||
assert "Login failed" in excinfo.value.faultstring
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue