Files
Unraid-AppOps-Gateway-Public/backend/tests/test_prowlarr_connector.py
T

334 lines
12 KiB
Python

from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine
@pytest.fixture
def api_client(tmp_path, monkeypatch):
db_path = tmp_path / "prowlarr_test.db"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
from backend.app.main import app
from backend.app.core.config import settings
from backend.app.db import session
from backend.app.db.init_db import init_db
previous_apis = dict(settings.apis)
for key in ["PROWLARR_URL", "PROWLARR_API_KEY", "PROWLARR_VERIFY_TLS", "PROWLARR_TIMEOUT_SECONDS"]:
settings.apis[key] = None
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
session.engine = engine
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
init_db()
with TestClient(app) as client:
yield client
settings.apis = previous_apis
def _set_prowlarr_config(url: str | None = "http://prowlarr:9696", api_key: str | None = "PROWLARR_SECRET_TOKEN"):
from backend.app.core.config import settings
previous = dict(settings.apis)
settings.apis["PROWLARR_URL"] = url
settings.apis["PROWLARR_API_KEY"] = api_key
settings.apis["PROWLARR_VERIFY_TLS"] = False
settings.apis["PROWLARR_TIMEOUT_SECONDS"] = "10"
return previous
def _restore_config(previous):
from backend.app.core.config import settings
settings.apis.clear()
settings.apis.update(previous)
def test_prowlarr_connector_missing_config_reports_required_env(api_client):
response = api_client.post("/api/connectors/prowlarr/test")
assert response.status_code == 200
payload = response.json()
assert payload["success"] is False
assert payload["status"] == "missing_config"
assert payload["missing_config"] is True
assert payload["required_env_vars"] == ["PROWLARR_URL", "PROWLARR_API_KEY"]
assert payload["secretRef"] == "PROWLARR_API_KEY"
def test_prowlarr_health_success_normalizes_response(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
monkeypatch.setattr(
ProwlarrConnector,
"_request",
lambda self, method, path, **_kwargs: {"version": "1.14.2", "appName": "Prowlarr"},
raising=True,
)
payload = ProwlarrConnector().health()
assert payload["success"] is True
assert payload["status"] == "connected"
assert payload["version"] == "1.14.2"
assert payload["url"] == "http://prowlarr:9696"
assert "PROWLARR_SECRET_TOKEN" not in str(payload)
_restore_config(previous)
def test_prowlarr_error_redacts_api_key(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
def fail_request(self, method, path, **_kwargs):
raise RuntimeError("api_key=PROWLARR_SECRET_TOKEN connection refused")
monkeypatch.setattr(ProwlarrConnector, "_request", fail_request, raising=True)
payload = ProwlarrConnector().health()
assert payload["success"] is False
assert payload["status"] in {"unreachable", "error"}
assert "PROWLARR_SECRET_TOKEN" not in str(payload)
assert "[REDACTED]" in str(payload)
_restore_config(previous)
def test_prowlarr_indexer_status_normalizes_counts(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
monkeypatch.setattr(
ProwlarrConnector,
"_request",
lambda self, method, path, **_kwargs: [
{"id": 1, "name": "Index A", "protocol": "torrent", "enable": True, "tags": [1], "privacy": "public"},
{"id": 2, "name": "Index B", "protocol": "usenet", "enabled": False, "status": "error", "categories": [{"name": "Movies"}], "privacy": "private"},
{"id": 3, "name": "Index C", "protocol": "torrent", "enable": True, "status": "warning"},
],
raising=True,
)
payload = ProwlarrConnector().indexer_status()
assert payload["success"] is True
assert payload["status"] == "ok"
assert payload["total"] == 3
assert payload["enabled"] == 2
assert payload["disabled"] == 1
assert payload["failing"] == 2
assert payload["protocols"] == {"torrent": 2, "usenet": 1, "unknown": 0}
assert payload["items"][0]["name"] == "Index A"
assert payload["items"][1]["categories"] == ["Movies"]
_restore_config(previous)
def test_prowlarr_application_status_normalizes_apps(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
monkeypatch.setattr(
ProwlarrConnector,
"_request",
lambda self, method, path, **_kwargs: [
{"id": 7, "name": "Sonarr", "implementation": "Sonarr", "syncLevel": "fullSync", "enable": True},
{"id": 8, "name": "Radarr", "implementation": "Radarr", "syncLevel": "addOnly", "enabled": False, "status": "warning"},
],
raising=True,
)
payload = ProwlarrConnector().application_status()
assert payload["success"] is True
assert payload["status"] == "ok"
assert payload["total"] == 2
assert payload["items"][0]["name"] == "Sonarr"
assert payload["items"][0]["syncLevel"] == "fullSync"
assert payload["items"][1]["enabled"] is False
assert payload["items"][1]["status"] == "warning"
_restore_config(previous)
def test_prowlarr_indexer_health_normalizes_warnings_and_errors(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
monkeypatch.setattr(
ProwlarrConnector,
"_request",
lambda self, method, path, **_kwargs: [
{"type": "warning", "message": "Indexer A slow"},
{"type": "error", "message": "Indexer B failing"},
],
raising=True,
)
payload = ProwlarrConnector().indexer_health()
assert payload["success"] is True
assert payload["warnings"] == ["Indexer A slow"]
assert payload["errors"] == ["Indexer B failing"]
assert payload["message"] == "health_issues_detected"
_restore_config(previous)
def test_prowlarr_search_test_is_read_only_and_normalizes_results(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
def fake_request(self, method, path, **kwargs):
assert method == "GET"
assert path == "/api/v1/search"
assert kwargs["params"]["query"] == "ubuntu"
return [
{
"title": "Ubuntu ISO",
"indexer": "Index A",
"protocol": "torrent",
"size": 123456,
"age": 2,
"seeders": 20,
"leechers": 1,
"categories": [{"name": "Linux"}],
"downloadUrl": "https://download.example/secret",
}
]
monkeypatch.setattr(ProwlarrConnector, "_request", fake_request, raising=True)
payload = ProwlarrConnector().search_test("ubuntu")
assert payload["success"] is True
assert payload["status"] == "ok"
assert payload["total"] == 1
assert payload["items"][0]["title"] == "Ubuntu ISO"
assert payload["items"][0]["category"] == "Linux"
assert payload["items"][0]["downloadAllowed"] is False
assert "downloadUrl" not in str(payload)
assert "PROWLARR_SECRET_TOKEN" not in str(payload)
_restore_config(previous)
def test_prowlarr_sync_status_summarizes_indexers_apps_and_health(monkeypatch):
from backend.app.connectors.prowlarr import ProwlarrConnector
previous = _set_prowlarr_config()
monkeypatch.setattr(ProwlarrConnector, "indexer_status", lambda self: {"success": True, "total": 3, "enabled": 2, "failing": 1}, raising=True)
monkeypatch.setattr(ProwlarrConnector, "application_status", lambda self: {"success": True, "total": 2, "items": []}, raising=True)
monkeypatch.setattr(ProwlarrConnector, "indexer_health", lambda self: {"success": True, "warnings": ["slow"], "errors": []}, raising=True)
payload = ProwlarrConnector().sync_status()
assert payload["success"] is True
assert payload["status"] == "ok"
assert payload["applicationsConfigured"] == 2
assert payload["indexersConfigured"] == 3
assert payload["indexersEnabled"] == 2
assert payload["healthWarnings"] == 1
_restore_config(previous)
def test_prowlarr_search_test_route_audits_manual_read_only(api_client, monkeypatch):
from backend.app.db.models import AuditEvent
from backend.app.db.session import engine
from sqlmodel import Session, select
monkeypatch.setattr(
"backend.app.connectors.prowlarr.ProwlarrConnector.search_test",
lambda self, query: {"success": True, "status": "ok", "message": "search_ok", "total": 1, "items": [{"title": query, "downloadAllowed": False}]},
raising=True,
)
response = api_client.get("/api/apps/prowlarr/search-test?q=ubuntu")
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["items"][0]["downloadAllowed"] is False
with Session(engine) as session:
events = session.exec(select(AuditEvent).where(AuditEvent.action_id == "prowlarr.search_test")).all()
assert len(events) == 1
assert events[0].redacted_metadata["query"] == "ubuntu"
assert events[0].redacted_metadata["downloadAllowed"] is False
assert "PROWLARR_SECRET_TOKEN" not in str(events[0].redacted_metadata)
def test_prowlarr_voice_search_test_requires_approval_without_connector_call(api_client, monkeypatch):
def fail_search(*_args, **_kwargs):
raise AssertionError("connector should not run before approval")
monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.search_test", fail_search, raising=True)
response = api_client.get("/api/apps/prowlarr/search-test?q=ubuntu&source=voice")
assert response.status_code == 200
payload = response.json()
assert payload["success"] is False
assert payload["decision"] == "approval_required"
assert payload["approvalId"].startswith("appr-")
assert payload["auditId"].startswith("audit-")
def test_prowlarr_mutating_actions_are_blocked(api_client):
for action in [
"prowlarr.delete_indexer",
"prowlarr.delete_application",
"prowlarr.update_indexer",
"prowlarr.update_application",
"prowlarr.grab_release",
"prowlarr.push_release",
"prowlarr.mass_edit",
"prowlarr.test_all_indexers",
]:
payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "prowlarr", "source": "manual"}).json()
assert payload["decision"] == "blocked"
def test_prowlarr_inventory_classification_and_candidate(api_client, monkeypatch):
from backend.app.services.app_classifier import classify_container
result = classify_container({"name": "binhex-prowlarr", "image": "ghcr.io/hotio/prowlarr:latest", "ports": ["9696:9696"]})
assert result["app_type"] == "prowlarr"
assert result["category"] == "media"
assert result["connector"] == "prowlarr"
assert result["policy_profile"] == "media_indexer"
assert result["healthcheck_suggestion"] == {"port": 9696}
monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True)
monkeypatch.setattr(
"backend.app.connectors.portainer.PortainerConnector.list_containers",
lambda self: {"status": "ok", "containers": [{"id": "prowlarr-container", "name": "binhex-prowlarr", "image": "linuxserver/prowlarr", "state": "running", "status": "running", "ports": ["9696:9696"]}]},
raising=True,
)
api_client.post("/api/inventory/discover")
candidates = api_client.get("/api/connectors/prowlarr/candidates").json()
assert len(candidates) == 1
assert candidates[0]["connectorId"] == "prowlarr"
assert candidates[0]["appType"] == "prowlarr"
assert candidates[0]["missingSecrets"] == ["PROWLARR_URL", "PROWLARR_API_KEY"]