Files
hermes-relay/plugin/dashboard/test_plugin_api.py
T

407 lines
16 KiB
Python

"""Hermetic unit tests for the dashboard plugin's proxy router.
Uses FastAPI's ``TestClient`` + ``httpx.MockTransport`` patched over
``httpx.AsyncClient`` so no real HTTP hits the loopback relay during CI.
"""
from __future__ import annotations
import unittest
from typing import Callable, Optional
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from plugin.dashboard import plugin_api
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _install_mock_transport(
test_case: "PluginApiTestCase",
handler: Callable[[httpx.Request], httpx.Response],
) -> list[httpx.Request]:
"""Replace ``httpx.AsyncClient`` with one backed by a MockTransport.
Returns a list that the test can inspect post-call to see what requests
the proxy actually issued (URL, query params, etc.). Undo on tearDown.
"""
captured: list[httpx.Request] = []
def _capture(request: httpx.Request) -> httpx.Response:
captured.append(request)
return handler(request)
transport = httpx.MockTransport(_capture)
original = httpx.AsyncClient
class _PatchedClient(httpx.AsyncClient):
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
kwargs["transport"] = transport
super().__init__(*args, **kwargs)
httpx.AsyncClient = _PatchedClient # type: ignore[misc,assignment]
test_case.addCleanup(lambda: setattr(httpx, "AsyncClient", original))
return captured
def _build_client() -> TestClient:
app = FastAPI()
app.include_router(plugin_api.router)
return TestClient(app)
class PluginApiTestCase(unittest.TestCase):
def setUp(self) -> None:
self.client = _build_client()
# ---------------------------------------------------------------------------
# 2xx passthrough
# ---------------------------------------------------------------------------
class OverviewTests(PluginApiTestCase):
def test_overview_forwards_relay_json(self) -> None:
payload = {"version": "0.5.0", "uptime_seconds": 42, "health": "ok"}
def handler(request: httpx.Request) -> httpx.Response:
self.assertEqual(request.url.path, "/relay/info")
self.assertEqual(request.url.host, "127.0.0.1")
self.assertEqual(request.url.port, plugin_api.RELAY_PORT)
return httpx.Response(200, json=payload)
_install_mock_transport(self, handler)
resp = self.client.get("/overview")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), payload)
class SessionsTests(PluginApiTestCase):
def test_sessions_forwards_relay_json(self) -> None:
payload = {"sessions": [{"prefix": "abc12345", "paired_at": 1_700_000_000}]}
def handler(request: httpx.Request) -> httpx.Response:
self.assertEqual(request.url.path, "/sessions")
return httpx.Response(200, json=payload)
_install_mock_transport(self, handler)
resp = self.client.get("/sessions")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), payload)
class BridgeActivityTests(PluginApiTestCase):
def test_limit_param_is_forwarded(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
self.assertEqual(request.url.path, "/bridge/activity")
self.assertEqual(request.url.params.get("limit"), "5")
return httpx.Response(200, json={"activity": []})
captured = _install_mock_transport(self, handler)
resp = self.client.get("/bridge-activity", params={"limit": 5})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), {"activity": []})
self.assertEqual(len(captured), 1)
def test_no_limit_param_omits_query(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
self.assertNotIn("limit", request.url.params)
return httpx.Response(200, json={"activity": []})
_install_mock_transport(self, handler)
resp = self.client.get("/bridge-activity")
self.assertEqual(resp.status_code, 200)
class MediaTests(PluginApiTestCase):
def test_include_expired_flag_forwards_true(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
self.assertEqual(request.url.path, "/media/inspect")
self.assertEqual(request.url.params.get("include_expired"), "true")
return httpx.Response(200, json={"media": []})
_install_mock_transport(self, handler)
resp = self.client.get("/media", params={"include_expired": "true"})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), {"media": []})
# ---------------------------------------------------------------------------
# Push stub — no network call
# ---------------------------------------------------------------------------
class PushTests(PluginApiTestCase):
def test_push_is_static_and_hits_no_network(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("push endpoint must not touch the network")
captured = _install_mock_transport(self, handler)
resp = self.client.get("/push")
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body["configured"], False)
self.assertIn("FCM", body["reason"])
self.assertEqual(len(captured), 0)
# ---------------------------------------------------------------------------
# Error translation
# ---------------------------------------------------------------------------
class RelayErrorTests(PluginApiTestCase):
def test_timeout_becomes_502_with_informative_detail(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.TimeoutException("timed out", request=request)
_install_mock_transport(self, handler)
resp = self.client.get("/overview")
self.assertEqual(resp.status_code, 502)
detail = resp.json()["detail"]
self.assertIn("relay unreachable", detail)
self.assertIn(f"127.0.0.1:{plugin_api.RELAY_PORT}", detail)
def test_connection_error_becomes_502(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
_install_mock_transport(self, handler)
resp = self.client.get("/sessions")
self.assertEqual(resp.status_code, 502)
self.assertIn("relay unreachable", resp.json()["detail"])
def test_relay_5xx_becomes_502(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="relay overloaded")
_install_mock_transport(self, handler)
resp = self.client.get("/overview")
self.assertEqual(resp.status_code, 502)
self.assertIn("relay unreachable", resp.json()["detail"])
def test_relay_404_passes_through(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"error": "not found"})
_install_mock_transport(self, handler)
resp = self.client.get("/overview")
self.assertEqual(resp.status_code, 404)
# FastAPI wraps HTTPException detail in {"detail": ...}.
self.assertEqual(resp.json(), {"detail": {"error": "not found"}})
# ---------------------------------------------------------------------------
# Remote Access tab — tailscale helper, public URL state, /probe
# ---------------------------------------------------------------------------
class RemoteAccessStateTests(PluginApiTestCase):
def setUp(self) -> None:
super().setUp()
# Redirect ``HERMES_HOME`` into a tmp dir so each test writes its
# own relay-remote.json instead of touching the real one. We
# monkey-patch ``_hermes_home`` rather than the env var so the
# path the route resolves is identical to what the test
# resolves.
import tempfile
self._tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self._tmpdir.cleanup)
self._orig_hermes_home = plugin_api._hermes_home
plugin_api._hermes_home = lambda: __import__("pathlib").Path(self._tmpdir.name)
self.addCleanup(lambda: setattr(plugin_api, "_hermes_home", self._orig_hermes_home))
def test_put_public_url_persists_then_get_reads(self) -> None:
resp = self.client.put(
"/remote-access/public-url", json={"url": "https://relay.example.com"}
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["url"], "https://relay.example.com")
resp = self.client.get("/remote-access/public-url")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["url"], "https://relay.example.com")
def test_put_public_url_clears_on_empty(self) -> None:
self.client.put("/remote-access/public-url", json={"url": "https://a.example.com"})
resp = self.client.put("/remote-access/public-url", json={"url": ""})
self.assertEqual(resp.status_code, 200)
self.assertIsNone(resp.json()["url"])
resp = self.client.get("/remote-access/public-url")
self.assertEqual(resp.json()["url"], None)
def test_put_public_url_rejects_bad_scheme(self) -> None:
resp = self.client.put(
"/remote-access/public-url", json={"url": "ftp://example.com"}
)
self.assertEqual(resp.status_code, 400)
self.assertIn("http", resp.json()["detail"])
def test_put_public_url_rejects_non_string(self) -> None:
resp = self.client.put("/remote-access/public-url", json={"url": 42})
self.assertEqual(resp.status_code, 400)
class RemoteAccessProbeTests(PluginApiTestCase):
def test_probe_returns_per_candidate_reachability(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
# Accept the /health suffix the route appends.
if request.url.path == "/health" and request.url.host == "relay.example.com":
return httpx.Response(200, json={"ok": True})
return httpx.Response(500, text="unexpected url")
_install_mock_transport(self, handler)
resp = self.client.post(
"/remote-access/probe",
json={"candidates": ["https://relay.example.com"]},
)
self.assertEqual(resp.status_code, 200)
results = resp.json()["results"]
self.assertEqual(len(results), 1)
self.assertTrue(results[0]["reachable"])
self.assertEqual(results[0]["status"], 200)
def test_probe_captures_connect_error_per_entry(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=request)
_install_mock_transport(self, handler)
resp = self.client.post(
"/remote-access/probe",
json={"candidates": ["https://down.example.com"]},
)
self.assertEqual(resp.status_code, 200)
results = resp.json()["results"]
self.assertEqual(len(results), 1)
self.assertFalse(results[0]["reachable"])
self.assertIn("refused", results[0]["error"])
def test_probe_rejects_non_array(self) -> None:
resp = self.client.post(
"/remote-access/probe", json={"candidates": "https://a.example.com"}
)
self.assertEqual(resp.status_code, 400)
class RemoteAccessStatusTests(PluginApiTestCase):
def test_status_surfaces_tailscale_dict_and_public_pin(self) -> None:
# Monkey-patch the tailscale helper so the test doesn't shell out.
from plugin.relay import tailscale as ts_mod
orig_status = ts_mod.status
orig_canonical = ts_mod.canonical_upstream_present
ts_mod.status = lambda: {
"available": True,
"hostname": "hermes.tail1234.ts.net",
"tailscale_ip": "100.64.0.1",
"serve_ports": [8767],
}
ts_mod.canonical_upstream_present = lambda: False
self.addCleanup(lambda: setattr(ts_mod, "status", orig_status))
self.addCleanup(lambda: setattr(ts_mod, "canonical_upstream_present", orig_canonical))
resp = self.client.get("/remote-access/status")
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertEqual(body["tailscale"]["hostname"], "hermes.tail1234.ts.net")
self.assertIsNone(body["public"]["url"])
self.assertFalse(body["upstream_canonical"])
class PhoneConfigTests(PluginApiTestCase):
"""``GET /phone/config`` reflects the phone adapter's env resolution.
No relay round-trip — the route reads process env directly, so these are
hermetic with no MockTransport.
"""
_ENV_KEYS = ("PHONE_ENABLED", "PHONE_HOME_CHANNEL", "PHONE_HOME_CHANNEL_NAME")
def setUp(self) -> None:
super().setUp()
import os
self._saved = {k: os.environ.get(k) for k in self._ENV_KEYS}
for k in self._ENV_KEYS:
os.environ.pop(k, None)
def tearDown(self) -> None:
import os
for k, v in self._saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_disabled_defaults(self) -> None:
resp = self.client.get("/phone/config")
self.assertEqual(resp.status_code, 200)
body = resp.json()
self.assertFalse(body["enabled"])
self.assertEqual(body["home_channel_id"], "phone")
self.assertEqual(body["home_channel_name"], "Phone")
self.assertEqual(body["name_env_key"], "PHONE_HOME_CHANNEL_NAME")
def test_enabled_custom_name(self) -> None:
import os
os.environ["PHONE_ENABLED"] = "1"
os.environ["PHONE_HOME_CHANNEL_NAME"] = "Pixel 9"
body = self.client.get("/phone/config").json()
self.assertTrue(body["enabled"])
self.assertEqual(body["home_channel_name"], "Pixel 9")
self.assertEqual(body["home_channel_id"], "phone")
class UpdateCheckTests(PluginApiTestCase):
"""``GET /update-check`` compares installed vs latest GitHub plugin release."""
def setUp(self) -> None:
super().setUp()
# Reset the module cache so every test triggers a (mocked) fetch.
plugin_api._UPDATE_CACHE.update(latest=None, fetched_at=0.0, error=None)
def test_update_available(self) -> None:
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"tag_name": "server-v99.0.0"}])
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertTrue(body["update_available"])
self.assertEqual(body["latest"], "99.0.0")
self.assertIn("hermes", body["update_command"])
def test_up_to_date(self) -> None:
from plugin import update_check
cur = update_check.current_version()
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"tag_name": f"server-v{cur}"}])
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertFalse(body["update_available"])
self.assertEqual(body["latest"], cur)
def test_github_error_degrades_softly(self) -> None:
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="nope")
_install_mock_transport(self, handler)
body = self.client.get("/update-check").json()
self.assertFalse(body["update_available"])
self.assertIsNotNone(body["error"])
if __name__ == "__main__": # pragma: no cover
unittest.main()