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"]