b1f4569608
P0.1: Visual Hierarchy for Tiles
- Backend: Added last_activity_at to GET /api/sessions by querying tmux list-sessions -F '#{session_activity}'
Updated sessions.py with tuple return from enumerate_sessions and activity cache
Updated main.py poll loop to populate both session endpoints with activity data
- Frontend: sessionPriority() now returns 'active' (< 5 min) in addition to 'bell'/'idle'
Added .session-tile--active (accent left border) and .session-tile--stale (dimmed styling) CSS classes
Updated session-tile.js and sidebar-item.js to apply priority-based classes
- Fixed P4 hover fade bug: removed opacity:0 rule on .session-tile:hover .tile-meta
P0.2: Readable Tile Previews
- Wired --preview-font-size CSS variable (was set by JS but never consumed by CSS)
Changed default from 11px to 12px with line-height 1.2 (was 1.0)
- Reduced preview line count from 20 to 12 lines for better readability at larger size
P0.3: Search/Filter Bar
- New <search-filter> Lit component with live input, match count, and clear button
Replaces empty filter-bar div in index.html with functional search component
Integrated into renderGrid() for live filtering and bindStaticEventListeners() for re-render on input
P0.4: Default Session / Quick-Resume
- Updated restoreState() to fall back to default_session setting when no active_session in server state
Added existence check before opening to prevent errors on non-existent sessions
Files changed: sessions.py, main.py, app.js, style.css, session-tile.js, sidebar-item.js, utils.js, index.html
New files: search-filter.js (Lit component)
Test updates: test_api.py, test_frontend_css.py, test_integration.py, test_sessions.py
All 1306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
3677 lines
129 KiB
Python
3677 lines
129 KiB
Python
"""
|
|
Tests for muxplex/main.py — FastAPI skeleton, lifespan, /health endpoint.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from muxplex.main import app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# autouse fixture — redirect state/PID files, mock startup side-effects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def patch_startup_and_state(tmp_path, monkeypatch):
|
|
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
|
# Redirect state files
|
|
tmp_state_dir = tmp_path / "state"
|
|
tmp_state_path = tmp_state_dir / "state.json"
|
|
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
|
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
|
|
|
# Redirect PID files
|
|
tmp_pid_dir = tmp_path / "ttyd"
|
|
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
|
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
|
|
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
|
async def _mock_kill_orphan():
|
|
return False
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
|
|
|
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
|
async def noop_poll_loop() -> None:
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_federation_cache():
|
|
"""Clear _federation_cache before and after each test.
|
|
|
|
The module-level _federation_cache persists across tests in the same process,
|
|
causing cross-test contamination: a test that populates the cache for remoteId=0
|
|
causes a later test (also using remoteId=0) to get stale cached data instead of
|
|
the expected unreachable status.
|
|
"""
|
|
import muxplex.main as main_mod
|
|
|
|
main_mod._federation_cache.clear()
|
|
yield
|
|
main_mod._federation_cache.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client fixture — TestClient with lifespan enabled
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
"""Return a TestClient that triggers the app lifespan on entry/exit.
|
|
|
|
Sets a valid session cookie so existing tests bypass the AuthMiddleware
|
|
(TestClient uses host='testclient', which is not a localhost address).
|
|
"""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
with TestClient(app) as c:
|
|
from muxplex.auth import create_session_cookie
|
|
from muxplex.main import _auth_secret, _auth_ttl
|
|
|
|
cookie = create_session_cookie(_auth_secret, _auth_ttl)
|
|
c.cookies.set("muxplex_session", cookie)
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_health_returns_200(client):
|
|
"""GET /health must return HTTP 200."""
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_health_returns_ok_status(client):
|
|
"""GET /health must return JSON body {status: 'ok'}."""
|
|
response = client.get("/health")
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_state_returns_full_state(client):
|
|
"""GET /api/state must return a dict with all 4 top-level keys."""
|
|
response = client.get("/api/state")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "active_session" in data
|
|
assert "session_order" in data
|
|
assert "sessions" in data
|
|
assert "devices" in data
|
|
|
|
|
|
def test_get_state_active_session_is_none_initially(client):
|
|
"""GET /api/state active_session must be None on a fresh state."""
|
|
response = client.get("/api/state")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_session"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /api/state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_patch_state_updates_session_order(client):
|
|
"""PATCH /api/state updates session_order and persists the change."""
|
|
from muxplex.state import load_state, save_state
|
|
|
|
# Write initial state with a known session order
|
|
initial_state = {
|
|
"active_session": None,
|
|
"session_order": ["alpha", "beta"],
|
|
"sessions": {},
|
|
"devices": {},
|
|
}
|
|
save_state(initial_state)
|
|
|
|
# Patch with reversed order
|
|
response = client.patch("/api/state", json={"session_order": ["beta", "alpha"]})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["session_order"] == ["beta", "alpha"]
|
|
|
|
# Verify the update was persisted to disk
|
|
persisted = load_state()
|
|
assert persisted["session_order"] == ["beta", "alpha"]
|
|
|
|
|
|
def test_patch_state_rejects_non_list_session_order(client):
|
|
"""PATCH /api/state rejects non-list session_order with HTTP 422."""
|
|
response = client.patch("/api/state", json={"session_order": "not-a-list"})
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_patch_state_updates_active_remote_id(client):
|
|
"""PATCH /api/state with active_remote_id persists it in state."""
|
|
response = client.patch(
|
|
"/api/state",
|
|
json={"active_session": "remote-sess", "active_remote_id": "fed-abc123"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_remote_id"] == "fed-abc123"
|
|
assert data["active_session"] == "remote-sess"
|
|
|
|
|
|
def test_patch_state_clears_active_remote_id(client):
|
|
"""PATCH /api/state with active_remote_id: null clears it in state."""
|
|
from muxplex.state import save_state
|
|
|
|
# Set up initial state with active_remote_id
|
|
initial = {
|
|
"active_session": "remote-sess",
|
|
"active_remote_id": "fed-abc123",
|
|
"session_order": [],
|
|
"sessions": {},
|
|
"devices": {},
|
|
}
|
|
save_state(initial)
|
|
|
|
response = client.patch(
|
|
"/api/state",
|
|
json={"active_session": None, "active_remote_id": None},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_remote_id"] is None
|
|
assert data["active_session"] is None
|
|
|
|
|
|
def test_patch_state_without_session_order_updates_active_remote_id_only(client):
|
|
"""PATCH /api/state without session_order only updates active_remote_id."""
|
|
from muxplex.state import save_state
|
|
|
|
initial = {
|
|
"active_session": None,
|
|
"active_remote_id": None,
|
|
"session_order": ["alpha", "beta"],
|
|
"sessions": {},
|
|
"devices": {},
|
|
}
|
|
save_state(initial)
|
|
|
|
response = client.patch(
|
|
"/api/state",
|
|
json={"active_remote_id": "fed-xyz"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_remote_id"] == "fed-xyz"
|
|
# session_order should be unchanged
|
|
assert data["session_order"] == ["alpha", "beta"]
|
|
|
|
|
|
def test_patch_state_ignores_unknown_fields(client):
|
|
"""PATCH /api/state ignores unknown fields in the request body."""
|
|
response = client.patch(
|
|
"/api/state",
|
|
json={"session_order": ["a", "b"], "unknown_field": "should_be_ignored"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "unknown_field" not in data
|
|
assert data["session_order"] == ["a", "b"]
|
|
|
|
|
|
def test_patch_state_sets_active_view(client):
|
|
"""PATCH /api/state with active_view persists the value.
|
|
|
|
Verifies response contains active_view and subsequent GET returns the value.
|
|
"""
|
|
from muxplex.state import load_state
|
|
|
|
# PATCH with a specific active_view value
|
|
response = client.patch("/api/state", json={"active_view": "my-view"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_view"] == "my-view"
|
|
|
|
# Verify the value persists via GET
|
|
get_response = client.get("/api/state")
|
|
assert get_response.status_code == 200
|
|
get_data = get_response.json()
|
|
assert get_data["active_view"] == "my-view"
|
|
|
|
# Also verify it was persisted to disk
|
|
persisted = load_state()
|
|
assert persisted["active_view"] == "my-view"
|
|
|
|
|
|
def test_patch_state_active_view_defaults_to_all(client):
|
|
"""GET /api/state returns active_view='all' by default."""
|
|
response = client.get("/api/state")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "active_view" in data
|
|
assert data["active_view"] == "all"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/sessions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_sessions_returns_list(client, monkeypatch):
|
|
"""GET /api/sessions must return a JSON list."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"alpha": "some text"})
|
|
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
items = response.json()
|
|
assert isinstance(items, list)
|
|
assert items[0]["name"] == "alpha"
|
|
|
|
|
|
def test_get_sessions_each_item_has_required_fields(client, monkeypatch):
|
|
"""Each item in GET /api/sessions must have name, snapshot, and bell fields."""
|
|
from muxplex.state import save_state
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["beta"])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"beta": "output"})
|
|
save_state(
|
|
{
|
|
"active_session": None,
|
|
"session_order": ["beta"],
|
|
"sessions": {
|
|
"beta": {
|
|
"bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0}
|
|
}
|
|
},
|
|
"devices": {},
|
|
}
|
|
)
|
|
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
items = response.json()
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert "name" in item
|
|
assert "snapshot" in item
|
|
assert "bell" in item
|
|
|
|
|
|
def test_get_sessions_includes_snapshot_text(client, monkeypatch):
|
|
"""GET /api/sessions snapshot field must contain the cached capture-pane text."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["gamma"])
|
|
monkeypatch.setattr(
|
|
"muxplex.main.get_snapshots",
|
|
lambda: {"gamma": "hello from tmux pane"},
|
|
)
|
|
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
items = response.json()
|
|
assert len(items) == 1
|
|
assert items[0]["name"] == "gamma"
|
|
assert items[0]["snapshot"] == "hello from tmux pane"
|
|
|
|
|
|
def test_get_sessions_includes_bell_state(client, monkeypatch):
|
|
"""GET /api/sessions bell field must include unseen_count from persistent state."""
|
|
from muxplex.state import save_state
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["delta"])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"delta": "pane text"})
|
|
save_state(
|
|
{
|
|
"active_session": None,
|
|
"session_order": ["delta"],
|
|
"sessions": {
|
|
"delta": {
|
|
"bell": {
|
|
"last_fired_at": 1234567890.0,
|
|
"seen_at": None,
|
|
"unseen_count": 3,
|
|
}
|
|
}
|
|
},
|
|
"devices": {},
|
|
}
|
|
)
|
|
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
items = response.json()
|
|
assert len(items) == 1
|
|
assert items[0]["bell"]["unseen_count"] == 3
|
|
|
|
|
|
def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
|
|
"""GET /api/sessions must return an empty list when there are no sessions."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/sessions/{name}/connect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_connect_session_returns_200(client, monkeypatch):
|
|
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
|
|
|
async def mock_kill():
|
|
return True
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
|
|
|
async def mock_spawn(name):
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
|
|
|
response = client.post("/api/sessions/alpha/connect")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_session"] == "alpha"
|
|
assert data["ttyd_port"] == 7682
|
|
|
|
|
|
def test_connect_session_sets_active_session(client, monkeypatch):
|
|
"""POST /api/sessions/{name}/connect persists active_session to state."""
|
|
from muxplex.state import load_state
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
|
|
|
async def mock_kill():
|
|
return True
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
|
|
|
async def mock_spawn(name):
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
|
|
|
client.post("/api/sessions/alpha/connect")
|
|
|
|
state = load_state()
|
|
assert state["active_session"] == "alpha"
|
|
|
|
|
|
def test_connect_session_kills_existing_ttyd(client, monkeypatch):
|
|
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
|
|
|
call_order = []
|
|
|
|
async def mock_kill():
|
|
call_order.append("kill")
|
|
return True
|
|
|
|
async def mock_spawn(name):
|
|
call_order.append(("spawn", name))
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
|
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
|
|
|
response = client.post("/api/sessions/alpha/connect")
|
|
assert response.status_code == 200
|
|
assert call_order == ["kill", ("spawn", "alpha")]
|
|
|
|
|
|
def test_connect_nonexistent_session_returns_404(client, monkeypatch):
|
|
"""POST /api/sessions/{name}/connect returns 404 when session is not in list."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
|
|
|
|
response = client.post("/api/sessions/gamma/connect")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /api/sessions/current
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
|
|
"""DELETE /api/sessions/current kills ttyd and clears active_session."""
|
|
from muxplex.state import load_state, save_state
|
|
|
|
# Set up initial state with active session
|
|
save_state(
|
|
{
|
|
"active_session": "alpha",
|
|
"session_order": ["alpha"],
|
|
"sessions": {},
|
|
"devices": {},
|
|
}
|
|
)
|
|
|
|
kill_called = []
|
|
|
|
async def mock_kill():
|
|
kill_called.append(True)
|
|
return True
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
|
|
|
response = client.delete("/api/sessions/current")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["active_session"] is None
|
|
assert len(kill_called) == 1
|
|
|
|
# Verify state was persisted
|
|
state = load_state()
|
|
assert state["active_session"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/heartbeat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_heartbeat_returns_200(client):
|
|
"""POST /api/heartbeat must return HTTP 200 with device_id and status 'ok'."""
|
|
response = client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"device_id": "dev-abc",
|
|
"label": "My Laptop",
|
|
"viewing_session": None,
|
|
"view_mode": "grid",
|
|
"last_interaction_at": 1234567890.0,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["device_id"] == "dev-abc"
|
|
assert data["status"] == "ok"
|
|
|
|
|
|
def test_heartbeat_registers_new_device(client):
|
|
"""POST /api/heartbeat registers a new device visible in GET /api/state."""
|
|
client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"device_id": "dev-new",
|
|
"label": "Test Device",
|
|
"viewing_session": "mysession",
|
|
"view_mode": "fullscreen",
|
|
"last_interaction_at": 1111111111.0,
|
|
},
|
|
)
|
|
|
|
state_response = client.get("/api/state")
|
|
assert state_response.status_code == 200
|
|
state = state_response.json()
|
|
assert "dev-new" in state["devices"]
|
|
device = state["devices"]["dev-new"]
|
|
assert device["label"] == "Test Device"
|
|
assert device["viewing_session"] == "mysession"
|
|
assert device["view_mode"] == "fullscreen"
|
|
assert device["last_interaction_at"] == 1111111111.0
|
|
|
|
|
|
def test_heartbeat_updates_existing_device(client):
|
|
"""Two POST /api/heartbeat calls: second values are persisted."""
|
|
# First heartbeat
|
|
client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"device_id": "dev-update",
|
|
"label": "Old Label",
|
|
"viewing_session": None,
|
|
"view_mode": "grid",
|
|
"last_interaction_at": 1000000000.0,
|
|
},
|
|
)
|
|
# Second heartbeat with updated values
|
|
client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"device_id": "dev-update",
|
|
"label": "New Label",
|
|
"viewing_session": "session-x",
|
|
"view_mode": "fullscreen",
|
|
"last_interaction_at": 2000000000.0,
|
|
},
|
|
)
|
|
|
|
state_response = client.get("/api/state")
|
|
state = state_response.json()
|
|
device = state["devices"]["dev-update"]
|
|
assert device["label"] == "New Label"
|
|
assert device["viewing_session"] == "session-x"
|
|
assert device["view_mode"] == "fullscreen"
|
|
assert device["last_interaction_at"] == 2000000000.0
|
|
|
|
|
|
def test_heartbeat_missing_device_id_returns_422(client):
|
|
"""POST /api/heartbeat without device_id must return HTTP 422."""
|
|
response = client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"label": "My Laptop",
|
|
"viewing_session": None,
|
|
"view_mode": "grid",
|
|
"last_interaction_at": 1234567890.0,
|
|
},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_heartbeat_invalid_view_mode_returns_422(client):
|
|
"""POST /api/heartbeat with invalid view_mode must return HTTP 422."""
|
|
response = client.post(
|
|
"/api/heartbeat",
|
|
json={
|
|
"device_id": "dev-abc",
|
|
"label": "My Laptop",
|
|
"viewing_session": None,
|
|
"view_mode": "invalid_mode",
|
|
"last_interaction_at": 1234567890.0,
|
|
},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/sessions/{name}/bell
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_receive_bell_returns_ok_and_session_name(client):
|
|
"""POST /api/sessions/{name}/bell returns {"ok": True, "session": name}."""
|
|
response = client.post("/api/sessions/web-tmux/bell")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is True
|
|
assert data["session"] == "web-tmux"
|
|
|
|
|
|
def test_receive_bell_increments_unseen_count(client):
|
|
"""POST /api/sessions/{name}/bell increments unseen_count in state."""
|
|
from muxplex.state import load_state
|
|
|
|
client.post("/api/sessions/my-session/bell")
|
|
|
|
state = load_state()
|
|
bell = state["sessions"]["my-session"]["bell"]
|
|
assert bell["unseen_count"] == 1
|
|
|
|
|
|
def test_receive_bell_creates_session_entry_if_absent(client):
|
|
"""POST /api/sessions/{name}/bell creates session/bell entries if missing."""
|
|
from muxplex.state import load_state
|
|
|
|
# Ensure session does not exist in state yet
|
|
client.post("/api/sessions/brand-new/bell")
|
|
|
|
state = load_state()
|
|
assert "brand-new" in state["sessions"]
|
|
assert "bell" in state["sessions"]["brand-new"]
|
|
|
|
|
|
def test_receive_bell_multiple_calls_accumulate(client):
|
|
"""Three POST calls to the bell endpoint accumulate unseen_count to 3."""
|
|
from muxplex.state import load_state
|
|
|
|
for _ in range(3):
|
|
client.post("/api/sessions/multi-session/bell")
|
|
|
|
state = load_state()
|
|
bell = state["sessions"]["multi-session"]["bell"]
|
|
assert bell["unseen_count"] == 3
|
|
|
|
|
|
def test_receive_bell_sets_last_fired_at(client):
|
|
"""POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp."""
|
|
import time
|
|
|
|
from muxplex.state import load_state
|
|
|
|
before = time.time()
|
|
client.post("/api/sessions/timed-session/bell")
|
|
after = time.time()
|
|
|
|
state = load_state()
|
|
bell = state["sessions"]["timed-session"]["bell"]
|
|
assert bell["last_fired_at"] is not None
|
|
assert before <= bell["last_fired_at"] <= after
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/sessions/{name}/bell/clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bell_clear_returns_ok(client):
|
|
"""POST /api/sessions/{name}/bell/clear returns {\"ok\": True, \"session\": name}."""
|
|
response = client.post("/api/sessions/web-tmux/bell/clear")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is True
|
|
assert data["session"] == "web-tmux"
|
|
|
|
|
|
def test_bell_clear_resets_unseen_count(client):
|
|
"""After clearing, unseen_count is 0."""
|
|
from muxplex.state import load_state
|
|
|
|
# First fire some bells to set unseen_count > 0
|
|
for _ in range(3):
|
|
client.post("/api/sessions/clear-test/bell")
|
|
|
|
state = load_state()
|
|
assert state["sessions"]["clear-test"]["bell"]["unseen_count"] == 3
|
|
|
|
# Now clear
|
|
client.post("/api/sessions/clear-test/bell/clear")
|
|
|
|
state = load_state()
|
|
assert state["sessions"]["clear-test"]["bell"]["unseen_count"] == 0
|
|
|
|
|
|
def test_bell_clear_sets_seen_at(client):
|
|
"""After clearing, seen_at is set to a recent timestamp."""
|
|
import time
|
|
|
|
from muxplex.state import load_state
|
|
|
|
# Fire a bell first to create the bell state
|
|
client.post("/api/sessions/seen-test/bell")
|
|
|
|
before = time.time()
|
|
client.post("/api/sessions/seen-test/bell/clear")
|
|
after = time.time()
|
|
|
|
state = load_state()
|
|
bell = state["sessions"]["seen-test"]["bell"]
|
|
assert bell["seen_at"] is not None
|
|
assert before <= bell["seen_at"] <= after
|
|
|
|
|
|
def test_bell_clear_noop_when_no_session(client):
|
|
"""No-op when session has no bell state (still returns 200 + ok)."""
|
|
response = client.post("/api/sessions/nonexistent-session/bell/clear")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is True
|
|
assert data["session"] == "nonexistent-session"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/internal/setup-hooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_setup_hooks_returns_ok(client, monkeypatch):
|
|
"""POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
monkeypatch.setattr("muxplex.main.run_tmux", AsyncMock(return_value=""))
|
|
|
|
response = client.post("/api/internal/setup-hooks")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is True
|
|
|
|
|
|
def test_setup_hooks_returns_ok_false_on_error(client, monkeypatch):
|
|
"""POST /api/internal/setup-hooks returns {"ok": False} when tmux raises."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
monkeypatch.setattr(
|
|
"muxplex.main.run_tmux",
|
|
AsyncMock(side_effect=RuntimeError("tmux not found")),
|
|
)
|
|
|
|
response = client.post("/api/internal/setup-hooks")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is False
|
|
assert "error" in data
|
|
|
|
|
|
def test_setup_hooks_curl_discards_response_body(client, monkeypatch):
|
|
"""POST /api/internal/setup-hooks passes curl with -o /dev/null to discard response."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
mock_run_tmux = AsyncMock(return_value="")
|
|
monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
|
|
|
|
response = client.post("/api/internal/setup-hooks")
|
|
assert response.status_code == 200
|
|
|
|
# Verify run_tmux was called with the correct hook command
|
|
assert mock_run_tmux.called
|
|
call_args = mock_run_tmux.call_args
|
|
# Positional args are: "set-hook", "-g", "alert-bell", <hook_command>
|
|
hook_command = call_args[0][3] if len(call_args[0]) > 3 else None
|
|
assert hook_command is not None
|
|
# Should have -sfo /dev/null, not just -sf
|
|
assert "-sfo /dev/null" in hook_command
|
|
|
|
|
|
def test_lifespan_alert_bell_hook_discards_response(monkeypatch):
|
|
"""Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response."""
|
|
from unittest.mock import AsyncMock
|
|
from fastapi.testclient import TestClient
|
|
from muxplex.main import app
|
|
|
|
# Mock run_tmux to capture the hook command
|
|
mock_run_tmux = AsyncMock(return_value="")
|
|
monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
|
|
|
|
# Trigger lifespan by entering/exiting TestClient context
|
|
with TestClient(app):
|
|
pass
|
|
|
|
# Verify run_tmux was called during lifespan startup
|
|
assert mock_run_tmux.called
|
|
# Find the call that sets the alert-bell hook
|
|
hook_calls = [
|
|
call
|
|
for call in mock_run_tmux.call_args_list
|
|
if len(call[0]) > 3 and call[0][2] == "alert-bell"
|
|
]
|
|
assert len(hook_calls) > 0, "alert-bell hook was not set during lifespan"
|
|
|
|
# Check the first hook call
|
|
hook_command = hook_calls[0][0][3]
|
|
assert "-sfo /dev/null" in hook_command
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static file serving tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_root_serves_html(client):
|
|
"""GET / must return 200 with text/html content-type."""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
assert "text/html" in response.headers["content-type"]
|
|
|
|
|
|
def test_style_css_served(client):
|
|
"""GET /style.css must return 200 with text/css content-type."""
|
|
response = client.get("/style.css")
|
|
assert response.status_code == 200
|
|
assert "text/css" in response.headers["content-type"]
|
|
|
|
|
|
def test_api_routes_not_shadowed(client):
|
|
"""GET /api/sessions must still return 200 with JSON list (not shadowed by StaticFiles)."""
|
|
response = client.get("/api/sessions")
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
|
|
def test_terminal_ws_route_exists():
|
|
"""The app must have a WebSocket route registered at /terminal/ws."""
|
|
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
|
|
from muxplex.main import app
|
|
|
|
ws_routes = [
|
|
r
|
|
for r in app.routes
|
|
if isinstance(r, (APIRoute, APIWebSocketRoute)) and r.path == "/terminal/ws"
|
|
]
|
|
assert len(ws_routes) == 1, "Expected exactly one /terminal/ws route"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth middleware integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_localhost_without_auth_gets_redirected(monkeypatch):
|
|
"""A non-localhost request without credentials is redirected to /login."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
from muxplex.main import app
|
|
|
|
# Ensure auth is active — set a known password via env
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw-for-api")
|
|
|
|
with TestClient(app, base_url="http://192.168.1.1") as c:
|
|
response = c.get("/health", follow_redirects=False)
|
|
# Should be redirected to /login or get 307/401
|
|
assert response.status_code in (307, 401)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Login stub and auth mode endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_login_returns_200_html(client):
|
|
"""GET /login returns 200 with HTML content."""
|
|
response = client.get("/login")
|
|
assert response.status_code == 200
|
|
assert "text/html" in response.headers["content-type"]
|
|
assert "<form" in response.text
|
|
|
|
|
|
def test_get_auth_mode_returns_json(client):
|
|
"""GET /auth/mode returns JSON with mode field."""
|
|
response = client.get("/auth/mode")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "mode" in data
|
|
assert data["mode"] in ("pam", "password")
|
|
|
|
|
|
def test_get_login_injects_muxplex_auth(client):
|
|
"""GET /login returns 200 with MUXPLEX_AUTH injected into HTML."""
|
|
response = client.get("/login")
|
|
assert response.status_code == 200
|
|
assert "MUXPLEX_AUTH" in response.text
|
|
assert '"mode"' in response.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /login
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_post_login_correct_password_redirects_to_root(monkeypatch):
|
|
"""POST /login with correct password: 303 redirect to / with muxplex_session cookie."""
|
|
import muxplex.main as main_module
|
|
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
monkeypatch.setattr(main_module, "_auth_mode", "password")
|
|
monkeypatch.setattr(main_module, "_auth_password", "test-password")
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.post(
|
|
"/login", data={"username": "user", "password": "test-password"}
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/"
|
|
assert "muxplex_session" in response.cookies
|
|
|
|
|
|
def test_post_login_wrong_password_redirects_to_login_error(monkeypatch):
|
|
"""POST /login with wrong password: 303 redirect to /login?error=1."""
|
|
import muxplex.main as main_module
|
|
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
monkeypatch.setattr(main_module, "_auth_mode", "password")
|
|
monkeypatch.setattr(main_module, "_auth_password", "test-password")
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.post(
|
|
"/login", data={"username": "user", "password": "wrong-password"}
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert "error=1" in response.headers["location"]
|
|
|
|
|
|
def test_post_login_pam_mode_correct_creds(monkeypatch):
|
|
"""POST /login in PAM mode with correct creds: 303 to / with muxplex_session cookie."""
|
|
import muxplex.main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_auth_mode", "pam")
|
|
monkeypatch.setattr("muxplex.main.authenticate_pam", lambda u, p: True)
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.post("/login", data={"username": "user", "password": "correct"})
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/"
|
|
assert "muxplex_session" in response.cookies
|
|
|
|
|
|
def test_post_login_pam_mode_wrong_creds(monkeypatch):
|
|
"""POST /login in PAM mode with wrong creds: 303 redirect to /login?error=1."""
|
|
import muxplex.main as main_module
|
|
|
|
monkeypatch.setattr(main_module, "_auth_mode", "pam")
|
|
monkeypatch.setattr("muxplex.main.authenticate_pam", lambda u, p: False)
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.post("/login", data={"username": "user", "password": "wrong"})
|
|
|
|
assert response.status_code == 303
|
|
assert "error=1" in response.headers["location"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /auth/logout
|
|
#
|
|
# Note: these tests intentionally bypass the shared `client` fixture.
|
|
# The `client` fixture pre-injects a valid muxplex_session cookie; these
|
|
# tests verify that logout works correctly even for an unauthenticated
|
|
# (or expired-session) request, so they create their own TestClient.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_logout_redirects_to_login(monkeypatch):
|
|
"""GET /auth/logout returns 303 redirect to /login."""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.get("/auth/logout")
|
|
|
|
assert response.status_code == 303
|
|
assert "/login" in response.headers["location"]
|
|
|
|
|
|
def test_logout_clears_session_cookie(monkeypatch):
|
|
"""GET /auth/logout clears muxplex_session cookie (Set-Cookie with max-age=0)."""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
response = c.get("/auth/logout")
|
|
|
|
assert response.status_code == 303
|
|
set_cookie = response.headers.get("set-cookie", "")
|
|
assert "muxplex_session" in set_cookie
|
|
assert "max-age=0" in set_cookie.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket auth tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _wrap_with_client_host(wrapped_app, host: str):
|
|
"""Return an ASGI wrapper that forces websocket scope client to `host`.
|
|
|
|
This lets tests simulate a WebSocket connection appearing to originate
|
|
from a specific IP without touching Starlette internals.
|
|
"""
|
|
|
|
async def _middleware(scope, receive, send):
|
|
if scope.get("type") == "websocket":
|
|
scope = {**scope, "client": (host, 50000)}
|
|
await wrapped_app(scope, receive, send)
|
|
|
|
return _middleware
|
|
|
|
|
|
def test_ws_localhost_no_cookie_bypasses_auth():
|
|
"""WebSocket from 127.0.0.1 is accepted even without a session cookie."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
# Force scope to look like localhost so auth check is bypassed
|
|
localhost_app = _wrap_with_client_host(app, "127.0.0.1")
|
|
|
|
with TestClient(localhost_app) as c:
|
|
try:
|
|
with c.websocket_connect("/terminal/ws") as _:
|
|
pass # connection was accepted — auth bypassed for localhost
|
|
except WebSocketDisconnect as e:
|
|
# The websocket was accepted (auth bypassed); ttyd is not running so
|
|
# the proxy fails and closes with a non-4001 code.
|
|
assert e.code != 4001, (
|
|
f"Localhost WebSocket should not be rejected; got close code {e.code}"
|
|
)
|
|
|
|
|
|
def test_ws_valid_cookie_non_localhost_not_rejected_4001():
|
|
"""WebSocket from non-localhost with a valid cookie is not rejected with 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
from muxplex.auth import create_session_cookie
|
|
from muxplex.main import _auth_secret, _auth_ttl
|
|
|
|
cookie = create_session_cookie(_auth_secret, _auth_ttl)
|
|
|
|
# TestClient default host is "testclient" — treated as non-localhost
|
|
with TestClient(app) as c:
|
|
c.cookies["muxplex_session"] = cookie
|
|
try:
|
|
with c.websocket_connect("/terminal/ws") as _:
|
|
pass # connection was accepted — auth passed
|
|
except WebSocketDisconnect as e:
|
|
# Auth passed; ttyd not running → proxy fails → close with code != 4001
|
|
assert e.code != 4001, (
|
|
f"Valid-cookie WebSocket should not be rejected; got close code {e.code}"
|
|
)
|
|
|
|
|
|
def test_ws_no_cookie_non_localhost_rejected_4001():
|
|
"""WebSocket from non-localhost without a cookie is closed with code 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
# TestClient default host "testclient" is treated as non-localhost
|
|
with TestClient(app) as c:
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
with c.websocket_connect("/terminal/ws") as _:
|
|
pass
|
|
assert exc_info.value.code == 4001
|
|
|
|
|
|
def test_ws_invalid_cookie_non_localhost_rejected_4001():
|
|
"""WebSocket from non-localhost with a tampered cookie is closed with code 4001."""
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
with TestClient(app) as c:
|
|
c.cookies["muxplex_session"] = "tampered.invalid.cookie.value"
|
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
|
with c.websocket_connect("/terminal/ws") as _:
|
|
pass
|
|
assert exc_info.value.code == 4001
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_settings_returns_defaults(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings returns 200 with default settings when no file exists."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
|
|
response = client.get("/api/settings")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["sort_order"] == "manual"
|
|
assert data["new_session_template"] == "tmux new-session -d -s {name}"
|
|
|
|
|
|
def test_get_settings_returns_saved_values(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings returns saved values when settings.json exists."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Pre-write a settings.json with a custom sort_order
|
|
settings_path.write_text(json.dumps({"sort_order": "alphabetical"}))
|
|
|
|
response = client.get("/api/settings")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["sort_order"] == "alphabetical"
|
|
|
|
|
|
def test_get_settings_redacts_federation_key(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings must NOT return the federation_key value — it must be empty string."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Save a settings file that includes a non-empty federation_key
|
|
settings_path.write_text(json.dumps({"federation_key": "secret-should-not-appear"}))
|
|
|
|
response = client.get("/api/settings")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# The federation_key key may be present but its value must be empty
|
|
assert data.get("federation_key") == "", (
|
|
f"federation_key must be redacted (empty string), got: {data.get('federation_key')!r}"
|
|
)
|
|
# The secret must not appear anywhere in the response
|
|
assert "secret-should-not-appear" not in str(data), (
|
|
"federation_key secret value must not appear anywhere in the response"
|
|
)
|
|
|
|
|
|
def test_get_settings_redacts_remote_instance_keys(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings must redact the 'key' field from each item in remote_instances."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Save settings with remote_instances containing secret key fields
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://remote-a:8088",
|
|
"key": "remote-secret-key-a",
|
|
"name": "Remote A",
|
|
"id": "remote-a",
|
|
},
|
|
{
|
|
"url": "http://remote-b:8088",
|
|
"key": "remote-secret-key-b",
|
|
"name": "Remote B",
|
|
"id": "remote-b",
|
|
},
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
response = client.get("/api/settings")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
remote_instances = data.get("remote_instances", [])
|
|
assert len(remote_instances) == 2, (
|
|
f"Expected 2 remote_instances, got {len(remote_instances)}"
|
|
)
|
|
|
|
for i, inst in enumerate(remote_instances):
|
|
assert inst.get("key") == "", (
|
|
f"remote_instances[{i}]['key'] must be redacted (empty string), "
|
|
f"got: {inst.get('key')!r}"
|
|
)
|
|
|
|
# The secrets must not appear anywhere in the response
|
|
assert "remote-secret-key-a" not in str(data), (
|
|
"remote-secret-key-a must not appear anywhere in the response"
|
|
)
|
|
assert "remote-secret-key-b" not in str(data), (
|
|
"remote-secret-key-b must not appear anywhere in the response"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /api/settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_patch_settings_updates_field(client, tmp_path, monkeypatch):
|
|
"""PATCH /api/settings with {sort_order: 'alphabetical'} returns 200 with updated sort_order and unchanged default_session."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
|
|
response = client.patch("/api/settings", json={"sort_order": "alphabetical"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["sort_order"] == "alphabetical"
|
|
assert data["default_session"] is None
|
|
|
|
|
|
def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch):
|
|
"""PATCH /api/settings with {unknown_key: 'value'} returns 200 without unknown_key."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
|
|
response = client.patch("/api/settings", json={"unknown_key": "value"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "unknown_key" not in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/instance-info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_instance_info_returns_200(client):
|
|
"""GET /api/instance-info returns 200 with name and version keys."""
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
assert "name" in response.json()
|
|
|
|
|
|
def test_instance_info_returns_name_and_version(client, tmp_path, monkeypatch):
|
|
"""GET /api/instance-info returns name='test-host' and a non-empty version string when hostname is mocked."""
|
|
import socket
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
monkeypatch.setattr(socket, "gethostname", lambda: "test-host")
|
|
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "test-host"
|
|
assert "version" in data and isinstance(data["version"], str) and data["version"]
|
|
|
|
|
|
def test_instance_info_uses_explicit_device_name(client, tmp_path, monkeypatch):
|
|
"""GET /api/instance-info uses explicit device_name from settings when set."""
|
|
import json
|
|
|
|
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({"device_name": "My Workstation"}))
|
|
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "My Workstation"
|
|
assert "version" in data and isinstance(data["version"], str) and data["version"]
|
|
|
|
|
|
def test_instance_info_no_auth_required(tmp_path, monkeypatch):
|
|
"""GET /api/instance-info returns 200 even without an auth cookie."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
|
|
with TestClient(app) as c:
|
|
# No auth cookie set — endpoint must be accessible without one
|
|
response = c.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "name" in data
|
|
assert "version" in data
|
|
|
|
|
|
def test_instance_info_includes_federation_enabled(client, tmp_path, monkeypatch):
|
|
"""GET /api/instance-info includes federation_enabled=False when no key file exists."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Redirect settings path so defaults are used
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
# Redirect federation key path to a nonexistent file
|
|
monkeypatch.setattr(
|
|
settings_mod, "FEDERATION_KEY_PATH", tmp_path / "nonexistent_federation_key"
|
|
)
|
|
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "federation_enabled" in data, (
|
|
f"Response must include 'federation_enabled' key, got: {data}"
|
|
)
|
|
assert data["federation_enabled"] is False, (
|
|
f"federation_enabled must be False when no key file exists, got: {data['federation_enabled']}"
|
|
)
|
|
|
|
|
|
def test_instance_info_federation_enabled_true_when_key_exists(
|
|
client, tmp_path, monkeypatch
|
|
):
|
|
"""GET /api/instance-info returns federation_enabled=True when a key file is present."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Write a federation key file to tmp_path
|
|
key_file = tmp_path / "federation_key"
|
|
key_file.write_text("test-federation-secret")
|
|
|
|
# Redirect settings path so defaults are used
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
# Redirect federation key path to the file we just wrote
|
|
monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_file)
|
|
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "federation_enabled" in data, (
|
|
f"Response must include 'federation_enabled' key, got: {data}"
|
|
)
|
|
assert data["federation_enabled"] is True, (
|
|
f"federation_enabled must be True when a key file exists, got: {data['federation_enabled']}"
|
|
)
|
|
|
|
|
|
def test_instance_info_includes_device_id(client, tmp_path, monkeypatch):
|
|
"""GET /api/instance-info includes device_id as a non-empty string."""
|
|
import muxplex.identity as identity_mod
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", tmp_path / "identity.json")
|
|
|
|
response = client.get("/api/instance-info")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "device_id" in data, f"Response must include 'device_id' key, got: {data}"
|
|
assert isinstance(data["device_id"], str), (
|
|
f"device_id must be a string, got: {type(data['device_id'])}"
|
|
)
|
|
assert data["device_id"] != "", (
|
|
f"device_id must be a non-empty string, got: {data['device_id']!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/sessions (create new session)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_session_returns_200_with_name(client, monkeypatch):
|
|
"""POST /api/sessions with valid name returns 200 with {name: name}."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
|
|
mock_proc.returncode = 0
|
|
|
|
monkeypatch.setattr(
|
|
"muxplex.main.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
)
|
|
|
|
response = client.post("/api/sessions", json={"name": "my-project"})
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["name"] == "my-project"
|
|
|
|
|
|
def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypatch):
|
|
"""POST /api/sessions substitutes {name} with actual name in new_session_template."""
|
|
import json
|
|
from unittest.mock import AsyncMock, 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({"new_session_template": "echo {name}"}))
|
|
|
|
shell_calls = []
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
|
|
mock_proc.returncode = 0
|
|
|
|
async def mock_create_subprocess(cmd, **kwargs):
|
|
shell_calls.append(cmd)
|
|
return mock_proc
|
|
|
|
monkeypatch.setattr(
|
|
"muxplex.main.asyncio.create_subprocess_shell", mock_create_subprocess
|
|
)
|
|
|
|
response = client.post("/api/sessions", json={"name": "my-project"})
|
|
assert response.status_code == 200
|
|
assert len(shell_calls) == 1
|
|
assert shell_calls[0] == "echo my-project"
|
|
|
|
|
|
def test_create_session_rejects_empty_name(client):
|
|
"""POST /api/sessions with empty name returns 422."""
|
|
response = client.post("/api/sessions", json={"name": ""})
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_create_session_rejects_missing_name(client):
|
|
"""POST /api/sessions with missing name returns 422."""
|
|
response = client.post("/api/sessions", json={})
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /api/sessions/{name}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_session_success(client, monkeypatch):
|
|
"""DELETE /api/sessions/{name} returns 200 with {ok: True, name: name} when session exists."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
monkeypatch.setattr(
|
|
"muxplex.main.get_session_list", lambda: ["my-session", "other"]
|
|
)
|
|
monkeypatch.setattr("muxplex.main.run_tmux", AsyncMock(return_value=""))
|
|
|
|
response = client.delete("/api/sessions/my-session")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["ok"] is True
|
|
assert data["name"] == "my-session"
|
|
|
|
|
|
def test_delete_session_calls_kill_session(client, monkeypatch, tmp_path):
|
|
"""DELETE /api/sessions/{name} runs 'tmux kill-session -t {name}' via subprocess (default template)."""
|
|
import muxplex.settings as settings_mod
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Redirect settings to a non-existent path so the default template is used
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
|
|
|
|
captured = []
|
|
|
|
def mock_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
|
client.delete("/api/sessions/my-session")
|
|
|
|
assert len(captured) == 1, "subprocess.run must be called exactly once"
|
|
executed_cmd = captured[0]
|
|
assert "kill-session" in executed_cmd, (
|
|
f"Default command must include 'kill-session', got: {executed_cmd!r}"
|
|
)
|
|
assert "-t" in executed_cmd, (
|
|
f"Default command must include '-t', got: {executed_cmd!r}"
|
|
)
|
|
assert "my-session" in executed_cmd, (
|
|
f"Command must include session name 'my-session', got: {executed_cmd!r}"
|
|
)
|
|
|
|
|
|
def test_delete_session_not_found(client, monkeypatch):
|
|
"""DELETE /api/sessions/{name} returns 404 when session is not in list."""
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
|
|
|
|
response = client.delete("/api/sessions/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue 1: Static assets exempt from auth middleware
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_static_asset_accessible_from_non_localhost_without_auth(monkeypatch):
|
|
"""Static assets (.svg, .css, .js etc.) are served without auth from non-localhost.
|
|
|
|
The login page needs its own CSS/JS/images to render before the user has
|
|
authenticated. The auth middleware must exempt static file extensions.
|
|
"""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw")
|
|
with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c:
|
|
response = c.get("/wordmark-on-dark.svg")
|
|
assert response.status_code == 200, (
|
|
f"Expected 200 for static asset from non-localhost, got {response.status_code}"
|
|
)
|
|
|
|
|
|
def test_css_asset_accessible_from_non_localhost_without_auth(monkeypatch):
|
|
"""CSS files are served without auth from non-localhost."""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw")
|
|
with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c:
|
|
response = c.get("/style.css")
|
|
assert response.status_code == 200, (
|
|
f"Expected 200 for CSS from non-localhost, got {response.status_code}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue 2: Hostname in page title
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_index_page_title_contains_hostname(client):
|
|
"""GET / returns HTML with hostname in page title (e.g. 'myhost — muxplex')."""
|
|
import socket
|
|
|
|
hostname = socket.gethostname().split(".")[0]
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
assert hostname in response.text, (
|
|
f"Expected hostname '{hostname}' in title of index page"
|
|
)
|
|
assert "muxplex" in response.text
|
|
|
|
|
|
def test_login_page_title_contains_hostname(client):
|
|
"""GET /login returns HTML with hostname in page title (e.g. 'Sign in — myhost — muxplex')."""
|
|
import socket
|
|
|
|
hostname = socket.gethostname().split(".")[0]
|
|
response = client.get("/login")
|
|
assert response.status_code == 200
|
|
assert hostname in response.text, (
|
|
f"Expected hostname '{hostname}' in title of login page"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /api/sessions/{name} — custom template (task: customizable delete command)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_session_uses_template_command(client, monkeypatch, tmp_path):
|
|
"""DELETE /api/sessions/{name} must execute the delete_session_template from settings.
|
|
|
|
The template {name} placeholder must be substituted with the session name.
|
|
The command must be run synchronously via subprocess.run (not run_tmux).
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Make the session appear to exist so the 404 guard passes
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["myworkspace"])
|
|
|
|
# Redirect settings to a temp path so we can write a custom template
|
|
import muxplex.settings as settings_mod
|
|
|
|
fake_settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
|
|
|
|
# Write a custom template
|
|
import json
|
|
|
|
fake_settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"delete_session_template": "echo destroy {name}",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Capture subprocess.run calls
|
|
captured_commands = []
|
|
|
|
def mock_run(cmd, **kwargs):
|
|
captured_commands.append(cmd)
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
|
response = client.delete("/api/sessions/myworkspace")
|
|
|
|
assert response.status_code == 200, (
|
|
f"DELETE /api/sessions/myworkspace must return 200, got {response.status_code}"
|
|
)
|
|
data = response.json()
|
|
assert data.get("ok") is True, f"Response must have ok=True, got: {data}"
|
|
assert data.get("name") == "myworkspace", (
|
|
f"Response must have name='myworkspace', got: {data}"
|
|
)
|
|
|
|
# Verify template substitution happened
|
|
assert len(captured_commands) == 1, (
|
|
f"subprocess.run must be called exactly once, called {len(captured_commands)} times"
|
|
)
|
|
executed_cmd = captured_commands[0]
|
|
assert "myworkspace" in executed_cmd, (
|
|
f"Executed command must contain session name 'myworkspace', got: {executed_cmd!r}"
|
|
)
|
|
assert "echo destroy" in executed_cmd, (
|
|
f"Executed command must use the custom template, got: {executed_cmd!r}"
|
|
)
|
|
|
|
|
|
def test_delete_session_default_template_is_tmux_kill(client, monkeypatch, tmp_path):
|
|
"""DELETE /api/sessions/{name} uses 'tmux kill-session -t {name}' when no custom template is set."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["mysession"])
|
|
|
|
# Redirect settings to empty temp file (no settings file = use defaults)
|
|
import muxplex.settings as settings_mod
|
|
|
|
fake_settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path)
|
|
# Don't write any settings — defaults should be used
|
|
|
|
captured_commands = []
|
|
|
|
def mock_run(cmd, **kwargs):
|
|
captured_commands.append(cmd)
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
|
response = client.delete("/api/sessions/mysession")
|
|
|
|
assert response.status_code == 200
|
|
assert len(captured_commands) == 1
|
|
executed_cmd = captured_commands[0]
|
|
# Default template substituted
|
|
assert "mysession" in executed_cmd, (
|
|
f"Default template must substitute session name, got: {executed_cmd!r}"
|
|
)
|
|
assert "kill-session" in executed_cmd, (
|
|
f"Default template must contain 'kill-session', got: {executed_cmd!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Federation Bearer token auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_bearer_auth_accepted(monkeypatch):
|
|
"""Request with valid Bearer token gets 200 on /api/sessions when federation key is set.
|
|
|
|
Patches the federation key on the AuthMiddleware instance (since the key is
|
|
loaded once at module startup) and verifies that a Bearer-authenticated
|
|
request reaches /api/sessions with HTTP 200.
|
|
|
|
Before implementation: fails with ImportError — _federation_key not in main.py.
|
|
After implementation: _federation_key exists, middleware is found and patched,
|
|
Bearer request is accepted.
|
|
"""
|
|
from muxplex.auth import AuthMiddleware
|
|
import muxplex.main as main_module
|
|
|
|
federation_key = "test-federation-key-abc123"
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
|
|
with TestClient(main_module.app) as c:
|
|
# Traverse the compiled middleware stack to find the AuthMiddleware instance
|
|
stack = main_module.app.middleware_stack
|
|
auth_mw = None
|
|
for _ in range(20):
|
|
if isinstance(stack, AuthMiddleware):
|
|
auth_mw = stack
|
|
break
|
|
stack = getattr(stack, "app", None)
|
|
if stack is None:
|
|
break
|
|
|
|
assert auth_mw is not None, "AuthMiddleware not found in middleware stack"
|
|
# Patch the federation key so Bearer token auth is enabled
|
|
auth_mw.federation_key = federation_key
|
|
|
|
# A request with a matching Bearer token must pass auth and get 200
|
|
response = c.get(
|
|
"/api/sessions",
|
|
headers={"Authorization": f"Bearer {federation_key}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# httpx.AsyncClient federation client on app.state (task-7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_client_exists_on_app_state(monkeypatch):
|
|
"""app.state.federation_client is set during lifespan and is not None.
|
|
|
|
Verifies that the lifespan creates an httpx.AsyncClient and attaches it to
|
|
app.state.federation_client before the application begins serving requests.
|
|
"""
|
|
import httpx
|
|
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
|
|
with TestClient(app):
|
|
# Inside the context manager the lifespan has completed startup,
|
|
# so app.state.federation_client must be set.
|
|
assert hasattr(app.state, "federation_client"), (
|
|
"app.state.federation_client must be set during lifespan startup"
|
|
)
|
|
assert app.state.federation_client is not None, (
|
|
"app.state.federation_client must not be None"
|
|
)
|
|
assert isinstance(app.state.federation_client, httpx.AsyncClient), (
|
|
"app.state.federation_client must be an httpx.AsyncClient instance"
|
|
)
|
|
# Capture reference before lifespan shuts down
|
|
client_ref = app.state.federation_client
|
|
|
|
# After lifespan shutdown completes, the client must be closed
|
|
assert client_ref.is_closed, (
|
|
"app.state.federation_client must be closed after lifespan shutdown"
|
|
)
|
|
|
|
|
|
def test_federation_client_disables_ssl_verification(monkeypatch):
|
|
"""Federation httpx client must disable SSL verification for self-signed certs.
|
|
|
|
muxplex setup-tls can generate self-signed certificates. When a remote instance
|
|
uses such a cert, httpx's default SSL verification rejects it with
|
|
CERTIFICATE_VERIFY_FAILED, making the remote unreachable in federation.
|
|
The client must use verify=False so self-signed certs on LAN/Tailscale remotes
|
|
are accepted. Bearer token auth still protects authorization.
|
|
"""
|
|
import ssl
|
|
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
|
|
with TestClient(app):
|
|
client = app.state.federation_client
|
|
# httpx.AsyncClient(verify=False) creates a transport whose SSL context
|
|
# has verify_mode=ssl.CERT_NONE (0). If verify=True (default), it would
|
|
# use CERT_REQUIRED (2).
|
|
ssl_context = client._transport._pool._ssl_context
|
|
assert ssl_context is not None, "federation_client SSL context must be present"
|
|
assert ssl_context.verify_mode == ssl.CERT_NONE, (
|
|
"federation_client must use verify=False (CERT_NONE) "
|
|
"to support self-signed certificates on remote instances"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/federation/sessions (task-5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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_remote_id_is_integer_index(client, monkeypatch, tmp_path):
|
|
"""GET /api/federation/sessions returns device_id-based remoteId for remote sessions.
|
|
|
|
When a remote doesn't have a device_id field, remoteId falls back to str(index)
|
|
(e.g. '0', '1', '2'...) -- NOT the URL string and NOT any 'id' field from the
|
|
remote config dict. When device_id is set, it is used directly.
|
|
"""
|
|
import json
|
|
|
|
import httpx
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Two remote instances -- first will succeed, second will fail
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"device_name": "local-host",
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://spark-2:8088",
|
|
"key": "abc123",
|
|
"name": "spark-2",
|
|
},
|
|
{
|
|
"url": "http://spark-3:8088",
|
|
"key": "def456",
|
|
"name": "spark-3",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
# First remote returns one session; second is unreachable
|
|
async def mock_get(url, **kwargs):
|
|
if "spark-2" in url:
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.raise_for_status = lambda: None
|
|
mock_resp.json = lambda: [{"name": "work", "snapshot": "", "bell": {}}]
|
|
return mock_resp
|
|
raise httpx.ConnectError("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()
|
|
|
|
# The successful remote session (spark-2, index 0) must have remoteId == 0
|
|
remote_entries = [s for s in data if s.get("remoteId") is not None]
|
|
assert len(remote_entries) == 2, (
|
|
f"Expected 2 remote entries (1 session + 1 unreachable), got: {remote_entries}"
|
|
)
|
|
|
|
spark2_session = next(
|
|
(s for s in remote_entries if s.get("deviceName") == "spark-2"), None
|
|
)
|
|
assert spark2_session is not None, "Expected a session entry from spark-2"
|
|
assert spark2_session["remoteId"] == "0", (
|
|
f"remoteId for first remote (no device_id, index 0) must be string '0', "
|
|
f"got: {spark2_session['remoteId']!r}"
|
|
)
|
|
assert isinstance(spark2_session["remoteId"], str), (
|
|
f"remoteId must be a str, got {type(spark2_session['remoteId'])}"
|
|
)
|
|
|
|
# The unreachable remote (spark-3, index 1) must have remoteId == '1'
|
|
spark3_entry = next(
|
|
(s for s in remote_entries if s.get("deviceName") == "spark-3"), None
|
|
)
|
|
assert spark3_entry is not None, "Expected a status entry from spark-3"
|
|
assert spark3_entry["remoteId"] == "1", (
|
|
f"remoteId for second remote (no device_id, index 1) must be string '1', "
|
|
f"got: {spark3_entry['remoteId']!r}"
|
|
)
|
|
|
|
|
|
def test_federation_sessions_local_sessions_have_session_key(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""GET /api/federation/sessions: local sessions must have sessionKey = 'deviceId:name'.
|
|
|
|
Local sessions are tagged with sessionKey = f'{local_device_id}:{name}' so they
|
|
can be uniquely identified in the merged multi-device session list.
|
|
"""
|
|
import json
|
|
|
|
import muxplex.identity as identity_mod
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Redirect identity file to a known UUID
|
|
identity_path = tmp_path / "identity.json"
|
|
identity_path.write_text(json.dumps({"device_id": "test-local-id"}))
|
|
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path)
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Write settings with no remote instances
|
|
settings_path.write_text(
|
|
json.dumps({"device_name": "my-local-machine", "remote_instances": []})
|
|
)
|
|
|
|
# Mock local session data
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"alpha": "", "beta": ""})
|
|
|
|
response = client.get("/api/federation/sessions")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Find local sessions (remoteId=None)
|
|
local_sessions = [s for s in data if s.get("remoteId") is None]
|
|
assert len(local_sessions) == 2, f"Expected 2 local sessions, got: {local_sessions}"
|
|
|
|
for local in local_sessions:
|
|
assert "sessionKey" in local, (
|
|
f"Local session must have sessionKey field, but got: {local}"
|
|
)
|
|
expected_key = f"test-local-id:{local['name']}"
|
|
assert local["sessionKey"] == expected_key, (
|
|
f"Local sessionKey must be '{expected_key}', got: {local['sessionKey']!r}"
|
|
)
|
|
|
|
|
|
def test_federation_sessions_remote_sessions_have_session_key(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""GET /api/federation/sessions: remote sessions must have sessionKey = 'remoteId:name'.
|
|
|
|
The sessionKey format is '{remote_id}:{session_name}' to prevent collisions
|
|
between local and remote sessions with identical names.
|
|
"""
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx # noqa: F401
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# One remote instance
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"device_name": "local-host",
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://spark-2:8088",
|
|
"key": "abc123",
|
|
"name": "spark-2",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
# Remote returns two sessions
|
|
async def mock_get(url, **kwargs):
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.raise_for_status = lambda: None
|
|
mock_resp.json = lambda: [
|
|
{"name": "work", "snapshot": "", "bell": {}},
|
|
{"name": "dev", "snapshot": "", "bell": {}},
|
|
]
|
|
return mock_resp
|
|
|
|
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()
|
|
|
|
# Find remote sessions (remoteId is not None) that are named sessions (not status entries)
|
|
named_remote_sessions = [
|
|
s for s in data if s.get("remoteId") is not None and "name" in s
|
|
]
|
|
assert len(named_remote_sessions) == 2, (
|
|
f"Expected 2 named remote sessions, got: {named_remote_sessions}"
|
|
)
|
|
|
|
for session in named_remote_sessions:
|
|
assert "sessionKey" in session, (
|
|
f"Remote session must have sessionKey field, but got: {session}"
|
|
)
|
|
expected_key = f"{session['remoteId']}:{session['name']}"
|
|
assert session["sessionKey"] == expected_key, (
|
|
f"sessionKey must be 'remoteId:name' = {expected_key!r}, "
|
|
f"got: {session['sessionKey']!r}"
|
|
)
|
|
|
|
# Verify specific values
|
|
work_session = next(s for s in named_remote_sessions if s["name"] == "work")
|
|
assert work_session["sessionKey"] == "0:work", (
|
|
f"Expected sessionKey '0:work', got: {work_session['sessionKey']!r}"
|
|
)
|
|
|
|
dev_session = next(s for s in named_remote_sessions if s["name"] == "dev")
|
|
assert dev_session["sessionKey"] == "0:dev", (
|
|
f"Expected sessionKey '0:dev', got: {dev_session['sessionKey']!r}"
|
|
)
|
|
|
|
|
|
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 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") == "0"
|
|
) # device_id string (fallback to str(index) when no device_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/federation/{remote_id}/connect/{session_name} (task-12)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_connect_proxies_to_remote(client, monkeypatch, tmp_path):
|
|
"""POST /api/federation/{remote_id}/connect/{session_name} proxies POST to remote's connect endpoint.
|
|
|
|
Looks up remote by integer index, sends POST {remote_url}/api/sessions/{session_name}/connect
|
|
with Bearer auth header, 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 = {
|
|
"active_session": "my-session",
|
|
"ttyd_port": 7682,
|
|
}
|
|
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/connect/my-session")
|
|
assert response.status_code == 200
|
|
|
|
# Verify the POST was made to the correct URL with session name
|
|
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/my-session/connect", (
|
|
f"Expected POST to remote connect 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 the response is the remote's JSON
|
|
data = response.json()
|
|
assert data["active_session"] == "my-session"
|
|
assert data["ttyd_port"] == 7682
|
|
|
|
|
|
def test_federation_connect_returns_404_for_invalid_remote_id(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{remote_id}/connect/{session_name} 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/connect/my-session")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_federation_connect_returns_404_for_non_integer_remote_id(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{device_id}/connect/{session_name} returns 404 when device_id has no match.
|
|
|
|
With device_id typed as str, any string is a valid path parameter.
|
|
When no remote matches 'not-an-int' by device_id and it cannot be parsed
|
|
as an integer index, the lookup returns None and the endpoint returns 404.
|
|
"""
|
|
import json
|
|
|
|
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": []}))
|
|
|
|
response = client.post("/api/federation/not-an-int/connect/my-session")
|
|
assert response.status_code == 404 # device_id not found in remote_instances
|
|
|
|
|
|
def test_federation_connect_returns_503_when_remote_unreachable(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{remote_id}/connect/{session_name} 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/connect/my-session")
|
|
assert response.status_code == 503
|
|
|
|
|
|
def test_federation_connect_returns_502_when_remote_returns_error_status(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{remote_id}/connect/{session_name} 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 the 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 = 502
|
|
raise httpx.HTTPStatusError(
|
|
"Bad Gateway",
|
|
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/connect/my-session")
|
|
assert response.status_code == 502
|
|
|
|
|
|
def test_federation_connect_returns_404_for_negative_remote_id(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{remote_id}/connect/{session_name} returns 404 for negative remote_id.
|
|
|
|
Negative indices are valid Python list indices ("from the end"), so without an
|
|
explicit guard a value like -1 would silently proxy to the last configured remote
|
|
instead of returning 404. The endpoint must treat negatives as 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)
|
|
|
|
# One remote configured — without the guard, remote_id=-1 would return it
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://remote-host:8088",
|
|
"key": "secret-key-123",
|
|
"name": "remote-host",
|
|
"id": "remote-0",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
response = client.post("/api/federation/-1/connect/my-session")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/federation/generate-key (task-13)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch):
|
|
"""POST /api/federation/generate-key creates a key file and returns the key in the response.
|
|
|
|
- Endpoint returns 200 with {"key": <str>, "path": <str>}
|
|
- Returned key length > 20
|
|
- The key file is created at the redirected FEDERATION_KEY_PATH
|
|
- The file contents match the returned key (with trailing newline stripped)
|
|
"""
|
|
import muxplex.settings as settings_mod
|
|
|
|
key_path = tmp_path / "federation_key"
|
|
monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_path)
|
|
|
|
response = client.post("/api/federation/generate-key")
|
|
assert response.status_code == 200, (
|
|
f"Expected 200, got {response.status_code}: {response.text}"
|
|
)
|
|
|
|
data = response.json()
|
|
assert "key" in data, f"Response must include 'key' field, got: {data}"
|
|
assert "path" in data, f"Response must include 'path' field, got: {data}"
|
|
|
|
returned_key = data["key"]
|
|
assert len(returned_key) > 20, (
|
|
f"Key must be longer than 20 chars, got length {len(returned_key)}"
|
|
)
|
|
|
|
# Verify file was created
|
|
assert key_path.exists(), f"Key file must be created at {key_path}"
|
|
|
|
# Verify file contents match returned key
|
|
file_contents = key_path.read_text().strip()
|
|
assert file_contents == returned_key, (
|
|
f"File contents must match returned key. "
|
|
f"File: {file_contents!r}, key: {returned_key!r}"
|
|
)
|
|
|
|
|
|
def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch):
|
|
"""GET /api/auth/token returns 401 when request has no valid session cookie."""
|
|
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
|
with TestClient(app, base_url="http://192.168.1.1") as c:
|
|
# No cookie set — endpoint must return 401 with application/json accept
|
|
response = c.get("/api/auth/token", headers={"Accept": "application/json"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bug fix: delete_session must pass input="y\n" to subprocess.run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_session_passes_stdin_y_to_subprocess(client, monkeypatch, tmp_path):
|
|
"""DELETE /api/sessions/{name} must pass input='y\\n' to subprocess.run.
|
|
|
|
When delete_session_template uses an interactive command (e.g. amplifier-dev
|
|
--destroy), the confirmation prompt must be auto-answered via stdin.
|
|
Without input='y\\n', subprocess.run hangs until 30s timeout and the
|
|
session is never actually deleted.
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
|
|
|
captured_kwargs = []
|
|
|
|
def mock_run(cmd, **kwargs):
|
|
captured_kwargs.append(kwargs)
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
|
response = client.delete("/api/sessions/my-session")
|
|
|
|
assert response.status_code == 200
|
|
assert len(captured_kwargs) == 1, "subprocess.run must be called once"
|
|
kwargs = captured_kwargs[0]
|
|
assert "input" in kwargs, (
|
|
"subprocess.run must receive input= kwarg to auto-answer interactive prompts"
|
|
)
|
|
assert kwargs["input"] == "y\n", (
|
|
f"input must be 'y\\n' to confirm deletion, got: {kwargs['input']!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bug fix: request-level INFO logging for session operations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_session_logs_command_at_info(client, monkeypatch, tmp_path, caplog):
|
|
"""DELETE /api/sessions/{name} must log the command being run at INFO level."""
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["logged-session"])
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
|
|
|
def mock_run(cmd, **kwargs):
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
|
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
|
client.delete("/api/sessions/logged-session")
|
|
|
|
log_messages = "\n".join(caplog.messages)
|
|
assert "logged-session" in log_messages, (
|
|
f"delete_session must log the session name at INFO level, got logs:\n{log_messages}"
|
|
)
|
|
|
|
|
|
def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
|
|
"""POST /api/sessions must log the command being launched at INFO level."""
|
|
import logging
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.communicate = AsyncMock(return_value=(b"", b""))
|
|
mock_proc.returncode = 0
|
|
|
|
monkeypatch.setattr(
|
|
"muxplex.main.asyncio.create_subprocess_shell",
|
|
AsyncMock(return_value=mock_proc),
|
|
)
|
|
|
|
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
|
client.post("/api/sessions", json={"name": "new-session"})
|
|
|
|
log_messages = "\n".join(caplog.messages)
|
|
assert "new-session" in log_messages, (
|
|
f"create_session must log session name at INFO level, got logs:\n{log_messages}"
|
|
)
|
|
|
|
|
|
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
|
|
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
|
|
import logging
|
|
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
|
|
|
|
async def mock_kill_ttyd():
|
|
pass
|
|
|
|
async def mock_spawn_ttyd(name):
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
|
|
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
|
|
|
|
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
|
client.post("/api/sessions/target-session/connect")
|
|
|
|
log_messages = "\n".join(caplog.messages)
|
|
assert "target-session" in log_messages, (
|
|
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
|
|
)
|
|
|
|
|
|
def test_cli_uvicorn_log_level_is_info():
|
|
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
|
|
import inspect
|
|
from muxplex import cli
|
|
|
|
source = inspect.getsource(cli.serve)
|
|
assert 'log_level="info"' in source or "log_level='info'" in source, (
|
|
"serve() must call uvicorn.run(..., log_level='info') so application "
|
|
"logs appear in journalctl; currently set to 'warning' which suppresses them"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Federation bell/clear proxy (task-3-federation-bell-clear-proxy)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_bell_clear_proxies_to_remote(client, monkeypatch, tmp_path):
|
|
"""POST /api/federation/{remote_id}/sessions/{name}/bell/clear proxies POST to remote's bell/clear endpoint.
|
|
|
|
Looks up remote by integer index, sends POST {remote_url}/api/sessions/{session_name}/bell/clear
|
|
with Bearer auth header, 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 = {"cleared": True}
|
|
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/my-session/bell/clear")
|
|
assert response.status_code == 200
|
|
|
|
# Verify the POST was made to the correct URL with session name
|
|
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/my-session/bell/clear"
|
|
), f"Expected POST to remote bell/clear 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 the response is the remote's JSON
|
|
data = response.json()
|
|
assert data["cleared"] is True
|
|
|
|
|
|
def test_federation_bell_clear_returns_404_for_invalid_remote(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""POST /api/federation/{remote_id}/sessions/{name}/bell/clear 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/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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Federation Authorization header safety — guard against empty key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Poll cycle federation bell clear (task-3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""_run_poll_cycle() fires POST bell/clear to remote when a device is viewing a remote session.
|
|
|
|
Sets up state with active_remote_id=0, one device viewing 'build' in fullscreen
|
|
with a recent interaction timestamp, mocks the module-level _federation_client,
|
|
runs one poll cycle, and verifies mock_client.post was called with the correct
|
|
URL and Bearer auth header.
|
|
"""
|
|
import json
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import muxplex.main as main_mod
|
|
import muxplex.settings as settings_mod
|
|
from muxplex.state import save_state
|
|
|
|
# Set up settings with one remote instance at index 0
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://remote-host:8088",
|
|
"key": "test-key",
|
|
"name": "remote-host",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Set up state with active_remote_id=0, one device viewing 'build' in fullscreen
|
|
state = {
|
|
"active_session": None,
|
|
"active_remote_id": 0,
|
|
"session_order": ["build"],
|
|
"sessions": {
|
|
"build": {
|
|
"bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0}
|
|
}
|
|
},
|
|
"devices": {
|
|
"dev-1": {
|
|
"label": "My Device",
|
|
"viewing_session": "build",
|
|
"view_mode": "fullscreen",
|
|
"last_interaction_at": time.time(),
|
|
"last_heartbeat_at": time.time(),
|
|
}
|
|
},
|
|
}
|
|
save_state(state)
|
|
|
|
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
|
async def mock_enumerate():
|
|
return ["build"], {"build": 1700000000.0}
|
|
|
|
async def mock_snapshot_all(names):
|
|
return {"build": "pane text"}
|
|
|
|
async def mock_process_bell_flags(names, state):
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
|
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
|
monkeypatch.setattr(
|
|
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
|
)
|
|
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
|
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
|
monkeypatch.setattr("muxplex.main.process_bell_flags", mock_process_bell_flags)
|
|
|
|
# Capture POST calls from the mocked federation client
|
|
post_calls: list[dict] = []
|
|
|
|
async def mock_post(url, **kwargs):
|
|
post_calls.append({"url": url, "kwargs": kwargs})
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
return resp
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post = mock_post
|
|
monkeypatch.setattr(main_mod, "_federation_client", mock_client)
|
|
|
|
# Run one poll cycle
|
|
await main_mod._run_poll_cycle()
|
|
|
|
# Verify mock_client.post was called exactly once with the correct URL and auth
|
|
assert len(post_calls) == 1, (
|
|
f"Expected exactly 1 POST call to remote bell/clear, got {len(post_calls)}: {post_calls}"
|
|
)
|
|
call = post_calls[0]
|
|
assert "/api/sessions/build/bell/clear" in call["url"], (
|
|
f"Expected URL to contain '/api/sessions/build/bell/clear', got: {call['url']}"
|
|
)
|
|
headers = call["kwargs"].get("headers", {})
|
|
assert headers.get("Authorization") == "Bearer test-key", (
|
|
f"Expected 'Authorization: Bearer test-key' header, got: {headers}"
|
|
)
|
|
|
|
|
|
async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uuid(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""_run_poll_cycle() fires bell/clear when active_remote_id is a UUID string.
|
|
|
|
Regression test for the bug where isinstance(active_remote_id, int) was
|
|
always False for UUID strings, silently skipping the bell-clear POST.
|
|
|
|
Sets up state with active_remote_id="dead-beef-uuid", remote instance has
|
|
device_id="dead-beef-uuid", one device viewing 'build' in fullscreen with
|
|
a recent interaction. Verifies mock_client.post is called with the correct
|
|
URL and Bearer auth header.
|
|
"""
|
|
import json
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import muxplex.main as main_mod
|
|
import muxplex.settings as settings_mod
|
|
from muxplex.state import save_state
|
|
|
|
remote_uuid = "dead-beef-uuid-1234-abcd"
|
|
|
|
# Set up settings with one remote instance that has a device_id
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://remote-host:8088",
|
|
"key": "uuid-key",
|
|
"name": "remote-host",
|
|
"device_id": remote_uuid,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Set up state with active_remote_id as a UUID string (not an integer)
|
|
state = {
|
|
"active_session": None,
|
|
"active_remote_id": remote_uuid,
|
|
"session_order": ["build"],
|
|
"sessions": {
|
|
"build": {
|
|
"bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0}
|
|
}
|
|
},
|
|
"devices": {
|
|
"dev-1": {
|
|
"label": "My Device",
|
|
"viewing_session": "build",
|
|
"view_mode": "fullscreen",
|
|
"last_interaction_at": time.time(),
|
|
"last_heartbeat_at": time.time(),
|
|
}
|
|
},
|
|
}
|
|
save_state(state)
|
|
|
|
# Mock all poll-cycle dependencies so the cycle completes without real tmux
|
|
async def mock_enumerate():
|
|
return ["build"], {"build": 1700000000.0}
|
|
|
|
async def mock_snapshot_all(names):
|
|
return {"build": "pane text"}
|
|
|
|
async def mock_process_bell_flags(names, state):
|
|
pass
|
|
|
|
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
|
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
|
monkeypatch.setattr(
|
|
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
|
)
|
|
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
|
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
|
monkeypatch.setattr("muxplex.main.process_bell_flags", mock_process_bell_flags)
|
|
|
|
# Capture POST calls from the mocked federation client
|
|
post_calls: list[dict] = []
|
|
|
|
async def mock_post(url, **kwargs):
|
|
post_calls.append({"url": url, "kwargs": kwargs})
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
return resp
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.post = mock_post
|
|
monkeypatch.setattr(main_mod, "_federation_client", mock_client)
|
|
|
|
# Run one poll cycle
|
|
await main_mod._run_poll_cycle()
|
|
|
|
# Verify mock_client.post was called — the isinstance bug causes 0 calls
|
|
assert len(post_calls) == 1, (
|
|
f"Expected exactly 1 POST call to remote bell/clear (UUID active_remote_id), "
|
|
f"got {len(post_calls)}: {post_calls}. "
|
|
f"This indicates the isinstance(active_remote_id, int) guard is still present."
|
|
)
|
|
call = post_calls[0]
|
|
assert "/api/sessions/build/bell/clear" in call["url"], (
|
|
f"Expected URL to contain '/api/sessions/build/bell/clear', got: {call['url']}"
|
|
)
|
|
headers = call["kwargs"].get("headers", {})
|
|
assert headers.get("Authorization") == "Bearer uuid-key", (
|
|
f"Expected 'Authorization: Bearer uuid-key' header, got: {headers}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/settings/sync (task-7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_settings_sync_returns_200(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings/sync returns HTTP 200."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
response = client.get("/api/settings/sync")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_settings_sync_response_has_settings_and_timestamp(
|
|
client, tmp_path, monkeypatch
|
|
):
|
|
"""GET /api/settings/sync returns {settings: dict, settings_updated_at: float}."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
response = client.get("/api/settings/sync")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "settings" in data, (
|
|
f"Response must have 'settings' key, got: {list(data.keys())}"
|
|
)
|
|
assert "settings_updated_at" in data, (
|
|
f"Response must have 'settings_updated_at' key, got: {list(data.keys())}"
|
|
)
|
|
assert isinstance(data["settings"], dict), (
|
|
f"'settings' must be a dict, got: {type(data['settings'])}"
|
|
)
|
|
assert isinstance(data["settings_updated_at"], float), (
|
|
f"'settings_updated_at' must be a float, got: {type(data['settings_updated_at'])}"
|
|
)
|
|
|
|
|
|
def test_settings_sync_excludes_infrastructure_keys(client, tmp_path, monkeypatch):
|
|
"""GET /api/settings/sync settings dict must not contain infrastructure keys."""
|
|
import muxplex.settings as settings_mod
|
|
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
response = client.get("/api/settings/sync")
|
|
assert response.status_code == 200
|
|
settings = response.json()["settings"]
|
|
infra_keys = (
|
|
"host",
|
|
"port",
|
|
"auth",
|
|
"session_ttl",
|
|
"tls_cert",
|
|
"tls_key",
|
|
"device_name",
|
|
"federation_key",
|
|
"remote_instances",
|
|
"multi_device_enabled",
|
|
"new_session_template",
|
|
"delete_session_template",
|
|
)
|
|
for key in infra_keys:
|
|
assert key not in settings, (
|
|
f"Infrastructure key '{key}' must not appear in /api/settings/sync response"
|
|
)
|
|
|
|
|
|
def test_federation_auth_headers_guard_empty_key():
|
|
"""Every federation Authorization header construction must guard against empty key.
|
|
|
|
An empty remote_instances[].key produces 'Bearer ' (trailing space, empty
|
|
token). httpx rejects that with "Illegal header value b'Bearer '" which
|
|
silently makes the remote appear unreachable.
|
|
|
|
The fix: every site that constructs the header must use the pattern
|
|
headers={"Authorization": f"Bearer {key}"} if key else {}
|
|
or an equivalent conditional, so an empty key simply omits the header.
|
|
|
|
This is a source-inspection test — it catches regressions without spinning
|
|
up a live server.
|
|
"""
|
|
import inspect
|
|
|
|
import muxplex.main as main_mod
|
|
|
|
source = inspect.getsource(main_mod)
|
|
|
|
# Collect every line that constructs an Authorization Bearer header (not
|
|
# comment or docstring lines — we only care about executable code lines).
|
|
offending: list[str] = []
|
|
for line in source.splitlines():
|
|
stripped = line.strip()
|
|
# Skip comment and docstring lines
|
|
if (
|
|
stripped.startswith("#")
|
|
or stripped.startswith('"""')
|
|
or stripped.startswith("'\"'\"'")
|
|
):
|
|
continue
|
|
# Match lines that build the header dict value
|
|
if (
|
|
'"Authorization"' in stripped
|
|
and "Bearer" in stripped
|
|
and 'f"Bearer' in stripped
|
|
):
|
|
# Must have an inline `if` guard — e.g. `{...} if key else {}`
|
|
if " if " not in stripped:
|
|
offending.append(stripped)
|
|
|
|
assert not offending, (
|
|
"Unguarded federation Bearer header(s) found in main.py — "
|
|
"use `{...} if key else {}` to skip the header when key is empty:\n"
|
|
+ "\n".join(f" {line}" for line in offending)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PUT /api/settings/sync (task-8)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_put_settings_sync_applies_when_newer(client, tmp_path, monkeypatch):
|
|
"""PUT /api/settings/sync applies settings when incoming timestamp is newer."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Set local timestamp to something old
|
|
settings_path.write_text(json.dumps({"settings_updated_at": 1000.0}))
|
|
|
|
response = client.put(
|
|
"/api/settings/sync",
|
|
json={"settings": {"fontSize": 20}, "settings_updated_at": 2000.0},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "settings" in data, f"Response must have 'settings' key, got: {data}"
|
|
assert "settings_updated_at" in data, (
|
|
f"Response must have 'settings_updated_at' key, got: {data}"
|
|
)
|
|
assert data["settings_updated_at"] == 2000.0, (
|
|
f"Timestamp must be 2000.0, got: {data['settings_updated_at']}"
|
|
)
|
|
assert data["settings"]["fontSize"] == 20, (
|
|
f"fontSize must be 20, got: {data['settings'].get('fontSize')}"
|
|
)
|
|
|
|
|
|
def test_put_settings_sync_rejects_when_older(client, tmp_path, monkeypatch):
|
|
"""PUT /api/settings/sync returns 409 when incoming timestamp is older."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Set local timestamp to something newer than the incoming
|
|
settings_path.write_text(json.dumps({"settings_updated_at": 2000.0}))
|
|
|
|
response = client.put(
|
|
"/api/settings/sync",
|
|
json={"settings": {"fontSize": 18}, "settings_updated_at": 1000.0},
|
|
)
|
|
assert response.status_code == 409
|
|
data = response.json()
|
|
assert "settings" in data, f"409 body must have 'settings' key, got: {data}"
|
|
assert "settings_updated_at" in data, (
|
|
f"409 body must have 'settings_updated_at' key, got: {data}"
|
|
)
|
|
# Should return local state, which has the newer timestamp
|
|
assert data["settings_updated_at"] == 2000.0, (
|
|
f"409 body must return local timestamp 2000.0, got: {data['settings_updated_at']}"
|
|
)
|
|
|
|
|
|
def test_put_settings_sync_rejects_when_equal(client, tmp_path, monkeypatch):
|
|
"""PUT /api/settings/sync returns 409 when timestamps are equal."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Set local timestamp
|
|
settings_path.write_text(json.dumps({"settings_updated_at": 1500.0}))
|
|
|
|
response = client.put(
|
|
"/api/settings/sync",
|
|
json={"settings": {"fontSize": 16}, "settings_updated_at": 1500.0},
|
|
)
|
|
assert response.status_code == 409, (
|
|
f"Equal timestamps must return 409, got {response.status_code}"
|
|
)
|
|
data = response.json()
|
|
assert data["settings_updated_at"] == 1500.0, (
|
|
f"409 body must return local timestamp 1500.0, got: {data.get('settings_updated_at')}"
|
|
)
|
|
|
|
|
|
def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatch):
|
|
"""PUT /api/settings/sync does not apply non-syncable keys like host."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# Start with default settings (host defaults to "127.0.0.1")
|
|
settings_path.write_text(json.dumps({"settings_updated_at": 0.0}))
|
|
|
|
response = client.put(
|
|
"/api/settings/sync",
|
|
json={
|
|
"settings": {"fontSize": 20, "host": "evil.com"},
|
|
"settings_updated_at": 9999999999.0,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Verify fontSize (syncable) was changed
|
|
local = settings_mod.load_settings()
|
|
assert local["fontSize"] == 20, (
|
|
f"Syncable key 'fontSize' must be updated to 20, got: {local['fontSize']}"
|
|
)
|
|
|
|
# Verify host (non-syncable) was NOT changed
|
|
assert local["host"] == "127.0.0.1", (
|
|
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_remote: zero-session visibility and flapping grace period
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_remote_returns_empty_status_for_zero_sessions(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""When remote /api/sessions returns [], federation returns {status: 'empty'} entry.
|
|
|
|
A device that is online but has zero tmux sessions must not vanish silently.
|
|
Instead, the endpoint must include a {status: 'empty', deviceName: ...} entry
|
|
so the frontend can render a 'No sessions' tile.
|
|
|
|
Before implementation: fails because the list comprehension returns [] for empty
|
|
session lists, and the empty list is flattened into nothing.
|
|
"""
|
|
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://empty-host:8088",
|
|
"key": "secret",
|
|
"name": "empty-host",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
mock_resp = MagicMock(spec=httpx.Response)
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = []
|
|
mock_resp.raise_for_status.return_value = None
|
|
|
|
async def mock_get(*args, **kwargs):
|
|
return mock_resp
|
|
|
|
mock_fed_client = MagicMock()
|
|
mock_fed_client.get = mock_get
|
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
|
|
response = client.get("/api/federation/sessions")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1, (
|
|
f"Expected exactly one status entry for empty remote, got {len(data)} entries: {data}"
|
|
)
|
|
assert data[0].get("status") == "empty", (
|
|
f"Expected status='empty' for zero-session remote, got: {data[0].get('status')!r}"
|
|
)
|
|
assert data[0].get("deviceName") == "empty-host", (
|
|
f"Expected deviceName='empty-host', got: {data[0].get('deviceName')!r}"
|
|
)
|
|
|
|
|
|
def test_fetch_remote_uses_cache_on_transient_failure(client, monkeypatch, tmp_path):
|
|
"""When remote fails after a prior success, cached sessions are returned (grace period).
|
|
|
|
A single failed HTTP request must not immediately evict the device from the UI.
|
|
The server keeps the last-known-good result and returns it for up to
|
|
_FEDERATION_GRACE_FAILURES consecutive failures.
|
|
|
|
Before implementation: fails because fetch_remote has no cache — transient failure
|
|
immediately returns {status: 'unreachable'}.
|
|
"""
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
|
|
import muxplex.main as main_mod
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Reset module-level cache so this test starts clean
|
|
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
|
|
|
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:8088", "key": "k", "name": "cache-host"}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
call_count = [0]
|
|
|
|
async def mock_get_stateful(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
mock_resp = MagicMock(spec=httpx.Response)
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = [{"name": "sess1"}, {"name": "sess2"}]
|
|
mock_resp.raise_for_status.return_value = None
|
|
return mock_resp
|
|
raise httpx.TimeoutException("timeout", request=MagicMock())
|
|
|
|
mock_fed_client = MagicMock()
|
|
mock_fed_client.get = mock_get_stateful
|
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
|
|
# First call: succeeds and populates cache
|
|
r1 = client.get("/api/federation/sessions")
|
|
assert r1.status_code == 200
|
|
d1 = [s for s in r1.json() if s.get("deviceName") == "cache-host"]
|
|
assert len(d1) == 2, f"First call must return 2 sessions, got {d1}"
|
|
|
|
# Second call: remote times out — cache should return the 2 cached sessions
|
|
r2 = client.get("/api/federation/sessions")
|
|
assert r2.status_code == 200
|
|
d2 = [s for s in r2.json() if s.get("deviceName") == "cache-host"]
|
|
assert len(d2) == 2, f"Within grace period, must return 2 cached sessions, got {d2}"
|
|
assert not any(s.get("status") == "unreachable" for s in d2), (
|
|
"Within grace period, cached sessions must be returned, not 'unreachable'"
|
|
)
|
|
|
|
|
|
def test_fetch_remote_marks_unreachable_after_grace_period(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""After _FEDERATION_GRACE_FAILURES consecutive failures, device is marked unreachable.
|
|
|
|
The grace period prevents flapping, but must not hide a genuinely offline device
|
|
indefinitely. After 3 consecutive failures the next poll must return
|
|
{status: 'unreachable'}.
|
|
|
|
Before implementation: fails because there is no cache at all — unreachable is
|
|
returned immediately on first failure.
|
|
"""
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
|
|
import muxplex.main as main_mod
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Reset module-level cache so this test starts clean
|
|
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
|
|
|
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:8088", "key": "k", "name": "grace-host"}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
|
|
call_count = [0]
|
|
|
|
async def mock_get_stateful(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
mock_resp = MagicMock(spec=httpx.Response)
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = [{"name": "sess1"}]
|
|
mock_resp.raise_for_status.return_value = None
|
|
return mock_resp
|
|
raise httpx.TimeoutException("timeout", request=MagicMock())
|
|
|
|
mock_fed_client = MagicMock()
|
|
mock_fed_client.get = mock_get_stateful
|
|
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
|
|
# Call 1: success — populates cache
|
|
r = client.get("/api/federation/sessions")
|
|
d = r.json()
|
|
assert any(s.get("name") == "sess1" for s in d), "Call 1 must return sess1"
|
|
|
|
# Calls 2-4 (failures 1-3): within grace period — must return cached sessions
|
|
for i in range(3):
|
|
r = client.get("/api/federation/sessions")
|
|
d = r.json()
|
|
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
|
assert not any(s.get("status") == "unreachable" for s in host_entries), (
|
|
f"Call {i + 2}: fail_count={i + 1} is within grace period — "
|
|
f"must return cached sessions, not 'unreachable'. Got: {host_entries}"
|
|
)
|
|
|
|
# Call 5 (failure 4): exceeds grace period — must return unreachable
|
|
r = client.get("/api/federation/sessions")
|
|
d = r.json()
|
|
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
|
assert any(s.get("status") == "unreachable" for s in host_entries), (
|
|
f"After exceeding grace period, device must be marked 'unreachable'. Got: {host_entries}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for _lookup_remote_by_device_id helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_lookup_remote_by_device_id_found(tmp_path, monkeypatch):
|
|
"""_lookup_remote_by_device_id returns the remote dict matching the given device_id."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
from muxplex.main import _lookup_remote_by_device_id
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://laptop:8088",
|
|
"key": "key-aaa",
|
|
"name": "Laptop",
|
|
"device_id": "aaa-111",
|
|
},
|
|
{
|
|
"url": "http://desktop:8088",
|
|
"key": "key-bbb",
|
|
"name": "Desktop",
|
|
"device_id": "bbb-222",
|
|
},
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
result = _lookup_remote_by_device_id("bbb-222")
|
|
|
|
assert result is not None, "Expected a remote dict, got None"
|
|
assert result.get("name") == "Desktop", (
|
|
f"Expected 'Desktop' remote, got: {result!r}"
|
|
)
|
|
assert result.get("device_id") == "bbb-222", (
|
|
f"Expected device_id 'bbb-222', got: {result.get('device_id')!r}"
|
|
)
|
|
|
|
|
|
def test_lookup_remote_by_device_id_not_found(tmp_path, monkeypatch):
|
|
"""_lookup_remote_by_device_id returns None when no remote matches the given device_id."""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
from muxplex.main import _lookup_remote_by_device_id
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"remote_instances": [
|
|
{
|
|
"url": "http://laptop:8088",
|
|
"key": "key-aaa",
|
|
"name": "Laptop",
|
|
"device_id": "aaa-111",
|
|
},
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
result = _lookup_remote_by_device_id("zzz-999")
|
|
|
|
assert result is None, (
|
|
f"Expected None for unknown device_id 'zzz-999', got: {result!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task-9: Federation Proxy Endpoints — Switch to device_id Lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_connect_by_device_id(client, monkeypatch, tmp_path):
|
|
"""POST /api/federation/{device_id}/connect/{session_name} works when device_id matches a remote.
|
|
|
|
Configures a remote with device_id='aaa-111-bbb', POSTs to the new device_id-based
|
|
URL, and verifies the endpoint proxies to the correct remote and returns 200.
|
|
"""
|
|
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://laptop:8088",
|
|
"key": "key-aaa",
|
|
"name": "Laptop",
|
|
"device_id": "aaa-111-bbb",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
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 = {
|
|
"active_session": "my-session",
|
|
"ttyd_port": 7682,
|
|
}
|
|
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/aaa-111-bbb/connect/my-session")
|
|
assert response.status_code == 200, (
|
|
f"Expected 200, got {response.status_code}: {response.text}"
|
|
)
|
|
data = response.json()
|
|
assert data["active_session"] == "my-session"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task-10: Tag Sessions with device_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_federation_sessions_tags_local_with_device_id(client, monkeypatch, tmp_path):
|
|
"""GET /api/federation/sessions: local sessions have deviceId and sessionKey from identity.
|
|
|
|
The local device_id is loaded from the identity file and used to:
|
|
- tag each local session with deviceId: local_device_id
|
|
- build sessionKey: f'{local_device_id}:{name}'
|
|
"""
|
|
import json
|
|
|
|
import muxplex.identity as identity_mod
|
|
import muxplex.settings as settings_mod
|
|
|
|
# Redirect identity file to a known UUID
|
|
identity_path = tmp_path / "identity.json"
|
|
identity_path.write_text(json.dumps({"device_id": "local-uuid"}))
|
|
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path)
|
|
|
|
# Set up settings with no remote instances
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
settings_path.write_text(
|
|
json.dumps({"device_name": "my-machine", "remote_instances": []})
|
|
)
|
|
|
|
# Mock local session list
|
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["dev"])
|
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"dev": ""})
|
|
|
|
response = client.get("/api/federation/sessions")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
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.get("deviceId") == "local-uuid", (
|
|
f"Local session must have deviceId='local-uuid', got: {local.get('deviceId')!r}"
|
|
)
|
|
assert local.get("sessionKey") == "local-uuid:dev", (
|
|
f"Local session must have sessionKey='local-uuid:dev', got: {local.get('sessionKey')!r}"
|
|
)
|
|
|
|
|
|
def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
|
|
"""POST /api/federation/{device_id}/connect/{session_name} returns 404 when device_id has no match.
|
|
|
|
POSTs to a URL with an unrecognised device_id; the lookup returns None and
|
|
the endpoint must respond with HTTP 404.
|
|
"""
|
|
import json
|
|
|
|
import muxplex.settings as settings_mod
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
|
|
# No remotes configured — any device_id lookup returns None
|
|
settings_path.write_text(json.dumps({"remote_instances": []}))
|
|
|
|
response = client.post("/api/federation/nonexistent-device/connect/my-session")
|
|
assert response.status_code == 404, (
|
|
f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}"
|
|
)
|