277 lines
13 KiB
Python
277 lines
13 KiB
Python
"""Tests for the service credential tester (CT-101..CT-113, CT-119)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import requests
|
|
|
|
from media_library_viewer_api.integrations.alertmanager import test_connection as am_test
|
|
from media_library_viewer_api.integrations.authentik import test_connection as ak_test
|
|
from media_library_viewer_api.integrations.base import translate_connection_error
|
|
from media_library_viewer_api.integrations.jellyfin import test_connection as jf_test
|
|
from media_library_viewer_api.integrations.nextcloud import test_connection as nc_test
|
|
from media_library_viewer_api.integrations.prometheus import test_connection as prom_test
|
|
from media_library_viewer_api.integrations.qbittorrent import test_connection as qbit_test
|
|
from media_library_viewer_api.integrations.remote_machine import test_connection as ssh_test
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# translate_connection_error (CT-119)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTranslateConnectionError:
|
|
def test_http_401_maps_to_auth_message(self) -> None:
|
|
resp = SimpleNamespace(status_code=401)
|
|
exc = requests.HTTPError(response=resp)
|
|
result = translate_connection_error(exc)
|
|
assert not result.ok
|
|
assert "Authentication failed" in result.detail
|
|
|
|
def test_http_403_maps_to_auth_message(self) -> None:
|
|
resp = SimpleNamespace(status_code=403)
|
|
exc = requests.HTTPError(response=resp)
|
|
result = translate_connection_error(exc)
|
|
assert not result.ok
|
|
assert "Authentication failed" in result.detail
|
|
|
|
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
|
|
exc = requests.ConnectionError("getaddrinfo failed")
|
|
result = translate_connection_error(exc)
|
|
assert not result.ok
|
|
assert "Host not found" in result.detail
|
|
|
|
def test_timeout_maps_to_timed_out(self) -> None:
|
|
exc = requests.Timeout("timed out")
|
|
result = translate_connection_error(exc)
|
|
assert not result.ok
|
|
assert "timed out" in result.detail.lower()
|
|
|
|
def test_generic_fallback_includes_context(self) -> None:
|
|
exc = ValueError("something weird happened")
|
|
result = translate_connection_error(exc, context="qBittorrent")
|
|
assert not result.ok
|
|
assert "qBittorrent" in result.detail
|
|
assert "something weird happened" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# qbittorrent (CT-104)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestQbittorrentTestConnection:
|
|
def test_success_returns_version(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
|
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
|
result = qbit_test(
|
|
{"base_url": "http://qb:8080", "timeout_seconds": 5},
|
|
{"username": "u", "password": "p"},
|
|
MagicMock(),
|
|
)
|
|
assert result.ok
|
|
assert result.evidence == "v4.6.0"
|
|
|
|
def test_login_failed_translates_to_auth_message(self) -> None:
|
|
mock_client = MagicMock()
|
|
# Mirrors the real _login auth-failure message for a "Fails." body.
|
|
mock_client.maindata.side_effect = RuntimeError(
|
|
"qBittorrent login failed (HTTP 200): invalid username or password"
|
|
)
|
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
|
assert not result.ok
|
|
assert "Authentication failed" in result.detail
|
|
|
|
def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None:
|
|
mock_client = MagicMock()
|
|
# A 504 from the reverse proxy must NOT be reported as "Authentication
|
|
# failed" — that misled users into re-entering correct credentials.
|
|
mock_client.maindata.side_effect = RuntimeError(
|
|
"qBittorrent is unreachable: reverse proxy returned HTTP 504 for http://qb:8080/api/v2/auth/login."
|
|
)
|
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
|
assert not result.ok
|
|
assert "Authentication failed" not in result.detail
|
|
assert "504" in result.detail
|
|
|
|
def test_connection_error_translates(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
|
|
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
|
|
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
|
|
assert not result.ok
|
|
assert "Connection refused" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prometheus (CT-105)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrometheusTestConnection:
|
|
def test_success_returns_gateway_evidence(self) -> None:
|
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
|
|
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
|
|
result = prom_test(
|
|
{"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
|
|
{"grafana_api_key": "tok"},
|
|
MagicMock(),
|
|
)
|
|
assert result.ok
|
|
assert "Gateway" in (result.evidence or "")
|
|
|
|
def test_missing_url_returns_error_without_network(self) -> None:
|
|
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
|
|
assert not result.ok
|
|
assert "URL" in result.detail
|
|
|
|
def test_missing_api_key_returns_error_without_network(self) -> None:
|
|
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
|
|
assert not result.ok
|
|
assert "API key" in result.detail
|
|
|
|
def test_http_401_translates_to_auth(self) -> None:
|
|
exc = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
|
payload = SimpleNamespace(raise_for_status=MagicMock(side_effect=exc))
|
|
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
|
|
result = prom_test(
|
|
{"grafana_url": "http://grafana:3000"},
|
|
{"grafana_api_key": "wrong"},
|
|
MagicMock(),
|
|
)
|
|
assert not result.ok
|
|
assert "Authentication failed" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# alertmanager (CT-106)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAlertmanagerTestConnection:
|
|
def test_success_returns_version(self) -> None:
|
|
payload = SimpleNamespace(
|
|
raise_for_status=lambda: None,
|
|
json=lambda: {"versionInfo": {"version": "0.27.0"}},
|
|
)
|
|
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
|
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
|
assert result.ok
|
|
assert result.evidence == "0.27.0"
|
|
|
|
def test_connection_refused_translates(self) -> None:
|
|
with patch(
|
|
"media_library_viewer_api.integrations.alertmanager.requests.get",
|
|
side_effect=requests.ConnectionError("refused"),
|
|
):
|
|
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
|
|
assert not result.ok
|
|
assert "Connection refused" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# jellyfin (CT-107)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJellyfinTestConnection:
|
|
def test_success_returns_user_count(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
|
|
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "k"}, MagicMock())
|
|
assert result.ok
|
|
assert "2 users" == result.evidence
|
|
|
|
def test_http_401_translates_to_auth(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.side_effect = requests.HTTPError(response=SimpleNamespace(status_code=401))
|
|
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
|
|
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "wrong"}, MagicMock())
|
|
assert not result.ok
|
|
assert "Authentication failed" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# authentik (CT-108)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthentikTestConnection:
|
|
def test_success_returns_user_count(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.return_value = {"total": 5, "items": []}
|
|
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
|
assert result.ok
|
|
assert "5 users" == result.evidence
|
|
|
|
def test_connection_error_translates(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.users.side_effect = requests.ConnectionError("refused")
|
|
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
|
|
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
|
|
assert not result.ok
|
|
assert "Connection refused" in result.detail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ssh_tasks (CT-109)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSshTasksTestConnection:
|
|
def test_success_returns_connected_evidence(self) -> None:
|
|
mock_client = MagicMock()
|
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {"passphrase": ""}, MagicMock())
|
|
assert result.ok
|
|
assert "Connected to srv:22" == result.evidence
|
|
|
|
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.connect.side_effect = Exception("SSH authentication failed")
|
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
|
assert not result.ok
|
|
assert "SSH authentication failed" in result.detail
|
|
|
|
def test_protocol_banner_translates(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.connect.side_effect = Exception("protocol banner error")
|
|
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
|
|
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
|
|
assert not result.ok
|
|
assert "SSH banner" in result.detail
|
|
|
|
def test_missing_host_returns_value_error(self) -> None:
|
|
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
|
|
assert not result.ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# nextcloud (CT-110)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNextcloudTestConnection:
|
|
def test_success_returns_version(self) -> None:
|
|
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"version": "29.0.0"})
|
|
with patch("media_library_viewer_api.integrations.nextcloud.requests.get", return_value=payload):
|
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
|
assert result.ok
|
|
assert result.evidence == "29.0.0"
|
|
|
|
def test_connection_error_translates(self) -> None:
|
|
with patch(
|
|
"media_library_viewer_api.integrations.nextcloud.requests.get",
|
|
side_effect=requests.ConnectionError("refused"),
|
|
):
|
|
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
|
|
assert not result.ok
|
|
assert "Connection refused" in result.detail
|