From 8609cf788068b7049ede144ab2830d254a2a8e28 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 10:58:42 -0700 Subject: [PATCH] feat: add GET /api/federation/sessions proxy endpoint --- muxplex/main.py | 73 +++++++++++++++++++++++++++++ muxplex/tests/test_api.py | 97 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/muxplex/main.py b/muxplex/main.py index 7cfbc6d..0d411df 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -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) # --------------------------------------------------------------------------- diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 4234bbc..224ff68 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -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"