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}
|
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)
|
# 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, (
|
assert client_ref.is_closed, (
|
||||||
"app.state.federation_client must be closed after lifespan shutdown"
|
"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