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

346 lines
13 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 / "plex_test.db"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
from backend.app.core.config import settings
from backend.app.db import session
from backend.app.db.init_db import init_db
from backend.app.main import app
previous_apis = dict(settings.apis)
for key in ["PLEX_URL", "PLEX_TOKEN", "PLEX_VERIFY_TLS", "PLEX_TIMEOUT_SECONDS", "PLEX_DEFAULT_LIBRARY_ID", "PLEX_MACHINE_IDENTIFIER"]:
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_plex_config(url: str | None = "http://plex:32400", token: str | None = "PLEX_SECRET_TOKEN"):
from backend.app.core.config import settings
previous = dict(settings.apis)
settings.apis["PLEX_URL"] = url
settings.apis["PLEX_TOKEN"] = token
settings.apis["PLEX_VERIFY_TLS"] = False
settings.apis["PLEX_TIMEOUT_SECONDS"] = "10"
settings.apis["PLEX_DEFAULT_LIBRARY_ID"] = None
settings.apis["PLEX_MACHINE_IDENTIFIER"] = None
return previous
def _restore_config(previous):
from backend.app.core.config import settings
settings.apis.clear()
settings.apis.update(previous)
def _server_payload():
return {
"MediaContainer": {
"friendlyName": "Widefrog Plex",
"machineIdentifier": "machine-123456789",
"version": "1.40.2",
"platform": "Linux",
"updatedAt": 1700000000,
"myPlex": True,
}
}
def _libraries_payload():
return {
"MediaContainer": {
"Directory": [
{"key": "1", "title": "Movies", "type": "movie", "agent": "tv.plex.agents.movie", "scanner": "Plex Movie", "Location": [{"path": "/srv/example/media/movies"}]},
{"key": "2", "title": "Shows", "type": "show", "agent": "tv.plex.agents.series", "scanner": "Plex Series", "Location": [{"path": "/srv/example/media/tv"}]},
]
}
}
def _sessions_payload():
return {
"MediaContainer": {
"size": 2,
"Metadata": [
{
"sessionKey": "11",
"title": "Direct Movie",
"type": "movie",
"User": {"title": "Jens"},
"Player": {"title": "Apple TV", "state": "playing", "product": "Plex", "platform": "tvOS", "address": "198.18.10.55"},
"Session": {"bandwidth": 12000},
"Media": [{"Part": [{"decision": "directplay"}], "videoDecision": "directplay", "audioDecision": "directplay"}],
"viewOffset": 120000,
"duration": 240000,
},
{
"sessionKey": "12",
"title": "Transcoded Episode",
"type": "episode",
"User": {"title": "Remote"},
"Player": {"title": "Chrome", "state": "playing", "product": "Plex Web", "platform": "Windows", "address": "203.0.113.9"},
"Session": {"bandwidth": 4500},
"TranscodeSession": {"videoDecision": "transcode", "audioDecision": "copy"},
"Media": [{"videoDecision": "transcode", "audioDecision": "copy"}],
"viewOffset": 50000,
"duration": 100000,
},
],
}
}
def _recent_payload():
return {
"MediaContainer": {
"Metadata": [
{"title": "Arrival", "type": "movie", "year": 2016, "librarySectionTitle": "Movies", "addedAt": 1700000000, "thumb": "/library/metadata/1/thumb", "summary": "A quiet first contact story."},
{"title": "Pilot", "grandparentTitle": "Example Show", "type": "episode", "year": 2024, "librarySectionTitle": "Shows", "addedAt": 1700000500},
]
}
}
def test_plex_connector_missing_config_reports_required_env(api_client):
response = api_client.post("/api/connectors/plex/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"] == ["PLEX_URL", "PLEX_TOKEN"]
assert payload["secretRef"] == "PLEX_TOKEN"
def test_plex_health_success_normalizes_response(monkeypatch):
from backend.app.connectors.plex import PlexConnector
previous = _set_plex_config()
monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _server_payload(), raising=True)
payload = PlexConnector().health()
assert payload["success"] is True
assert payload["status"] == "connected"
assert payload["friendlyName"] == "Widefrog Plex"
assert payload["version"] == "1.40.2"
assert payload["machineIdentifier"] == "machine-123456789"
assert payload["url"] == "http://plex:32400"
assert "PLEX_SECRET_TOKEN" not in str(payload)
_restore_config(previous)
def test_plex_auth_failure_redacts_token(monkeypatch):
from backend.app.connectors.plex import PlexConnector
previous = _set_plex_config()
def fail_request(self, path, params=None):
raise RuntimeError("auth_failed token=PLEX_SECRET_TOKEN")
monkeypatch.setattr(PlexConnector, "_request", fail_request, raising=True)
payload = PlexConnector().health()
assert payload["success"] is False
assert payload["status"] == "auth_failed"
assert "PLEX_SECRET_TOKEN" not in str(payload)
assert "[REDACTED]" in str(payload)
_restore_config(previous)
def test_plex_library_status_normalizes_sections(monkeypatch):
from backend.app.connectors.plex import PlexConnector
previous = _set_plex_config()
def fake_request(self, path, params=None):
if path == "/library/sections":
return _libraries_payload()
if path == "/library/sections/1/all":
return {"MediaContainer": {"size": 14}}
if path == "/library/sections/2/all":
return {"MediaContainer": {"size": 8}}
return _server_payload()
monkeypatch.setattr(PlexConnector, "_request", fake_request, raising=True)
payload = PlexConnector().library_status()
assert payload["success"] is True
assert payload["total"] == 2
assert payload["items"][0]["id"] == "1"
assert payload["items"][0]["type"] == "movie"
assert payload["items"][0]["count"] == 14
assert payload["items"][0]["locations"] == [".../movies"]
_restore_config(previous)
def test_plex_sessions_and_stream_summary(monkeypatch):
from backend.app.connectors.plex import PlexConnector
previous = _set_plex_config()
monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _sessions_payload(), raising=True)
sessions = PlexConnector().active_sessions()
summary = PlexConnector().stream_summary()
assert sessions["success"] is True
assert sessions["total"] == 2
assert sessions["items"][0]["user"] == "Jens"
assert sessions["items"][0]["ip"] == "192.168.10.x"
assert sessions["items"][1]["transcodeDecision"] == "transcode"
assert summary["activeStreams"] == 2
assert summary["directPlay"] == 1
assert summary["transcoding"] == 1
assert summary["users"] == 2
_restore_config(previous)
def test_plex_recent_media_normalizes_items(monkeypatch):
from backend.app.connectors.plex import PlexConnector
previous = _set_plex_config()
monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _recent_payload(), raising=True)
payload = PlexConnector().recent_media()
assert payload["success"] is True
assert payload["total"] == 2
assert payload["items"][0]["title"] == "Arrival"
assert payload["items"][1]["title"] == "Example Show - Pilot"
_restore_config(previous)
def test_plex_scan_library_allowed_creates_job_and_audit(api_client, monkeypatch):
monkeypatch.setattr(
"backend.app.connectors.plex.PlexConnector.scan_library",
lambda self, library_id: {"success": True, "status": "ok", "message": "scan_started", "data": {"libraryId": library_id}},
raising=True,
)
response = api_client.post("/api/apps/plex/libraries/1/scan", json={"source": "manual"})
payload = response.json()
assert response.status_code == 200
assert payload["success"] is True
assert payload["decision"] == "allowed"
assert payload["jobId"].startswith("job-")
assert payload["auditId"].startswith("audit-")
assert payload["data"]["libraryId"] == "1"
def test_plex_scan_library_voice_requires_approval_without_connector_call(api_client, monkeypatch):
def fail_scan(*_args, **_kwargs):
raise AssertionError("connector should not run before approval")
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_library", fail_scan, raising=True)
payload = api_client.post("/api/apps/plex/libraries/1/scan", json={"source": "voice"}).json()
assert payload["success"] is False
assert payload["decision"] == "approval_required"
assert payload["approvalId"].startswith("appr-")
assert payload["auditId"].startswith("audit-")
def test_plex_scan_all_requires_approval_or_blocks_without_connector_call(api_client, monkeypatch):
def fail_scan_all(*_args, **_kwargs):
raise AssertionError("scan all should wait for approval")
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_all_libraries", fail_scan_all, raising=True)
manual = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "manual"}).json()
voice = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "voice"}).json()
assert manual["decision"] == "approval_required"
assert voice["decision"] == "blocked"
def test_plex_scan_all_executes_after_approval(api_client, monkeypatch):
calls = []
def scan_all(_self):
calls.append('scan')
return {'success': True, 'status': 'accepted', 'message': 'scan_all_started'}
monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_all_libraries", scan_all, raising=True)
requested = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "manual"}).json()
api_client.patch(f"/api/approvals/{requested['approvalId']}", json={"status": "approved"})
executed = api_client.post(f"/api/approvals/{requested['approvalId']}/execute").json()
assert executed['success'] is True
assert executed['approvalStatus'] == 'executed'
assert calls == ['scan']
def test_plex_destructive_actions_are_blocked(api_client):
for action in [
"plex.delete_media",
"plex.delete_library",
"plex.empty_trash",
"plex.clean_bundles",
"plex.optimize_database",
"plex.kill_session",
"plex.user_management",
"plex.force_refresh_all_metadata",
"plex.mass_edit",
"plex.unknown_mutation",
]:
payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "plex", "source": "manual"}).json()
assert payload["decision"] == "blocked"
def test_plex_inventory_classification_and_candidate(api_client, monkeypatch):
from backend.app.services.app_classifier import classify_container
result = classify_container({"name": "binhex-plexpass", "image": "binhex/arch-plexpass:latest", "ports": ["32400:32400"]})
assert result["app_type"] == "plex"
assert result["category"] == "media"
assert result["subcategory"] == "media_server"
assert result["connector"] == "plex"
assert result["policy_profile"] == "media_server"
assert result["healthcheck_suggestion"] == {"port": 32400}
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": "plex-container", "name": "binhex-plexpass", "image": "binhex/arch-plexpass", "state": "running", "status": "running", "ports": ["32400:32400"]}]},
raising=True,
)
api_client.post("/api/inventory/discover")
candidates = api_client.get("/api/connectors/plex/candidates").json()
assert len(candidates) == 1
assert candidates[0]["connectorId"] == "plex"
assert candidates[0]["appType"] == "plex"
assert candidates[0]["missingSecrets"] == ["PLEX_URL", "PLEX_TOKEN"]