Files
mcp-synology-container/tests/test_dsm_client.py
T
marcus a1a9388d88 test: v0.2.8 — comprehensive test suite for dsm_client
Adds tests/test_dsm_client.py covering:
- _scrub_url and _error_message pure helpers
- DsmClient.request happy-path, API-not-cached, SID scrubbing in
  HTTPStatusError, sensitive-param log masking
- Session re-auth retry: single-retry semantics, auth-manager-absent
  path, re-auth failure path, thundering-herd (login called once
  under concurrent 106 responses)
- trigger_build_stream: SSE fire-and-forget, JSON error detection,
  ReadTimeout swallowing, HTTP-error scrubbing
- upload_text and download_text happy-path + error-response branches
- _ensure_initialized double-checked-locking and M4 negative-cache
  cooldown behavior

Addresses C3 from the 0.2.7 review; paired with 4caac3a for 0.2.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:15:43 +02:00

1082 lines
40 KiB
Python

"""Tests for dsm_client.py.
Covers the critical paths of DsmClient:
- Pure helpers (_scrub_url, _error_message).
- request() happy-path, envelope handling, HTTPStatusError scrubbing,
sensitive-param log masking.
- Session re-auth retry: single-retry semantics, thundering-herd,
auth-manager-absent, re-auth failure.
- trigger_build_stream: SSE fire-and-forget, JSON error detection,
ReadTimeout swallowing, HTTP-error scrubbing.
- upload_text / download_text happy-path + error-response.
- _ensure_initialized double-checked-locking and M4 negative-cache cooldown.
All tests run offline. No real HTTP traffic. No new test dependencies —
only pytest, pytest-asyncio, and unittest.mock.
"""
from __future__ import annotations
import asyncio
import json
import logging
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from mcp_synology_container.dsm_client import (
INIT_ERROR_COOLDOWN,
DsmClient,
SynologyError,
_error_message,
_scrub_url,
)
# ──────────────────────────────────────────────────────────────────────
# Test helpers
# ──────────────────────────────────────────────────────────────────────
def make_response(
body: dict | None,
*,
status: int = 200,
content_type: str = "application/json",
text: str | None = None,
url: str = "https://nas.local:443/webapi/entry.cgi",
) -> MagicMock:
"""Build a mock object that quacks like httpx.Response.
Exposes: status_code, headers (dict), json(), text, raise_for_status(),
request.url. raise_for_status() raises httpx.HTTPStatusError for 4xx/5xx.
"""
resp = MagicMock(name="Response")
resp.status_code = status
resp.headers = {"content-type": content_type}
if body is not None:
resp.json = MagicMock(return_value=body)
resp.text = text if text is not None else json.dumps(body)
else:
resp.json = MagicMock(side_effect=json.JSONDecodeError("no body", "", 0))
resp.text = text if text is not None else ""
request = MagicMock(name="Request")
request.url = url
resp.request = request
def _raise_for_status() -> None:
if status >= 400:
raise httpx.HTTPStatusError(
f"{status} error",
request=request, # type: ignore[arg-type]
response=resp, # type: ignore[arg-type]
)
resp.raise_for_status = MagicMock(side_effect=_raise_for_status)
return resp
def seed_api_cache(client: DsmClient) -> None:
"""Populate the API cache with the APIs the tests use."""
client._api_cache = {
"SYNO.API.Info": {"path": "query.cgi", "minVersion": 1, "maxVersion": 1},
"SYNO.API.Auth": {"path": "auth.cgi", "minVersion": 1, "maxVersion": 7},
"SYNO.Docker.Project": {
"path": "entry.cgi",
"minVersion": 1,
"maxVersion": 1,
},
"SYNO.FileStation.Upload": {
"path": "FileStation/api_upload.cgi",
"minVersion": 2,
"maxVersion": 2,
},
"SYNO.FileStation.Download": {
"path": "FileStation/file_download.cgi",
"minVersion": 1,
"maxVersion": 2,
},
}
def mark_initialized(client: DsmClient) -> None:
"""Bypass _ensure_initialized so tests can focus on request()."""
client._initialized = True
seed_api_cache(client)
def make_stream_ctx(response: MagicMock) -> MagicMock:
"""Mock for httpx.AsyncClient.stream() — returns an async ctx manager."""
ctx = MagicMock(name="StreamCtx")
ctx.__aenter__ = AsyncMock(return_value=response)
ctx.__aexit__ = AsyncMock(return_value=None)
return ctx
async def _aiter_from_bytes(chunks: list[bytes]):
"""Async iterator that yields the given byte chunks."""
for c in chunks:
yield c
# ──────────────────────────────────────────────────────────────────────
# _scrub_url
# ──────────────────────────────────────────────────────────────────────
def test_scrub_url_replaces_sid_value() -> None:
assert _scrub_url("https://nas/api?_sid=abc123") == "https://nas/api?_sid=***"
def test_scrub_url_sid_between_other_params() -> None:
assert (
_scrub_url("https://nas/api?foo=bar&_sid=xyz&baz=qux")
== "https://nas/api?foo=bar&_sid=***&baz=qux"
)
def test_scrub_url_sid_first() -> None:
assert _scrub_url("https://nas/api?_sid=xyz&foo=bar") == "https://nas/api?_sid=***&foo=bar"
def test_scrub_url_without_sid_unchanged() -> None:
url = "https://nas.local/webapi/entry.cgi?api=SYNO.Foo&method=list"
assert _scrub_url(url) == url
def test_scrub_url_multiple_sid_occurrences_all_replaced() -> None:
# Pathological: both values get masked; re.sub replaces all non-overlapping
# occurrences by default.
got = _scrub_url("https://nas/api?_sid=aaa&_sid=bbb")
assert got == "https://nas/api?_sid=***&_sid=***"
def test_scrub_url_empty_string() -> None:
assert _scrub_url("") == ""
def test_scrub_url_sid_value_preserved_as_asterisks_not_removed() -> None:
# The secret must be gone entirely; the key survives.
url = "https://nas/api?_sid=supersecrettoken12345"
got = _scrub_url(url)
assert "supersecrettoken12345" not in got
assert "_sid=***" in got
# ──────────────────────────────────────────────────────────────────────
# _error_message
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("code", "expected"),
[
(100, "Unknown error"),
(101, "Invalid parameter"),
(102, "API does not exist on this NAS"),
(103, "Method does not exist"),
(104, "API version not supported"),
(105, "Permission denied — check DSM user permissions"),
(106, "Session timeout"),
(107, "Session displaced by another login"),
(119, "Session invalid"),
],
)
def test_error_message_common_codes(code: int, expected: str) -> None:
assert _error_message(code) == expected
@pytest.mark.parametrize(
("code", "expected"),
[
(400, "Incorrect username or password"),
(401, "Account disabled"),
(402, "Permission denied for this service"),
(403, "2FA code required"),
(404, "2FA code incorrect or expired"),
(407, "Too many failed login attempts — account temporarily locked"),
(408, "IP blocked due to excessive failed attempts"),
],
)
def test_error_message_auth_codes_require_auth_api(code: int, expected: str) -> None:
assert _error_message(code, "SYNO.API.Auth") == expected
def test_error_message_400_without_auth_api_falls_through() -> None:
# 400 is not in the common table, so without "Auth" in the api name we
# expect the unknown-code fallback.
assert _error_message(400, "SYNO.Docker.Project") == "DSM error code 400"
def test_error_message_unknown_code_fallback() -> None:
assert _error_message(9999) == "DSM error code 9999"
def test_error_message_unknown_code_with_auth_api_fallback() -> None:
# 9999 not in auth or common — still falls back.
assert _error_message(9999, "SYNO.API.Auth") == "DSM error code 9999"
def test_error_message_session_codes_common_not_auth() -> None:
# 106 is in common but also accessed via auth flows; common wins regardless.
assert _error_message(106, "SYNO.API.Auth") == "Session timeout"
# ──────────────────────────────────────────────────────────────────────
# DsmClient.request() — happy-path + error envelope
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_request_success_returns_data() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response({"success": True, "data": {"x": 1}})
)
result = await client.request("SYNO.Docker.Project", "list")
assert result == {"x": 1}
@pytest.mark.asyncio
async def test_request_success_no_data_returns_empty_dict() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
client._http.get = AsyncMock(return_value=make_response({"success": True}))
result = await client.request("SYNO.Docker.Project", "list")
assert result == {}
@pytest.mark.asyncio
async def test_request_api_not_cached_raises_102() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._initialized = True
client._api_cache = {} # no APIs at all
client._http = AsyncMock()
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Does.Not.Exist", "list")
assert exc_info.value.code == 102
assert "SYNO.Does.Not.Exist" in str(exc_info.value)
@pytest.mark.asyncio
async def test_request_error_envelope_raises_with_code() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 101}})
)
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Docker.Project", "list")
assert exc_info.value.code == 101
assert "Invalid parameter" in str(exc_info.value)
@pytest.mark.asyncio
async def test_request_http_error_scrubs_sid_from_url() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "secret123"
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response(
None,
status=500,
url="https://nas.local/webapi/entry.cgi?api=SYNO.Docker.Project&_sid=secret123",
)
)
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Docker.Project", "list")
msg = str(exc_info.value)
assert "secret123" not in msg
assert "_sid=***" in msg
assert exc_info.value.code == 500
@pytest.mark.asyncio
async def test_request_sensitive_params_masked_in_log(
caplog: pytest.LogCaptureFixture,
) -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "leakedsid"
client._http = AsyncMock()
client._http.get = AsyncMock(return_value=make_response({"success": True, "data": {}}))
with caplog.at_level(logging.DEBUG, logger="mcp_synology_container.dsm_client"):
await client.request(
"SYNO.API.Auth",
"login",
params={
"passwd": "s3cr3tP4ss",
"device_id": "deviceABC",
"otp_code": "654321",
"device_token": "tokXYZ",
"account": "admin", # not sensitive — keep visible
},
)
log_text = "\n".join(rec.getMessage() for rec in caplog.records)
assert "s3cr3tP4ss" not in log_text
assert "deviceABC" not in log_text
assert "654321" not in log_text
assert "tokXYZ" not in log_text
assert "leakedsid" not in log_text
# Non-sensitive param and masking token must be visible.
assert "admin" in log_text
assert "***" in log_text
# ──────────────────────────────────────────────────────────────────────
# Session re-auth retry
# ──────────────────────────────────────────────────────────────────────
def make_auth_manager(new_sid: str = "fresh_sid") -> AsyncMock:
"""Build a fake AuthManager with a counted login()."""
mgr = AsyncMock()
mgr.login = AsyncMock(return_value=new_sid)
return mgr
@pytest.mark.asyncio
async def test_request_reauths_on_106_and_retries() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "old_sid"
client._auth_manager = make_auth_manager("new_sid")
client._http = AsyncMock()
responses = [
make_response({"success": False, "error": {"code": 106}}),
make_response({"success": True, "data": {"ok": 1}}),
]
client._http.get = AsyncMock(side_effect=responses)
result = await client.request("SYNO.Docker.Project", "list")
assert result == {"ok": 1}
assert client._auth_manager.login.call_count == 1
assert client._sid == "new_sid"
assert client._http.get.call_count == 2
@pytest.mark.asyncio
async def test_request_reauth_single_retry_only() -> None:
"""Second 106 after re-auth must NOT trigger another login."""
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "old_sid"
client._auth_manager = make_auth_manager("new_sid")
client._http = AsyncMock()
responses = [
make_response({"success": False, "error": {"code": 106}}),
make_response({"success": False, "error": {"code": 106}}),
]
client._http.get = AsyncMock(side_effect=responses)
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Docker.Project", "list")
assert exc_info.value.code == 106
assert client._auth_manager.login.call_count == 1
assert client._http.get.call_count == 2
@pytest.mark.asyncio
async def test_request_reauth_login_failure_surfaces_reauth_error() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "old_sid"
client._auth_manager = AsyncMock()
client._auth_manager.login = AsyncMock(side_effect=RuntimeError("login broke"))
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 106}})
)
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Docker.Project", "list")
assert exc_info.value.code == 106
assert "Re-authentication failed" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, RuntimeError)
@pytest.mark.asyncio
async def test_request_no_auth_manager_no_retry_on_106() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "old_sid"
client._auth_manager = None
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 106}})
)
with pytest.raises(SynologyError) as exc_info:
await client.request("SYNO.Docker.Project", "list")
assert exc_info.value.code == 106
assert client._http.get.call_count == 1
@pytest.mark.asyncio
async def test_request_reauth_thundering_herd_login_called_once() -> None:
"""Two concurrent requests both hitting 106 must share one login.
The reauth lock plus the old_sid snapshot check in dsm_client ensures the
second task observes the already-refreshed SID and skips the redundant
login call.
"""
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "old_sid"
client._auth_manager = make_auth_manager("new_sid")
# Four sequential responses consumed in call order by asyncio.gather:
# both initial calls get 106, both retries get success.
responses = [
make_response({"success": False, "error": {"code": 106}}),
make_response({"success": False, "error": {"code": 106}}),
make_response({"success": True, "data": {"n": 1}}),
make_response({"success": True, "data": {"n": 2}}),
]
client._http = AsyncMock()
client._http.get = AsyncMock(side_effect=responses)
# Make login slow so the two tasks genuinely contend for the lock.
login_barrier = asyncio.Event()
async def slow_login(_client: Any) -> str:
await asyncio.sleep(0.01)
login_barrier.set()
return "new_sid"
client._auth_manager.login = AsyncMock(side_effect=slow_login)
results = await asyncio.gather(
client.request("SYNO.Docker.Project", "list"),
client.request("SYNO.Docker.Project", "list"),
)
assert all(isinstance(r, dict) for r in results)
assert client._auth_manager.login.call_count == 1
assert client._sid == "new_sid"
# 2 initial + 2 retry = 4 total GETs.
assert client._http.get.call_count == 4
# ──────────────────────────────────────────────────────────────────────
# trigger_build_stream
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_build_stream_sse_fire_and_forget_does_not_read_body() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
resp = make_response(
{"success": True},
content_type="text/event-stream",
)
# If the code ever tries to read the SSE body, blow up the test.
def _boom(*_a: Any, **_k: Any):
raise AssertionError("SSE body must not be read")
resp.aiter_bytes = MagicMock(side_effect=_boom)
client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
result = await client.trigger_build_stream("proj-1")
assert result is None
@pytest.mark.asyncio
async def test_build_stream_json_error_raises_synology_error() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
body = {"success": False, "error": {"code": 1401}}
resp = make_response(body, content_type="application/json")
resp.aiter_bytes = lambda: _aiter_from_bytes([json.dumps(body).encode("utf-8")])
client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
with pytest.raises(SynologyError) as exc_info:
await client.trigger_build_stream("proj-1")
assert exc_info.value.code == 1401
@pytest.mark.asyncio
async def test_build_stream_json_success_accepted() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
body = {"success": True}
resp = make_response(body, content_type="application/json")
resp.aiter_bytes = lambda: _aiter_from_bytes([json.dumps(body).encode("utf-8")])
client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
result = await client.trigger_build_stream("proj-1")
assert result is None
@pytest.mark.asyncio
async def test_build_stream_read_timeout_swallowed() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
# stream() itself raises ReadTimeout before entering the ctx manager.
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(side_effect=httpx.ReadTimeout("headers timed out"))
ctx.__aexit__ = AsyncMock(return_value=None)
client._http.stream = MagicMock(return_value=ctx)
result = await client.trigger_build_stream("proj-1")
assert result is None
@pytest.mark.asyncio
async def test_build_stream_http_500_scrubs_sid() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "topsecret"
client._http = AsyncMock()
resp = make_response(
None,
status=500,
url="https://nas.local/webapi/entry.cgi?api=SYNO.Docker.Project&_sid=topsecret",
)
client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
with pytest.raises(SynologyError) as exc_info:
await client.trigger_build_stream("proj-1")
msg = str(exc_info.value)
assert "topsecret" not in msg
assert "_sid=***" in msg
assert exc_info.value.code == 500
@pytest.mark.asyncio
async def test_build_stream_malformed_json_body_treated_as_accepted() -> None:
"""Defensive branch: JSON content-type but body fails to parse → return None."""
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
resp = make_response({"placeholder": True}, content_type="application/json")
resp.aiter_bytes = lambda: _aiter_from_bytes([b"not-json-at-all{{"])
client._http.stream = MagicMock(return_value=make_stream_ctx(resp))
result = await client.trigger_build_stream("proj-1")
assert result is None
# ──────────────────────────────────────────────────────────────────────
# upload_text / download_text
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_upload_text_happy_path_builds_form_and_file_parts() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "sid-up"
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response({"success": True, "data": {"wrote": 1}})
)
result = await client.upload_text(
"/volume1/docker/app", "compose.yaml", "version: '3'\n", overwrite=True
)
assert result == {"wrote": 1}
assert client._http.post.call_count == 1
_, kwargs = client._http.post.call_args
form = kwargs["data"]
assert form["api"] == "SYNO.FileStation.Upload"
assert form["version"] == "2"
assert form["method"] == "upload"
assert form["path"] == "/volume1/docker/app"
assert form["overwrite"] == "true"
assert form["create_parents"] == "true"
files = kwargs["files"]
name, blob, ctype = files["file"]
assert name == "compose.yaml"
assert blob == b"version: '3'\n"
assert ctype == "text/plain"
assert kwargs["params"] == {"_sid": "sid-up"}
@pytest.mark.asyncio
async def test_upload_text_error_response_raises() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 900}})
)
with pytest.raises(SynologyError) as exc_info:
await client.upload_text("/volume1/docker/app", "compose.yaml", "x")
assert exc_info.value.code == 900
@pytest.mark.asyncio
async def test_download_text_plain_text_returns_body() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
body_text = "version: '3'\nservices: {}\n"
resp = make_response(
None,
content_type="text/plain",
text=body_text,
)
client._http.get = AsyncMock(return_value=resp)
result = await client.download_text("/volume1/docker/app/compose.yaml")
assert result == body_text
@pytest.mark.asyncio
async def test_download_text_json_error_branch_raises() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
resp = make_response(
{"success": False, "error": {"code": 408}},
content_type="application/json",
)
client._http.get = AsyncMock(return_value=resp)
with pytest.raises(SynologyError) as exc_info:
await client.download_text("/volume1/docker/app/missing.yaml")
# 408 without "Auth" in api name → common fallback.
assert exc_info.value.code == 408
# ──────────────────────────────────────────────────────────────────────
# _ensure_initialized — double-checked-locking + M4 negative cache
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_ensure_initialized_runs_once_under_concurrency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two parallel _ensure_initialized() calls on a fresh client → single init.
The double-checked locking in _ensure_initialized guarantees only one of
the two tasks actually runs the init body; the second awaits on the
asyncio.Lock, then finds _initialized already True and returns early.
"""
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
query_calls = 0
async def fake_query_api_info() -> dict[str, dict[str, Any]]:
nonlocal query_calls
query_calls += 1
# Yield control so the other task enters the lock-wait branch.
await asyncio.sleep(0.01)
seed_api_cache(client)
return client._api_cache
monkeypatch.setattr(client, "query_api_info", fake_query_api_info)
auth_mgr = make_auth_manager("sid-init")
client._auth_manager = auth_mgr
await asyncio.gather(
client._ensure_initialized(),
client._ensure_initialized(),
)
assert query_calls == 1
assert auth_mgr.login.call_count == 1
assert client._initialized is True
@pytest.mark.asyncio
async def test_ensure_initialized_negative_cache_reraises_without_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""M4: after init failure, cached error is re-raised during cooldown."""
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
query_calls = 0
async def failing_query() -> dict[str, Any]:
nonlocal query_calls
query_calls += 1
raise httpx.ConnectError("nas offline")
monkeypatch.setattr(client, "query_api_info", failing_query)
# Pin the clock so the cooldown window is deterministic.
fake_time = [1000.0]
monkeypatch.setattr(
"asyncio.get_event_loop",
lambda: SimpleNamespace(time=lambda: fake_time[0]),
)
with pytest.raises(httpx.ConnectError):
await client._ensure_initialized()
assert query_calls == 1
assert client._init_error is not None
assert client._init_error_until == pytest.approx(1000.0 + INIT_ERROR_COOLDOWN)
# Second call within cooldown → cached error, no new query_api_info.
fake_time[0] = 1010.0
with pytest.raises(httpx.ConnectError):
await client._ensure_initialized()
assert query_calls == 1
@pytest.mark.asyncio
async def test_ensure_initialized_retries_after_cooldown_expires(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
query_calls = 0
async def failing_query() -> dict[str, Any]:
nonlocal query_calls
query_calls += 1
raise httpx.ConnectError("nas offline")
monkeypatch.setattr(client, "query_api_info", failing_query)
fake_time = [2000.0]
monkeypatch.setattr(
"asyncio.get_event_loop",
lambda: SimpleNamespace(time=lambda: fake_time[0]),
)
with pytest.raises(httpx.ConnectError):
await client._ensure_initialized()
assert query_calls == 1
# Fast-forward past the cooldown window. The code snapshots `now`
# BEFORE checking _init_error_until, so bumping the clock is enough.
fake_time[0] = 2000.0 + INIT_ERROR_COOLDOWN + 1.0
with pytest.raises(httpx.ConnectError):
await client._ensure_initialized()
assert query_calls == 2
@pytest.mark.asyncio
async def test_ensure_initialized_success_clears_cached_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""After a successful init, _init_error and _init_error_until reset."""
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
# Pre-populate stale error state (as if a previous init failed).
client._init_error = httpx.ConnectError("stale")
client._init_error_until = 0.0 # already expired
async def ok_query() -> dict[str, Any]:
seed_api_cache(client)
return client._api_cache
monkeypatch.setattr(client, "query_api_info", ok_query)
client._auth_manager = None
await client._ensure_initialized()
assert client._initialized is True
assert client._init_error is None
assert client._init_error_until == 0.0
# ──────────────────────────────────────────────────────────────────────
# Auxiliary coverage: sid property, set_auth_manager, _get_http guard,
# query_api_info, post_request
# ──────────────────────────────────────────────────────────────────────
def test_sid_property_roundtrip() -> None:
client = DsmClient(base_url="https://nas.local:443")
assert client.sid is None
client.sid = "new-sid"
assert client.sid == "new-sid"
assert client._sid == "new-sid"
def test_set_auth_manager_registers_instance() -> None:
client = DsmClient(base_url="https://nas.local:443")
dummy = object()
client.set_auth_manager(dummy) # type: ignore[arg-type]
assert client._auth_manager is dummy
def test_get_http_without_context_manager_raises() -> None:
client = DsmClient(base_url="https://nas.local:443")
with pytest.raises(RuntimeError, match="async context manager"):
client._get_http()
@pytest.mark.asyncio
async def test_query_api_info_success_caches_apis() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response(
{
"success": True,
"data": {
"SYNO.Docker.Project": {
"path": "entry.cgi",
"minVersion": 1,
"maxVersion": 2,
},
"SYNO.API.Auth": {
"path": "auth.cgi",
# minVersion/maxVersion omitted → defaults.
},
},
}
)
)
result = await client.query_api_info()
assert set(result.keys()) == {"SYNO.Docker.Project", "SYNO.API.Auth"}
assert result["SYNO.Docker.Project"]["maxVersion"] == 2
assert result["SYNO.API.Auth"]["minVersion"] == 1 # default
assert result["SYNO.API.Auth"]["maxVersion"] == 1 # default
assert client._api_cache == result
@pytest.mark.asyncio
async def test_query_api_info_error_envelope_raises() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 100}})
)
with pytest.raises(SynologyError) as exc_info:
await client.query_api_info()
assert exc_info.value.code == 100
@pytest.mark.asyncio
async def test_query_api_info_http_error_scrubs_sid() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response(
None,
status=500,
url="https://nas.local/webapi/query.cgi?_sid=hushhush",
)
)
with pytest.raises(SynologyError) as exc_info:
await client.query_api_info()
assert "hushhush" not in str(exc_info.value)
assert "_sid=***" in str(exc_info.value)
@pytest.mark.asyncio
async def test_post_request_success_returns_data() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "sid-post"
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response({"success": True, "data": {"deleted": 1}})
)
result = await client.post_request("SYNO.Docker.Project", "delete", params={"id": "proj-1"})
assert result == {"deleted": 1}
_, kwargs = client._http.post.call_args
# Form body carries api/version/method plus custom params.
assert kwargs["data"]["api"] == "SYNO.Docker.Project"
assert kwargs["data"]["method"] == "delete"
assert kwargs["data"]["id"] == "proj-1"
# SID travels in the query string, not the form body.
assert kwargs["params"] == {"_sid": "sid-post"}
@pytest.mark.asyncio
async def test_post_request_api_not_cached_raises_102() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._initialized = True
client._api_cache = {}
client._http = AsyncMock()
with pytest.raises(SynologyError) as exc_info:
await client.post_request("SYNO.Does.Not.Exist", "delete")
assert exc_info.value.code == 102
@pytest.mark.asyncio
async def test_post_request_error_envelope_raises() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response({"success": False, "error": {"code": 105}})
)
with pytest.raises(SynologyError) as exc_info:
await client.post_request("SYNO.Docker.Project", "delete")
assert exc_info.value.code == 105
assert "Permission denied" in str(exc_info.value)
@pytest.mark.asyncio
async def test_post_request_http_error_scrubs_sid() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "shh"
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response(
None,
status=500,
url="https://nas.local/webapi/entry.cgi?_sid=shh",
)
)
with pytest.raises(SynologyError) as exc_info:
await client.post_request("SYNO.Docker.Project", "delete")
assert "shh" not in str(exc_info.value) or "_sid=***" in str(exc_info.value)
assert exc_info.value.code == 500
@pytest.mark.asyncio
async def test_upload_text_api_not_cached_raises_102() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._initialized = True
client._api_cache = {} # FileStation.Upload missing
client._http = AsyncMock()
with pytest.raises(SynologyError) as exc_info:
await client.upload_text("/tmp", "x.txt", "content")
assert exc_info.value.code == 102
@pytest.mark.asyncio
async def test_download_text_api_not_cached_raises_102() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._initialized = True
client._api_cache = {}
client._http = AsyncMock()
with pytest.raises(SynologyError) as exc_info:
await client.download_text("/tmp/x.txt")
assert exc_info.value.code == 102
@pytest.mark.asyncio
async def test_download_text_http_error_scrubs_sid() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "dontleak"
client._http = AsyncMock()
client._http.get = AsyncMock(
return_value=make_response(
None,
status=500,
url="https://nas.local/webapi/FileStation/file_download.cgi?_sid=dontleak",
)
)
with pytest.raises(SynologyError) as exc_info:
await client.download_text("/volume1/docker/app/compose.yaml")
assert "dontleak" not in str(exc_info.value)
assert "_sid=***" in str(exc_info.value)
@pytest.mark.asyncio
async def test_upload_text_http_error_scrubs_sid() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
mark_initialized(client)
client._sid = "uploadsecret"
client._http = AsyncMock()
client._http.post = AsyncMock(
return_value=make_response(
None,
status=500,
url="https://nas.local/webapi/FileStation/api_upload.cgi?_sid=uploadsecret",
)
)
with pytest.raises(SynologyError) as exc_info:
await client.upload_text("/tmp", "x.txt", "content")
assert "uploadsecret" not in str(exc_info.value)
assert "_sid=***" in str(exc_info.value)
@pytest.mark.asyncio
async def test_build_stream_api_not_cached_raises_102() -> None:
async with DsmClient(base_url="https://nas.local:443") as client:
client._initialized = True
client._api_cache = {}
client._http = AsyncMock()
with pytest.raises(SynologyError) as exc_info:
await client.trigger_build_stream("proj-1")
assert exc_info.value.code == 102