feat: add GET /api/federation/sessions proxy endpoint
This commit is contained in:
@@ -765,6 +765,79 @@ async def auth_mode_endpoint():
|
||||
return {"mode": _auth_mode, "user": username}
|
||||
|
||||
|
||||
@app.get("/api/federation/sessions")
|
||||
async def federation_sessions(request: Request) -> list[dict]:
|
||||
"""Fetch sessions from all instances (local + remotes) and merge.
|
||||
|
||||
Local sessions are tagged with deviceName (from settings) and remoteId=None.
|
||||
Remote sessions are fetched concurrently via asyncio.gather with Bearer auth
|
||||
headers. Failed remotes produce a status entry with status='unreachable' or
|
||||
status='auth_failed'.
|
||||
"""
|
||||
settings = load_settings()
|
||||
local_device_name: str = settings.get("device_name", "")
|
||||
remote_instances: list[dict] = settings.get("remote_instances", [])
|
||||
|
||||
# Build local sessions with deviceName/remoteId tags
|
||||
names = get_session_list()
|
||||
snapshots = get_snapshots()
|
||||
state = await read_state()
|
||||
local_sessions: list[dict] = []
|
||||
for name in names:
|
||||
session_state = state.get("sessions", {}).get(name, {})
|
||||
bell = session_state.get("bell", empty_bell())
|
||||
local_sessions.append(
|
||||
{
|
||||
"name": name,
|
||||
"snapshot": snapshots.get(name, ""),
|
||||
"bell": bell,
|
||||
"deviceName": local_device_name,
|
||||
"remoteId": None,
|
||||
}
|
||||
)
|
||||
|
||||
if not remote_instances:
|
||||
return local_sessions
|
||||
|
||||
# Fetch remote sessions concurrently
|
||||
http_client: httpx.AsyncClient = request.app.state.federation_client
|
||||
|
||||
async def fetch_remote(remote: dict) -> list[dict]:
|
||||
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry."""
|
||||
url: str = remote.get("url", "")
|
||||
key: str = remote.get("key", "")
|
||||
remote_name: str = remote.get("name", url)
|
||||
remote_id: str = remote.get("id", url)
|
||||
try:
|
||||
resp = await http_client.get(
|
||||
f"{url.rstrip('/')}/api/sessions",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
if resp.status_code in (401, 403):
|
||||
return [{"status": "auth_failed", "remoteId": remote_id, "deviceName": remote_name}]
|
||||
resp.raise_for_status()
|
||||
sessions = resp.json()
|
||||
# Tag each session with deviceName and remoteId
|
||||
return [
|
||||
{**s, "deviceName": remote_name, "remoteId": remote_id}
|
||||
for s in sessions
|
||||
]
|
||||
except httpx.HTTPStatusError:
|
||||
return [{"status": "auth_failed", "remoteId": remote_id, "deviceName": remote_name}]
|
||||
except Exception:
|
||||
return [{"status": "unreachable", "remoteId": remote_id, "deviceName": remote_name}]
|
||||
|
||||
remote_results: list[list[dict]] = await asyncio.gather(
|
||||
*(fetch_remote(remote) for remote in remote_instances)
|
||||
)
|
||||
|
||||
all_sessions: list[dict] = list(local_sessions)
|
||||
for result in remote_results:
|
||||
all_sessions.extend(result)
|
||||
|
||||
return all_sessions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static file serving — MUST come after all API routes (first-match-wins)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1471,3 +1471,100 @@ def test_federation_client_exists_on_app_state(monkeypatch):
|
||||
assert client_ref.is_closed, (
|
||||
"app.state.federation_client must be closed after lifespan shutdown"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/federation/sessions (task-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_federation_sessions_returns_local_sessions(client, monkeypatch, tmp_path):
|
||||
"""GET /api/federation/sessions returns local sessions tagged with deviceName and remoteId=None.
|
||||
|
||||
Local sessions must have:
|
||||
- deviceName from settings device_name
|
||||
- remoteId set to None
|
||||
- The session fields (name, snapshot, bell) from local /api/sessions
|
||||
"""
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Write settings with a known device_name and no remote instances
|
||||
import json
|
||||
settings_path.write_text(json.dumps({"device_name": "my-workstation", "remote_instances": []}))
|
||||
|
||||
# Mock local session data
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["session-one"])
|
||||
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"session-one": "pane text"})
|
||||
|
||||
response = client.get("/api/federation/sessions")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Must return a list
|
||||
assert isinstance(data, list)
|
||||
|
||||
# Find local sessions (remoteId=None)
|
||||
local_sessions = [s for s in data if s.get("remoteId") is None]
|
||||
assert len(local_sessions) == 1, f"Expected 1 local session, got: {local_sessions}"
|
||||
|
||||
local = local_sessions[0]
|
||||
assert local["name"] == "session-one"
|
||||
assert local["deviceName"] == "my-workstation"
|
||||
assert local["remoteId"] is None
|
||||
|
||||
|
||||
def test_federation_sessions_includes_remote_failure_status(client, monkeypatch, tmp_path):
|
||||
"""GET /api/federation/sessions includes a status entry for unreachable remotes.
|
||||
|
||||
When a remote instance cannot be reached (connection error), the result must
|
||||
include a status entry with status='unreachable' for that remote.
|
||||
"""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Configure one remote instance that will fail
|
||||
settings_path.write_text(json.dumps({
|
||||
"device_name": "local-host",
|
||||
"remote_instances": [
|
||||
{"url": "http://remote-host:8088", "key": "abc123", "name": "remote-host", "id": "remote-1"}
|
||||
],
|
||||
}))
|
||||
|
||||
# Mock local sessions (empty for simplicity)
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
||||
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
||||
|
||||
# Patch the federation_client to raise a ConnectError (unreachable)
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
async def mock_get(url, **kwargs):
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = mock_get
|
||||
monkeypatch.setattr(client.app.state, "federation_client", mock_client)
|
||||
|
||||
response = client.get("/api/federation/sessions")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Must return a list
|
||||
assert isinstance(data, list)
|
||||
|
||||
# Find the failure status entry for the remote
|
||||
failure_entries = [s for s in data if s.get("status") in ("unreachable", "auth_failed")]
|
||||
assert len(failure_entries) == 1, (
|
||||
f"Expected 1 failure status entry, got: {failure_entries}. Full data: {data}"
|
||||
)
|
||||
entry = failure_entries[0]
|
||||
assert entry["status"] == "unreachable"
|
||||
assert entry.get("remoteId") == "remote-1"
|
||||
|
||||
Reference in New Issue
Block a user