Files
manage/backend/tests/test_api.py
2026-07-14 20:58:46 +00:00

887 lines
36 KiB
Python

"""Integration tests for FastAPI endpoints using TestClient.
These tests mock the SSH and Jellyfin clients to test the API layer
without requiring real remote connections.
"""
import json
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import requests
from fastapi.testclient import TestClient
from media_library_viewer_api.clients.ssh import CommandResult
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_settings_store,
get_ssh_client,
get_user_id,
)
from media_library_viewer_api.main import app
from media_library_viewer_api.routers.media import get_media_index
from media_library_viewer_api.services.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord
# Short alias for the monitoring router module under test.
_MON = "media_library_viewer_api.routers.monitoring"
_SVC = "media_library_viewer_api.services.service_resolution"
# --- Fixtures ---
@pytest.fixture
def mock_jellyfin():
"""Mock Jellyfin client."""
client = MagicMock()
client.media_counts.return_value = {"movies": 100, "series": 20, "episodes": 500}
client.libraries.return_value = [
{"Id": "lib1", "Name": "Movies", "CollectionType": "movies"},
{"Id": "lib2", "Name": "TV Shows", "CollectionType": "tvshows"},
]
client.users.return_value = [
{"Id": "jf1", "Name": "alex"},
{"Id": "jf2", "Name": "sam"},
]
client.library_item_counts.return_value = [
{"library": "Movies", "type": "movies", "movies": 100, "series": 0, "episodes": 0, "total": 100},
{"library": "TV Shows", "type": "tvshows", "movies": 0, "series": 20, "episodes": 500, "total": 520},
]
client.sessions.return_value = [
{
"Id": "sess1",
"UserName": "alex",
"DeviceName": "Chrome",
"NowPlayingItem": {"Name": "Test Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"TranscodingInfo": {"IsVideoDirect": True, "IsAudioDirect": False},
},
{
"Id": "sess2",
"UserName": "sam",
"DeviceName": "Android",
"NowPlayingItem": None,
"PlayState": {},
"TranscodingInfo": None,
},
]
return client
@pytest.fixture
def mock_ssh():
"""Mock SSH client."""
client = MagicMock()
client.host = "test-host"
# Default list_dir response
entries = [
{"type": "d", "size": 4096, "mtime": 1700000000, "name": "Movies"},
{"type": "f", "size": 5000000000, "mtime": 1700000100, "name": "movie.mkv"},
]
client.list_dir.return_value = CommandResult(
command="find ...",
exit_status=0,
stdout=json.dumps(entries),
stderr="",
)
client.stat_path.return_value = CommandResult(
command="stat ...",
exit_status=0,
stdout="regular file\n5000000000 bytes\n2024-01-01\n/path",
stderr="",
)
client.ffprobe_json.return_value = {
"format": {"filename": "movie.mkv", "format_name": "matroska"},
"streams": [{"codec_type": "video", "codec_name": "hevc"}],
}
return client
@pytest.fixture
def test_client(mock_jellyfin, mock_ssh, tmp_path):
"""FastAPI test client with mocked dependencies."""
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
app.dependency_overrides[get_user_id] = lambda: "user123"
store = SettingsStore(tmp_path / "settings.sqlite")
app.dependency_overrides[get_settings_store] = lambda: store
auth_settings = SimpleNamespace(auth_enabled=False)
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
client = TestClient(app)
yield client
app.dependency_overrides.clear()
# --- Health ---
class TestHealth:
def test_health(self, test_client):
response = test_client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_metrics_endpoint(self, test_client):
response = test_client.get("/metrics")
assert response.status_code == 200
assert "manage_api_requests_total" in response.text
assert response.headers["content-type"].startswith("text/plain")
def test_request_id_header(self, test_client):
response = test_client.get("/api/health")
assert response.status_code == 200
assert "x-request-id" in response.headers
assert len(response.headers["x-request-id"]) > 0
# --- Dashboard ---
class TestDashboard:
def test_counts(self, test_client):
response = test_client.get("/api/dashboard/counts")
assert response.status_code == 200
data = response.json()
assert data["movies"] == 100
assert data["series"] == 20
assert data["episodes"] == 500
def test_libraries(self, test_client):
response = test_client.get("/api/dashboard/libraries")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["library"] == "Movies"
assert data[1]["library"] == "TV Shows"
def test_activity(self, test_client):
response = test_client.get("/api/dashboard/activity")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
playing_row = next(row for row in data if row["user"] == "alex")
assert playing_row["title"] == "Test Movie"
assert playing_row["state"] == "playing"
assert playing_row["transcoding"] == "yes"
assert "audio" in playing_row["transcoding_type"]
idle_row = next(row for row in data if row["user"] == "sam")
assert idle_row["state"] == "idle"
assert idle_row["title"] == "(idle)"
def test_now_playing_alias(self, test_client):
response = test_client.get("/api/dashboard/now-playing")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
def test_jellyfin_endpoints_return_503_when_not_configured(self, test_client):
# Remove the mocked Jellyfin dependency so the real one runs; with no
# Jellyfin service seeded, endpoints must degrade to 503, not 500.
app.dependency_overrides.pop(get_jellyfin_client, None)
app.dependency_overrides.pop(get_user_id, None)
try:
for path in ("/api/dashboard/counts", "/api/dashboard/activity"):
response = test_client.get(path)
assert response.status_code == 503, path
detail = response.json()["detail"]
assert "configured" in detail, path
finally:
# Restore the mocks for subsequent tests in this fixture session.
app.dependency_overrides[get_jellyfin_client] = lambda: MagicMock()
app.dependency_overrides[get_user_id] = lambda: "user123"
# --- Settings reset ---
class TestSettingsReset:
def test_reset_local_database_requires_full_confirmation(self, test_client, tmp_path):
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
media_db = tmp_path / "media.sqlite"
media_db.write_text("placeholder", encoding="utf-8")
media_wal = tmp_path / "media.sqlite-wal"
media_wal.write_text("wal", encoding="utf-8")
app.dependency_overrides[get_settings_store] = lambda: store
settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"])
original_media_index = settings_module.MediaIndex
settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db)
try:
response = test_client.post(
"/api/settings/reset-local-database",
json={
"confirm_phrase": "RESET LOCAL DATABASE",
"acknowledge_settings_loss": True,
"acknowledge_media_index_loss": False,
"acknowledge_irreversible": True,
},
)
finally:
app.dependency_overrides.pop(get_settings_store, None)
settings_module.MediaIndex = original_media_index
assert response.status_code == 400
def test_reset_local_database_wipes_state_and_leaves_no_machines(self, test_client, tmp_path):
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
media_db = tmp_path / "media.sqlite"
media_db.write_text("placeholder", encoding="utf-8")
media_wal = tmp_path / "media.sqlite-wal"
media_wal.write_text("wal", encoding="utf-8")
app.dependency_overrides[get_settings_store] = lambda: store
settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"])
original_media_index = settings_module.MediaIndex
settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db)
try:
response = test_client.post(
"/api/settings/reset-local-database",
json={
"confirm_phrase": "RESET LOCAL DATABASE",
"acknowledge_settings_loss": True,
"acknowledge_media_index_loss": True,
"acknowledge_irreversible": True,
},
)
finally:
app.dependency_overrides.pop(get_settings_store, None)
settings_module.MediaIndex = original_media_index
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "reset"
assert not media_db.exists()
assert not media_wal.exists()
assert store.list_services("remote_machine") == []
# --- Files ---
class TestFiles:
def test_list_directory(self, test_client):
response = test_client.get("/api/files/list", params={"path": "/media"})
assert response.status_code == 200
data = response.json()
assert data["path"] == "/media"
assert data["count"] == 2
assert data["entries"][0]["name"] == "Movies"
def test_ffprobe(self, test_client):
response = test_client.get("/api/files/ffprobe", params={"path": "/media/movie.mkv"})
assert response.status_code == 200
data = response.json()
assert data["format"]["format_name"] == "matroska"
assert data["streams"][0]["codec_name"] == "hevc"
def test_stat(self, test_client):
response = test_client.get("/api/files/stat", params={"path": "/media/movie.mkv"})
assert response.status_code == 200
data = response.json()
assert data["path"] == "/media/movie.mkv"
assert "bytes" in data["output"]
def test_resolve_path(self, test_client):
response = test_client.get("/api/files/resolve-path", params={"path": "/media/shows/X"})
assert response.status_code == 200
data = response.json()
assert data["original"] == "/media/shows/X"
assert "resolved" in data
def test_list_failure(self, test_client, mock_ssh):
mock_ssh.list_dir.return_value = CommandResult(
command="find ...", exit_status=1, stdout="", stderr="Permission denied"
)
response = test_client.get("/api/files/list", params={"path": "/root"})
assert response.status_code == 400
assert "Permission denied" in response.text
# --- Media index build ---
class TestMediaIndexApi:
def test_status_includes_build_progress(self, test_client, tmp_path):
index = MediaIndex(tmp_path / "index.sqlite")
index.init_schema()
index.set_metadata("build_running", "true")
index.set_metadata("build_stage", "building")
index.set_metadata("build_message", "3 / 10 items")
index.set_metadata("build_progress", "0.3")
index.set_metadata("build_items_processed", "3")
index.set_metadata("build_items_total", "10")
index.set_metadata("build_current_library", "Movies")
index.set_metadata("build_library_index", "1")
index.set_metadata("build_libraries_total", "2")
index.set_metadata("build_library_progress", "0.5")
index.set_metadata("build_library_items_processed", "1")
index.set_metadata("build_library_items_total", "2")
index.set_metadata("build_elapsed_seconds", "12.0")
index.set_metadata("build_eta_seconds", "28.0")
index.set_metadata("build_library_elapsed_seconds", "4.0")
index.set_metadata("build_library_eta_seconds", "4.0")
index.set_metadata("build_pid", "4321")
app.dependency_overrides[get_media_index] = lambda: index
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
response = test_client.get("/api/media/status")
assert response.status_code == 200
data = response.json()
assert data["build_running"] is True
assert data["build_stage"] == "building"
assert data["build_message"] == "3 / 10 items"
assert data["build_progress"] == 0.3
assert data["build_items_processed"] == 3
assert data["build_items_total"] == 10
assert data["build_current_library"] == "Movies"
assert data["build_library_index"] == 1
assert data["build_libraries_total"] == 2
assert data["build_library_progress"] == 0.5
assert data["build_library_items_processed"] == 1
assert data["build_library_items_total"] == 2
assert data["build_elapsed_seconds"] == 12.0
assert data["build_eta_seconds"] == 28.0
assert data["build_library_elapsed_seconds"] == 4.0
assert data["build_library_eta_seconds"] == 4.0
assert data["build_pid"] == 4321
def test_build_returns_started_when_background_build_is_queued(self, test_client, tmp_path, mock_jellyfin):
index = MediaIndex(tmp_path / "index.sqlite")
app.dependency_overrides[get_media_index] = lambda: index
class FakeProcess:
pid = 4321
with patch("media_library_viewer_api.routers.media._start_worker", return_value=FakeProcess()) as start_worker:
try:
response = test_client.post("/api/media/build")
assert response.status_code == 202
data = response.json()
assert data["status"] == "started"
assert data["build_running"] is True
assert data["build_stage"] == "queued"
assert data["build_libraries_total"] == 0
assert data["build_pid"] == 4321
start_worker.assert_called_once()
finally:
app.dependency_overrides.pop(get_media_index, None)
def test_stop_requests_cancel(self, test_client, tmp_path):
index = MediaIndex(tmp_path / "index.sqlite")
index.init_schema()
index.set_metadata("build_running", "true")
index.set_metadata("build_stage", "building")
index.set_metadata("build_pid", "4321")
app.dependency_overrides[get_media_index] = lambda: index
with patch("media_library_viewer_api.routers.media._pid_is_alive", return_value=True):
try:
response = test_client.post("/api/media/stop")
assert response.status_code == 202
data = response.json()
assert data["status"] == "stop_requested"
assert data["build_cancel_requested"] is True
status = test_client.get("/api/media/status").json()
assert status["build_cancel_requested"] is True
assert status["build_stage"] == "canceling"
finally:
app.dependency_overrides.pop(get_media_index, None)
def test_force_stop_terminates_worker(self, test_client, tmp_path):
index = MediaIndex(tmp_path / "index.sqlite")
index.init_schema()
index.set_metadata("build_running", "true")
index.set_metadata("build_stage", "building")
index.set_metadata("build_pid", "4321")
app.dependency_overrides[get_media_index] = lambda: index
alive_calls = {"count": 0}
def fake_pid_is_alive(pid):
alive_calls["count"] += 1
return alive_calls["count"] <= 2
with (
patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive),
patch("media_library_viewer_api.routers.media.os.killpg") as killpg,
patch("media_library_viewer_api.routers.media.time.sleep", return_value=None),
):
try:
response = test_client.post("/api/media/force-stop")
assert response.status_code == 202
data = response.json()
assert data["status"] == "force_stopped"
killpg.assert_called()
status = test_client.get("/api/media/status").json()
assert status["build_running"] is False
assert status["build_stage"] == "force-stopped"
finally:
app.dependency_overrides.pop(get_media_index, None)
def test_force_stop_returns_conflict_when_idle(self, test_client, tmp_path):
index = MediaIndex(tmp_path / "index.sqlite")
index.init_schema()
app.dependency_overrides[get_media_index] = lambda: index
try:
response = test_client.post("/api/media/force-stop")
assert response.status_code == 409
finally:
app.dependency_overrides.pop(get_media_index, None)
# --- Jobs ---
class TestJobs:
def test_list_templates(self, test_client):
response = test_client.get("/api/jobs/templates")
assert response.status_code == 200
data = response.json()
assert len(data) >= 3
keys = [t["key"] for t in data]
assert "disk_usage" in keys
assert "ffprobe" in keys
def test_run_job(self, test_client, mock_ssh):
mock_ssh.run.return_value = CommandResult(
command="du -sh '/media/test'",
exit_status=0,
stdout="5.0G\t/media/test\n",
stderr="",
)
response = test_client.post("/api/jobs/run", json={"job_key": "disk_usage", "path": "/media/test"})
assert response.status_code == 200
data = response.json()
assert data["exit_status"] == 0
assert "5.0G" in data["stdout"]
def test_run_unknown_job(self, test_client):
response = test_client.post("/api/jobs/run", json={"job_key": "nonexistent", "path": "/x"})
assert response.status_code == 400
# --- Monitoring ---
class TestResolveServiceRecord:
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
def _store(self, rows):
store = MagicMock()
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
def list_filtered(service_type=None):
return [r for r in rows if r["service_type"] == (service_type or r["service_type"])]
store.list_services = list_filtered
return store
def test_service_id_match_returns_record(self):
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row])
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = resolve_service_record(store, "alertmanager", "am1")
assert result == "RECORD"
mock_build.assert_called_once_with(store, row)
def test_service_id_type_mismatch_returns_none(self):
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row])
assert resolve_service_record(store, "alertmanager", "x1") is None
def test_service_id_disabled_returns_none(self):
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
store = self._store([row])
assert resolve_service_record(store, "alertmanager", "am1") is None
def test_no_service_id_returns_first_enabled(self):
from media_library_viewer_api.services.service_resolution import resolve_service_record
rows = [
{
"id": "am1",
"service_type": "alertmanager",
"name": "Disabled",
"enabled": False,
"config": {},
"secrets": {},
},
{
"id": "am2",
"service_type": "alertmanager",
"name": "Active",
"enabled": True,
"config": {},
"secrets": {},
},
]
store = self._store(rows)
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = resolve_service_record(store, "alertmanager", None)
assert result == "RECORD"
mock_build.assert_called_once_with(store, rows[1])
def test_no_service_id_and_none_enabled_returns_none(self):
from media_library_viewer_api.services.service_resolution import resolve_service_record
store = self._store([])
assert resolve_service_record(store, "alertmanager", None) is None
def _am_service(name="Alertmanager", **config):
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
cfg.update(config)
return ServiceRecord(id="am1", service_type="alertmanager", name=name, config=cfg)
class TestAlertmanager:
def test_alerts_endpoint_when_not_configured(self, test_client):
# No alertmanager service seeded -> registry resolves None.
response = test_client.get("/api/monitoring/alerts")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["error"] == "alertmanager_not_configured"
def test_alerts_endpoint_when_unreachable(self, test_client):
service = _am_service()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
):
response = test_client.get("/api/monitoring/alerts")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["error"] == "alertmanager_unreachable"
assert data["service_id"] == "am1"
assert data["name"] == "Alertmanager"
def test_alerts_endpoint_returns_summary(self, test_client):
service = _am_service()
resp = MagicMock()
resp.json.return_value = {
"status": "success",
"data": [
{
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
"annotations": {"summary": "Backup failed", "description": "details"},
"startsAt": "2026-05-11T02:00:00Z",
"status": "firing",
}
],
}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/alerts")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["by_severity"]["critical"] == 1
assert data["alerts"][0]["name"] == "BackupJobFailed"
assert data["service_id"] == "am1"
def test_alerts_endpoint_sends_bearer_token(self, test_client):
service = _am_service()
service.secrets = {"api_key": "tok"}
resp = MagicMock()
resp.json.return_value = {"status": "success", "data": []}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
):
test_client.get("/api/monitoring/alerts")
_, kwargs = mock_get.call_args
assert kwargs["headers"]["Authorization"] == "Bearer tok"
def test_alertmanager_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/alertmanager-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "alertmanager_not_configured"
assert data["peers"] == []
def test_alertmanager_status_when_unreachable(self, test_client):
service = _am_service()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
):
response = test_client.get("/api/monitoring/alertmanager-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "alertmanager_unreachable"
assert data["name"] == "Alertmanager"
def test_alertmanager_status_returns_cluster_info(self, test_client):
service = _am_service()
resp = MagicMock()
resp.json.return_value = {
"versionInfo": {"version": "0.27.0"},
"clusterStatus": {"createdAt": "2026-06-01T00:00:00Z"},
"cluster": {"peers": [{"name": "am-1"}, {"name": "am-2"}]},
}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/alertmanager-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is True
assert data["version"] == "0.27.0"
assert data["peers"] == ["am-1", "am-2"]
class TestAlertmanagerWebhook:
"""Tests for POST /api/monitoring/alertmanager-webhook (log-only receiver)."""
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
payload = {
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {"alertname": "BackupJobFailed", "severity": "critical"},
"annotations": {"summary": "Backup failed"},
"startsAt": "2026-06-13T10:00:00Z",
}
],
}
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "received"
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
class TestPrometheusStatus:
def test_prometheus_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "no_service_configured"
def test_prometheus_status_when_unreachable(self, test_client):
service = ServiceRecord(
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", side_effect=__import__("requests").ConnectionError("refused")),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "prometheus_unreachable"
def test_prometheus_status_returns_ok(self, test_client):
service = ServiceRecord(
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
gateway_resp = MagicMock()
gateway_resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", return_value=gateway_resp),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is True
assert data["version"] == "ok"
assert data["service_id"] == "p1"
@pytest.mark.parametrize("status_code", [401, 403])
def test_prometheus_status_returns_auth_failure_message(self, test_client, status_code):
# GM-110: a 401/403 from the Grafana gateway must surface as an
# auth-related error, not a crash and not a generic gateway error.
service = ServiceRecord(
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "bad-key"},
)
auth_error = requests.HTTPError(
f"{status_code} Client Error",
response=MagicMock(status_code=status_code),
)
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", side_effect=auth_error),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert not data["up"]
assert data["error"] == "auth_failed"
assert data["service_id"] == "p1"
class TestPrometheusStartupValidation:
"""GM-113: startup warns (never crashes) about old-shape prometheus services."""
def test_old_shape_prometheus_service_logs_migration_warning(self, tmp_path, caplog):
from media_library_viewer_api.main import _validate_prometheus_gateway_config
# Seed a prometheus service persisted with the OLD config shape: a
# ``base_url`` and no ``grafana_url`` (pre-gateway migration).
store = SettingsStore(tmp_path / "settings.sqlite")
store.upsert_service(
{
"service_type": "prometheus",
"name": "Legacy Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"enabled": True,
}
)
with (
patch("media_library_viewer_api.main.get_settings_store", return_value=store),
caplog.at_level(logging.WARNING, logger="media_library_viewer_api.main"),
):
# Must not raise even though the service uses the deprecated shape.
_validate_prometheus_gateway_config()
# Best-effort validator logs a migration hint referencing grafana_url.
assert "grafana_url" in caplog.text
assert any(record.levelno == logging.WARNING for record in caplog.records)
# --- Service credential tester endpoint (CT-101..CT-113) ---
class TestServiceTestEndpoint:
"""Tests for POST /api/services/test — dispatch, validation-first, no-persistence, no-secret-logs."""
def test_backups_returns_no_test_needed(self, test_client: TestClient) -> None:
"""backups has test_callable=None → returns ok=true with 'No test' detail."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "No" in body["detail"]
def test_validation_first_rejects_malformed_config(self, test_client: TestClient) -> None:
"""Malformed config (schema-less base_url) → 422, no test_callable called."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "qbittorrent",
"name": "test",
"config": {"base_url": "localhost:8080"}, # missing http://
"secrets": {"username": "u", "password": "p"},
"enabled": True,
},
)
assert response.status_code == 422
def test_no_persistence_after_test(self, test_client: TestClient, tmp_path) -> None:
"""Calling /test does not create a service row."""
store = app.dependency_overrides[get_settings_store]()
before = len(store.list_services())
test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
after = len(store.list_services())
assert before == after
def test_secrets_not_logged(self, test_client: TestClient, caplog) -> None:
"""Secret values in the request body never reach any log line.
Unlike the trivial empty-secrets case, this drives the full endpoint
path (validate -> dispatch to the real test_callable -> success log)
with real-looking secret payloads. The per-type test_callables are
mocked at the network boundary so they succeed, proving the endpoint
does not log the secret values even though they are in the request body.
"""
api_key_secret = "glc_somethingverysecret"
password_secret = "SUPER-SECRET-PW-12345"
prom_response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
qbit_client = MagicMock()
qbit_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
with (
caplog.at_level(logging.DEBUG),
patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=prom_response),
patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=qbit_client),
):
prom_resp = test_client.post(
"/api/services/test",
json={
"service_type": "prometheus",
"name": "test",
"config": {"grafana_url": "http://grafana:3000"},
"secrets": {"grafana_api_key": api_key_secret},
"enabled": True,
},
)
qbit_resp = test_client.post(
"/api/services/test",
json={
"service_type": "qbittorrent",
"name": "test",
"config": {"base_url": "http://qb:8080"},
"secrets": {"username": "u", "password": password_secret},
"enabled": True,
},
)
# Both requests must run the endpoint fully (validate + dispatch + success).
assert prom_resp.status_code == 200
assert prom_resp.json()["ok"] is True
assert qbit_resp.status_code == 200
assert qbit_resp.json()["ok"] is True
# Neither the full secret values nor meaningful fragments may leak into logs.
leaked = [
fragment for fragment in (api_key_secret, password_secret, "verysecret", "SUPER") if fragment in caplog.text
]
assert not leaked, f"secret fragments leaked into logs: {leaked!r}"