"""Regression tests for the machine-dashboard multi-profile unification. The dashboard is ONE machine-level management surface: config, env, MCP, model, and chat-PTY endpoints accept an optional ``profile`` so the global profile switcher can target any profile's HERMES_HOME. These tests pin: reads/writes land in the REQUESTED profile, the dashboard's own profile stays untouched, and the chat PTY env is scoped via HERMES_HOME. """ import pytest import yaml @pytest.fixture def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home): """Isolated default home + one named profile, each with config + .env.""" from hermes_constants import get_hermes_home from hermes_cli import profiles default_home = get_hermes_home() profiles_root = default_home / "profiles" worker_home = profiles_root / "worker_beta" for home in (default_home, worker_home): home.mkdir(parents=True, exist_ok=True) (home / "config.yaml").write_text("{}\n", encoding="utf-8") (worker_home / ".env").write_text("", encoding="utf-8") monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) return {"default": default_home, "worker_beta": worker_home} @pytest.fixture def client(monkeypatch, isolated_profiles): try: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") import hermes_state from hermes_constants import get_hermes_home from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") c = TestClient(app) c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN return c def _cfg(home): return yaml.safe_load((home / "config.yaml").read_text()) or {} class TestProfileScopedConfig: def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles): resp = client.put( "/api/config", json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"}, ) assert resp.status_code == 200 assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus" assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus" def test_config_get_reads_target_profile(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / "config.yaml").write_text( "timezone: Venus/Cloud\n", encoding="utf-8" ) resp = client.get("/api/config", params={"profile": "worker_beta"}) assert resp.status_code == 200 assert resp.json().get("timezone") == "Venus/Cloud" # Unscoped read sees the dashboard's own config. resp = client.get("/api/config") assert resp.json().get("timezone") != "Venus/Cloud" def test_config_query_param_equivalent_to_body(self, client, isolated_profiles): """The SPA's fetchJSON injects ?profile= — must scope like body.profile.""" resp = client.put( "/api/config?profile=worker_beta", json={"config": {"timezone": "Pluto/Far"}}, ) assert resp.status_code == 200 assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far" assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far" def test_config_raw_round_trip_scoped(self, client, isolated_profiles): resp = client.put( "/api/config/raw", json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"}, ) assert resp.status_code == 200 resp = client.get("/api/config/raw", params={"profile": "worker_beta"}) assert "Io/Volcano" in resp.json()["yaml"] resp = client.get("/api/config/raw") assert "Io/Volcano" not in resp.json()["yaml"] def test_unknown_profile_404(self, client, isolated_profiles): resp = client.get("/api/config", params={"profile": "ghost"}) assert resp.status_code == 404 class TestProfileScopedEnv: def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles): resp = client.put( "/api/env", json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"}, ) assert resp.status_code == 200 worker_env = (isolated_profiles["worker_beta"] / ".env").read_text() assert "test-fal-123" in worker_env default_env_path = isolated_profiles["default"] / ".env" if default_env_path.exists(): assert "test-fal-123" not in default_env_path.read_text() def test_env_list_reads_target_profile(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / ".env").write_text( "FAL_KEY=worker-only-value\n", encoding="utf-8" ) resp = client.get("/api/env", params={"profile": "worker_beta"}) assert resp.status_code == 200 assert resp.json()["FAL_KEY"]["is_set"] is True resp = client.get("/api/env") assert resp.json()["FAL_KEY"]["is_set"] is False def test_env_delete_scoped(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / ".env").write_text( "FAL_KEY=doomed\n", encoding="utf-8" ) resp = client.request( "DELETE", "/api/env", json={"key": "FAL_KEY", "profile": "worker_beta"}, ) assert resp.status_code == 200 assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text() class TestProfileScopedMcp: def test_mcp_add_and_list_scoped(self, client, isolated_profiles): resp = client.post( "/api/mcp/servers", json={"name": "scoped-srv", "url": "http://localhost:1234/sse", "profile": "worker_beta"}, ) assert resp.status_code == 200 worker_cfg = _cfg(isolated_profiles["worker_beta"]) assert "scoped-srv" in worker_cfg.get("mcp_servers", {}) assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {}) listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json() assert any(s["name"] == "scoped-srv" for s in listing["servers"]) listing = client.get("/api/mcp/servers").json() assert not any(s["name"] == "scoped-srv" for s in listing["servers"]) def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / "config.yaml").write_text( "mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8" ) resp = client.put( "/api/mcp/servers/srv1/enabled", json={"enabled": False, "profile": "worker_beta"}, ) assert resp.status_code == 200 worker_cfg = _cfg(isolated_profiles["worker_beta"]) assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False def test_mcp_remove_scoped(self, client, isolated_profiles): (isolated_profiles["worker_beta"] / "config.yaml").write_text( "mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8" ) # Removing from the DASHBOARD's profile must 404 (srv2 lives in worker). resp = client.delete("/api/mcp/servers/srv2") assert resp.status_code == 404 resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"}) assert resp.status_code == 200 assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {}) class TestProfileScopedModel: def test_model_set_main_scoped(self, client, isolated_profiles): resp = client.post( "/api/model/set", json={ "scope": "main", "provider": "openrouter", "model": "test/model-1", "confirm_expensive_model": True, "profile": "worker_beta", }, ) assert resp.status_code == 200 worker_cfg = _cfg(isolated_profiles["worker_beta"]) model_cfg = worker_cfg.get("model", {}) assert isinstance(model_cfg, dict) assert model_cfg.get("provider") == "openrouter" default_model = _cfg(isolated_profiles["default"]).get("model", {}) if isinstance(default_model, dict): assert default_model.get("default") != "test/model-1" class TestProfileScopedChatPty: def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch): import hermes_cli.web_server as web_server monkeypatch.setattr( "hermes_cli.main._make_tui_argv", lambda root, tui_dev=False: (["cat"], None), raising=False, ) argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta") assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"]) # Scoped chat must NOT attach to the dashboard's in-memory gateway. assert "HERMES_TUI_GATEWAY_URL" not in env def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch): import hermes_cli.web_server as web_server monkeypatch.setattr( "hermes_cli.main._make_tui_argv", lambda root, tui_dev=False: (["cat"], None), raising=False, ) argv, cwd, env = web_server._resolve_chat_argv() assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"]) def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch): from fastapi import HTTPException import hermes_cli.web_server as web_server monkeypatch.setattr( "hermes_cli.main._make_tui_argv", lambda root, tui_dev=False: (["cat"], None), raising=False, ) with pytest.raises(HTTPException) as exc: web_server._resolve_chat_argv(profile="ghost") assert exc.value.status_code == 404