334 lines
12 KiB
Python
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"]
|