From 97f59fda8461a9aa5817fd3cc50723e5a650b061 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sat, 4 Apr 2026 07:43:18 -0700 Subject: [PATCH] feat: add POST /api/federation/{remote_id}/sessions proxy endpoint Adds federation_create_session endpoint following the same pattern as federation_connect and federation_bell_clear. The endpoint: - Looks up remote by integer index into remote_instances in settings - Sends POST {remote_url}/api/sessions with Bearer auth header and JSON body {name: ...} - Returns remote's JSON response - Raises 404 for invalid remote_id, 503 when unreachable, 502 on HTTP error Adds 4 tests covering: - Proxy behavior with correct URL, auth header, and JSON body forwarding - 404 for out-of-range remote_id - 503 when ConnectError raised - 502 when remote returns HTTP error status --- muxplex/main.py | 48 ++++++++++ muxplex/tests/test_api.py | 181 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/muxplex/main.py b/muxplex/main.py index 72456f3..30f0e52 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -1084,6 +1084,54 @@ async def federation_bell_clear( ) +@app.post("/api/federation/{remote_id}/sessions") +async def federation_create_session( + remote_id: int, payload: CreateSessionPayload, request: Request +) -> dict: + """Proxy a create-session POST to a remote instance. + + Looks up the remote by integer index into ``remote_instances`` in settings, + sends ``POST {remote_url}/api/sessions`` with a Bearer auth header and JSON + body ``{name: ...}``, and returns the remote's JSON response. + + Raises HTTP 404 if ``remote_id`` is not a valid integer index, + 503 when remote is unreachable, 502 when remote returns HTTP error. + """ + settings = load_settings() + remotes = settings.get("remote_instances", []) + if remote_id < 0 or remote_id >= len(remotes): + raise HTTPException( + status_code=404, + detail=f"Remote instance '{remote_id}' not found", + ) + remote = remotes[remote_id] + remote_url: str = remote.get("url", "").rstrip("/") + remote_key: str = remote.get("key", "") + url = f"{remote_url}/api/sessions" + http_client: httpx.AsyncClient = request.app.state.federation_client + try: + resp = await http_client.post( + url, + headers={"Authorization": f"Bearer {remote_key}"}, + json={"name": payload.name}, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=502, + detail=f"Remote returned {exc.response.status_code}", + ) + except Exception as exc: + _log.warning( + "federation_create_session: remote %s unreachable: %s", remote_url, exc + ) + raise HTTPException( + status_code=503, + detail=f"Remote unreachable: {remote_url}", + ) + + # --------------------------------------------------------------------------- # 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 fb480fe..7b197c3 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -2417,3 +2417,184 @@ def test_federation_bell_clear_returns_404_for_invalid_remote( response = client.post("/api/federation/0/sessions/my-session/bell/clear") assert response.status_code == 404 + + +def test_federation_create_session_proxies_to_remote(client, monkeypatch, tmp_path): + """POST /api/federation/{remote_id}/sessions proxies POST to remote's /api/sessions endpoint. + + Looks up remote by integer index, sends POST {remote_url}/api/sessions with Bearer auth + header and JSON body {name: ...}, and returns the remote's JSON response. + """ + import json + from unittest.mock import MagicMock + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + { + "url": "http://remote-host:8088", + "key": "secret-key-123", + "name": "remote-host", + "id": "remote-0", + } + ], + } + ) + ) + + # Track what POST was called with + post_calls = [] + + async def mock_post(url, **kwargs): + post_calls.append({"url": url, "kwargs": kwargs}) + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"name": "new-session", "pid": 12345} + return mock_resp + + mock_fed_client = MagicMock() + mock_fed_client.post = mock_post + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + response = client.post("/api/federation/0/sessions", json={"name": "new-session"}) + assert response.status_code == 200 + + # Verify the POST was made to the correct remote URL + assert len(post_calls) == 1, f"Expected exactly 1 POST call, got {len(post_calls)}" + call = post_calls[0] + assert call["url"] == "http://remote-host:8088/api/sessions", ( + f"Expected POST to remote /api/sessions URL, got: {call['url']}" + ) + + # Verify Bearer auth was included + headers = call["kwargs"].get("headers", {}) + assert headers.get("Authorization") == "Bearer secret-key-123", ( + f"Expected Bearer auth header, got: {headers}" + ) + + # Verify JSON body was forwarded + json_body = call["kwargs"].get("json", {}) + assert json_body.get("name") == "new-session", ( + f"Expected JSON body with name='new-session', got: {json_body}" + ) + + # Verify the response is the remote's JSON + data = response.json() + assert data["name"] == "new-session" + assert data["pid"] == 12345 + + +def test_federation_create_session_returns_404_for_invalid_remote( + client, monkeypatch, tmp_path +): + """POST /api/federation/{remote_id}/sessions returns 404 when remote_id is out of range.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + # No remote instances configured + settings_path.write_text(json.dumps({"remote_instances": []})) + + response = client.post("/api/federation/0/sessions", json={"name": "new-session"}) + assert response.status_code == 404 + + +def test_federation_create_session_returns_503_when_remote_unreachable( + client, monkeypatch, tmp_path +): + """POST /api/federation/{remote_id}/sessions returns 503 when remote is unreachable. + + If the outbound http_client.post() raises a network-level exception (e.g. ConnectError), + the endpoint must return 503 rather than propagating a raw 500. + """ + import json + from unittest.mock import MagicMock + + import httpx + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + { + "url": "http://remote-host:8088", + "key": "secret-key-123", + "name": "remote-host", + "id": "remote-0", + } + ], + } + ) + ) + + async def mock_post_unreachable(*args, **kwargs): + raise httpx.ConnectError("Connection refused") + + mock_fed_client = MagicMock() + mock_fed_client.post = mock_post_unreachable + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + response = client.post("/api/federation/0/sessions", json={"name": "new-session"}) + assert response.status_code == 503 + + +def test_federation_create_session_returns_502_when_remote_returns_error( + client, monkeypatch, tmp_path +): + """POST /api/federation/{remote_id}/sessions returns 502 when remote returns HTTP error. + + If the outbound http_client.post() returns a non-2xx response that raises HTTPStatusError + (via raise_for_status), the endpoint must return 502 with the upstream status code in detail. + """ + import json + from unittest.mock import MagicMock + + import httpx + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + { + "url": "http://remote-host:8088", + "key": "secret-key-123", + "name": "remote-host", + "id": "remote-0", + } + ], + } + ) + ) + + async def mock_post_error(*args, **kwargs): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 422 + raise httpx.HTTPStatusError( + "Unprocessable Entity", + request=MagicMock(), + response=mock_response, + ) + + mock_fed_client = MagicMock() + mock_fed_client.post = mock_post_error + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + response = client.post("/api/federation/0/sessions", json={"name": "new-session"}) + assert response.status_code == 502