"""Tests for hermes_cli.web_server and related config utilities.""" import os import json import tempfile from pathlib import Path from unittest.mock import patch, MagicMock import pytest from hermes_cli.config import ( DEFAULT_CONFIG, reload_env, redact_key, _EXTRA_ENV_KEYS, OPTIONAL_ENV_VARS, ) # --------------------------------------------------------------------------- # reload_env tests # --------------------------------------------------------------------------- class TestReloadEnv: """Tests for reload_env() — re-reads .env into os.environ.""" def test_adds_new_vars(self, tmp_path): """reload_env() adds vars from .env that are not in os.environ.""" env_file = tmp_path / ".env" env_file.write_text("TEST_RELOAD_VAR=hello123\n") with patch("hermes_cli.config.get_env_path", return_value=env_file): os.environ.pop("TEST_RELOAD_VAR", None) count = reload_env() assert count >= 1 assert os.environ.get("TEST_RELOAD_VAR") == "hello123" os.environ.pop("TEST_RELOAD_VAR", None) def test_updates_changed_vars(self, tmp_path): """reload_env() updates vars whose value changed on disk.""" env_file = tmp_path / ".env" env_file.write_text("TEST_RELOAD_VAR=old_value\n") with patch("hermes_cli.config.get_env_path", return_value=env_file): os.environ["TEST_RELOAD_VAR"] = "old_value" # Now change the file env_file.write_text("TEST_RELOAD_VAR=new_value\n") count = reload_env() assert count >= 1 assert os.environ.get("TEST_RELOAD_VAR") == "new_value" os.environ.pop("TEST_RELOAD_VAR", None) def test_removes_deleted_known_vars(self, tmp_path): """reload_env() removes known Hermes vars not present in .env.""" env_file = tmp_path / ".env" env_file.write_text("") # empty .env # Pick a known key from OPTIONAL_ENV_VARS known_key = next(iter(OPTIONAL_ENV_VARS.keys())) with patch("hermes_cli.config.get_env_path", return_value=env_file): os.environ[known_key] = "stale_value" count = reload_env() assert known_key not in os.environ assert count >= 1 def test_does_not_remove_unknown_vars(self, tmp_path): """reload_env() preserves non-Hermes env vars even when absent from .env.""" env_file = tmp_path / ".env" env_file.write_text("") with patch("hermes_cli.config.get_env_path", return_value=env_file): os.environ["MY_CUSTOM_UNRELATED_VAR"] = "keep_me" reload_env() assert os.environ.get("MY_CUSTOM_UNRELATED_VAR") == "keep_me" os.environ.pop("MY_CUSTOM_UNRELATED_VAR", None) # --------------------------------------------------------------------------- # redact_key tests # --------------------------------------------------------------------------- class TestRedactKey: def test_long_key_shows_prefix_suffix(self): result = redact_key("sk-1234567890abcdef") assert result.startswith("sk-1") assert result.endswith("cdef") assert "..." in result def test_short_key_fully_masked(self): assert redact_key("short") == "***" def test_empty_key(self): result = redact_key("") assert "not set" in result.lower() or result == "***" or "\x1b" in result # --------------------------------------------------------------------------- # web_server tests (FastAPI endpoints) # --------------------------------------------------------------------------- class TestWebServerEndpoints: """Test the FastAPI REST endpoints using Starlette TestClient.""" @pytest.fixture(autouse=True) def _setup_test_client(self): """Create a TestClient — import is deferred to avoid requiring fastapi.""" try: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") from hermes_cli.web_server import app self.client = TestClient(app) def test_get_status(self): resp = self.client.get("/api/status") assert resp.status_code == 200 data = resp.json() assert "version" in data assert "hermes_home" in data assert "active_sessions" in data def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config import hermes_cli.web_server as web_server class _Platform: def __init__(self, value): self.value = value class _GatewayConfig: def get_connected_platforms(self): return [_Platform("telegram")] monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234) monkeypatch.setattr( web_server, "read_runtime_status", lambda: { "gateway_state": "running", "updated_at": "2026-04-12T00:00:00+00:00", "platforms": { "telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, "whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"}, "feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, }, }, ) monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig()) resp = self.client.get("/api/status") assert resp.status_code == 200 assert resp.json()["gateway_platforms"] == { "telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, } def test_get_status_hides_stale_platforms_when_gateway_not_running(self, monkeypatch): import gateway.config as gateway_config import hermes_cli.web_server as web_server class _GatewayConfig: def get_connected_platforms(self): return [] monkeypatch.setattr(web_server, "get_running_pid", lambda: None) monkeypatch.setattr( web_server, "read_runtime_status", lambda: { "gateway_state": "startup_failed", "updated_at": "2026-04-12T00:00:00+00:00", "platforms": { "whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"}, "feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, }, }, ) monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig()) resp = self.client.get("/api/status") assert resp.status_code == 200 assert resp.json()["gateway_state"] == "startup_failed" assert resp.json()["gateway_platforms"] == {} def test_get_config_schema(self): resp = self.client.get("/api/config/schema") assert resp.status_code == 200 data = resp.json() assert "fields" in data assert "category_order" in data schema = data["fields"] assert len(schema) > 100 # Should have 150+ fields assert "model" in schema # Verify category_order is a non-empty list assert isinstance(data["category_order"], list) assert len(data["category_order"]) > 0 assert "general" in data["category_order"] def test_get_config_defaults(self): resp = self.client.get("/api/config/defaults") assert resp.status_code == 200 defaults = resp.json() assert "model" in defaults def test_get_env_vars(self): resp = self.client.get("/api/env") assert resp.status_code == 200 data = resp.json() # Should contain known env var names assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys()) def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value from hermes_cli.web_server import _SESSION_TOKEN save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345") resp = self.client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_KEY"}, headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, ) assert resp.status_code == 200 data = resp.json() assert data["key"] == "TEST_REVEAL_KEY" assert data["value"] == "super-secret-value-12345" def test_reveal_env_var_not_found(self): """POST /api/env/reveal should 404 for unknown keys.""" from hermes_cli.web_server import _SESSION_TOKEN resp = self.client.post( "/api/env/reveal", json={"key": "NONEXISTENT_KEY_XYZ"}, headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, ) assert resp.status_code == 404 def test_reveal_env_var_no_token(self, tmp_path): """POST /api/env/reveal without token should return 401.""" from hermes_cli.config import save_env_value save_env_value("TEST_REVEAL_NOAUTH", "secret-value") resp = self.client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_NOAUTH"}, ) assert resp.status_code == 401 def test_reveal_env_var_bad_token(self, tmp_path): """POST /api/env/reveal with wrong token should return 401.""" from hermes_cli.config import save_env_value save_env_value("TEST_REVEAL_BADAUTH", "secret-value") resp = self.client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_BADAUTH"}, headers={"Authorization": "Bearer wrong-token-here"}, ) assert resp.status_code == 401 def test_session_token_endpoint(self): """GET /api/auth/session-token should return a token.""" from hermes_cli.web_server import _SESSION_TOKEN resp = self.client.get("/api/auth/session-token") assert resp.status_code == 200 assert resp.json()["token"] == _SESSION_TOKEN def test_path_traversal_blocked(self): """Verify URL-encoded path traversal is blocked.""" # %2e%2e = .. resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd") # Should return 200 with index.html (SPA fallback), not the actual file assert resp.status_code in (200, 404) if resp.status_code == 200: # Should be the SPA fallback, not the system file assert "root:" not in resp.text def test_path_traversal_dotdot_blocked(self): """Direct .. path traversal via encoded sequences.""" resp = self.client.get("/%2e%2e/hermes_cli/web_server.py") assert resp.status_code in (200, 404) if resp.status_code == 200: assert "FastAPI" not in resp.text # Should not serve the actual source # --------------------------------------------------------------------------- # _build_schema_from_config tests # --------------------------------------------------------------------------- class TestBuildSchemaFromConfig: def test_produces_expected_field_count(self): from hermes_cli.web_server import CONFIG_SCHEMA # DEFAULT_CONFIG has ~150+ leaf fields assert len(CONFIG_SCHEMA) > 100 def test_schema_entries_have_required_fields(self): from hermes_cli.web_server import CONFIG_SCHEMA for key, entry in list(CONFIG_SCHEMA.items())[:10]: assert "type" in entry, f"Missing type for {key}" assert "category" in entry, f"Missing category for {key}" def test_overrides_applied(self): from hermes_cli.web_server import CONFIG_SCHEMA # terminal.backend should be a select with options if "terminal.backend" in CONFIG_SCHEMA: entry = CONFIG_SCHEMA["terminal.backend"] assert entry["type"] == "select" assert "options" in entry assert "local" in entry["options"] def test_empty_prefix_produces_correct_keys(self): from hermes_cli.web_server import _build_schema_from_config test_config = {"model": "test", "nested": {"key": "val"}} schema = _build_schema_from_config(test_config) assert "model" in schema assert "nested.key" in schema def test_top_level_scalars_get_general_category(self): """Top-level scalar fields should be in 'general' category.""" from hermes_cli.web_server import CONFIG_SCHEMA assert CONFIG_SCHEMA["model"]["category"] == "general" def test_nested_keys_get_parent_category(self): """Nested fields should use the top-level parent as their category.""" from hermes_cli.web_server import CONFIG_SCHEMA if "agent.max_turns" in CONFIG_SCHEMA: assert CONFIG_SCHEMA["agent.max_turns"]["category"] == "agent" def test_category_merge_applied(self): """Small categories should be merged into larger ones.""" from hermes_cli.web_server import CONFIG_SCHEMA categories = {e["category"] for e in CONFIG_SCHEMA.values()} # These should be merged away assert "privacy" not in categories # merged into security assert "context" not in categories # merged into agent def test_no_single_field_categories(self): """After merging, no category should have just 1 field.""" from hermes_cli.web_server import CONFIG_SCHEMA from collections import Counter cats = Counter(e["category"] for e in CONFIG_SCHEMA.values()) for cat, count in cats.items(): assert count >= 2, f"Category '{cat}' has only {count} field(s) — should be merged" # --------------------------------------------------------------------------- # Config round-trip tests # --------------------------------------------------------------------------- class TestConfigRoundTrip: """Verify config survives GET → edit → PUT without data loss.""" @pytest.fixture(autouse=True) def _setup(self): try: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") from hermes_cli.web_server import app self.client = TestClient(app) def test_get_config_no_internal_keys(self): """GET /api/config should not expose _config_version or _model_meta.""" config = self.client.get("/api/config").json() internal = [k for k in config if k.startswith("_")] assert not internal, f"Internal keys leaked to frontend: {internal}" def test_get_config_model_is_string(self): """GET /api/config should normalize model dict to a string.""" config = self.client.get("/api/config").json() assert isinstance(config.get("model"), str), \ f"model should be string, got {type(config.get('model'))}" def test_round_trip_preserves_model_subkeys(self): """Save and reload should not lose model.provider, model.base_url, etc.""" from hermes_cli.config import load_config, save_config # Set up a config with model as a dict (the common user config form) save_config({ "model": { "default": "anthropic/claude-sonnet-4", "provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_mode": "openai", } }) before = load_config() assert isinstance(before.get("model"), dict) original_keys = set(before["model"].keys()) # GET → PUT unchanged web_config = self.client.get("/api/config").json() assert isinstance(web_config.get("model"), str), "GET should normalize model to string" self.client.put("/api/config", json={"config": web_config}) after = load_config() assert isinstance(after.get("model"), dict), "model should still be a dict after save" assert set(after["model"].keys()) >= original_keys, \ f"Lost model subkeys: {original_keys - set(after['model'].keys())}" def test_edit_model_name_preserved(self): """Changing the model string should update model.default on disk.""" from hermes_cli.config import load_config web_config = self.client.get("/api/config").json() original_model = web_config["model"] # Change model web_config["model"] = "test/editing-model" self.client.put("/api/config", json={"config": web_config}) after = load_config() if isinstance(after.get("model"), dict): assert after["model"]["default"] == "test/editing-model" else: assert after["model"] == "test/editing-model" # Restore web_config["model"] = original_model self.client.put("/api/config", json={"config": web_config}) def test_edit_nested_value(self): """Editing a nested config value should persist correctly.""" from hermes_cli.config import load_config web_config = self.client.get("/api/config").json() original_turns = web_config.get("agent", {}).get("max_turns") # Change max_turns if "agent" not in web_config: web_config["agent"] = {} web_config["agent"]["max_turns"] = 42 self.client.put("/api/config", json={"config": web_config}) after = load_config() assert after.get("agent", {}).get("max_turns") == 42 # Restore web_config["agent"]["max_turns"] = original_turns self.client.put("/api/config", json={"config": web_config}) def test_schema_types_match_config_values(self): """Every schema field should have a matching-type value in the config.""" config = self.client.get("/api/config").json() schema_resp = self.client.get("/api/config/schema").json() schema = schema_resp["fields"] def get_nested(obj, path): parts = path.split(".") cur = obj for p in parts: if cur is None or not isinstance(cur, dict): return None cur = cur.get(p) return cur mismatches = [] for key, entry in schema.items(): val = get_nested(config, key) if val is None: continue # not set in user config — fine expected = entry["type"] if expected in ("string", "select") and not isinstance(val, str): mismatches.append(f"{key}: expected str, got {type(val).__name__}") elif expected == "number" and not isinstance(val, (int, float)): mismatches.append(f"{key}: expected number, got {type(val).__name__}") elif expected == "boolean" and not isinstance(val, bool): mismatches.append(f"{key}: expected bool, got {type(val).__name__}") elif expected == "list" and not isinstance(val, list): mismatches.append(f"{key}: expected list, got {type(val).__name__}") assert not mismatches, f"Type mismatches:\n" + "\n".join(mismatches) # --------------------------------------------------------------------------- # New feature endpoint tests # --------------------------------------------------------------------------- class TestNewEndpoints: """Tests for session detail, logs, cron, skills, tools, raw config, analytics.""" @pytest.fixture(autouse=True) def _setup(self): try: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") from hermes_cli.web_server import app self.client = TestClient(app) def test_get_logs_default(self): resp = self.client.get("/api/logs") assert resp.status_code == 200 data = resp.json() assert "file" in data assert "lines" in data assert isinstance(data["lines"], list) def test_get_logs_invalid_file(self): resp = self.client.get("/api/logs?file=nonexistent") assert resp.status_code == 400 def test_cron_list(self): resp = self.client.get("/api/cron/jobs") assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_cron_job_not_found(self): resp = self.client.get("/api/cron/jobs/nonexistent-id") assert resp.status_code == 404 def test_skills_list(self): resp = self.client.get("/api/skills") assert resp.status_code == 200 skills = resp.json() assert isinstance(skills, list) if skills: assert "name" in skills[0] assert "enabled" in skills[0] def test_skills_list_includes_disabled_skills(self, monkeypatch): import tools.skills_tool as skills_tool import hermes_cli.skills_config as skills_config import hermes_cli.web_server as web_server def _fake_find_all_skills(*, skip_disabled=False): if skip_disabled: return [ {"name": "active-skill", "description": "active", "category": "demo"}, {"name": "disabled-skill", "description": "disabled", "category": "demo"}, ] return [ {"name": "active-skill", "description": "active", "category": "demo"}, ] monkeypatch.setattr(skills_tool, "_find_all_skills", _fake_find_all_skills) monkeypatch.setattr(skills_config, "get_disabled_skills", lambda config: {"disabled-skill"}) monkeypatch.setattr(web_server, "load_config", lambda: {"skills": {"disabled": ["disabled-skill"]}}) resp = self.client.get("/api/skills") assert resp.status_code == 200 assert resp.json() == [ { "name": "active-skill", "description": "active", "category": "demo", "enabled": True, }, { "name": "disabled-skill", "description": "disabled", "category": "demo", "enabled": False, }, ] def test_toolsets_list(self): resp = self.client.get("/api/tools/toolsets") assert resp.status_code == 200 toolsets = resp.json() assert isinstance(toolsets, list) if toolsets: assert "name" in toolsets[0] assert "label" in toolsets[0] assert "enabled" in toolsets[0] def test_toolsets_list_matches_cli_enabled_state(self, monkeypatch): import hermes_cli.tools_config as tools_config import toolsets as toolsets_module import hermes_cli.web_server as web_server monkeypatch.setattr( tools_config, "_get_effective_configurable_toolsets", lambda: [ ("web", "🔍 Web Search & Scraping", "web_search, web_extract"), ("skills", "📚 Skills", "list, view, manage"), ("memory", "💾 Memory", "persistent memory across sessions"), ], ) monkeypatch.setattr( tools_config, "_get_platform_tools", lambda config, platform, include_default_mcp_servers=False: {"web", "skills"}, ) monkeypatch.setattr( tools_config, "_toolset_has_keys", lambda ts_key, config=None: ts_key != "web", ) monkeypatch.setattr( toolsets_module, "resolve_toolset", lambda name: { "web": ["web_search", "web_extract"], "skills": ["skills_list", "skill_view"], "memory": ["memory_read"], }[name], ) monkeypatch.setattr(web_server, "load_config", lambda: {"platform_toolsets": {"cli": ["web", "skills"]}}) resp = self.client.get("/api/tools/toolsets") assert resp.status_code == 200 assert resp.json() == [ { "name": "web", "label": "🔍 Web Search & Scraping", "description": "web_search, web_extract", "enabled": True, "available": True, "configured": False, "tools": ["web_extract", "web_search"], }, { "name": "skills", "label": "📚 Skills", "description": "list, view, manage", "enabled": True, "available": True, "configured": True, "tools": ["skill_view", "skills_list"], }, { "name": "memory", "label": "💾 Memory", "description": "persistent memory across sessions", "enabled": False, "available": False, "configured": True, "tools": ["memory_read"], }, ] def test_config_raw_get(self): resp = self.client.get("/api/config/raw") assert resp.status_code == 200 assert "yaml" in resp.json() def test_config_raw_put_valid(self): resp = self.client.put( "/api/config/raw", json={"yaml_text": "model: test\ntoolsets:\n - all\n"}, ) assert resp.status_code == 200 assert resp.json()["ok"] is True def test_config_raw_put_invalid(self): resp = self.client.put( "/api/config/raw", json={"yaml_text": "- this is a list not a dict"}, ) assert resp.status_code == 400 def test_analytics_usage(self): resp = self.client.get("/api/analytics/usage?days=7") assert resp.status_code == 200 data = resp.json() assert "daily" in data assert "by_model" in data assert "totals" in data assert isinstance(data["daily"], list) assert "total_sessions" in data["totals"] def test_session_token_endpoint(self): from hermes_cli.web_server import _SESSION_TOKEN resp = self.client.get("/api/auth/session-token") assert resp.status_code == 200 assert resp.json()["token"] == _SESSION_TOKEN