|
| 1 | +"""Tests for Honcho config profile isolation. |
| 2 | +
|
| 3 | +Verifies that each Hermes profile writes to its own instance-local |
| 4 | +honcho.json ($HERMES_HOME/honcho.json) rather than the shared global |
| 5 | +~/.honcho/config.json. |
| 6 | +""" |
| 7 | + |
| 8 | +import json |
| 9 | +import os |
| 10 | +from pathlib import Path |
| 11 | +from unittest.mock import patch |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from honcho_integration.cli import ( |
| 16 | + _config_path, |
| 17 | + _local_config_path, |
| 18 | + _read_config, |
| 19 | + _write_config, |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +@pytest.fixture |
| 24 | +def isolated_home(tmp_path, monkeypatch): |
| 25 | + """Create an isolated HERMES_HOME + real home for testing.""" |
| 26 | + hermes_home = tmp_path / "profile_a" |
| 27 | + hermes_home.mkdir() |
| 28 | + global_dir = tmp_path / "home" / ".honcho" |
| 29 | + global_dir.mkdir(parents=True) |
| 30 | + global_config = global_dir / "config.json" |
| 31 | + |
| 32 | + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) |
| 33 | + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home")) |
| 34 | + # GLOBAL_CONFIG_PATH is a module-level constant cached at import time, |
| 35 | + # so we must patch it in both the defining module and the importing module. |
| 36 | + import honcho_integration.client as _client_mod |
| 37 | + import honcho_integration.cli as _cli_mod |
| 38 | + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config) |
| 39 | + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config) |
| 40 | + |
| 41 | + return { |
| 42 | + "hermes_home": hermes_home, |
| 43 | + "global_config": global_config, |
| 44 | + "local_config": hermes_home / "honcho.json", |
| 45 | + } |
| 46 | + |
| 47 | + |
| 48 | +class TestLocalConfigPath: |
| 49 | + """_local_config_path always returns $HERMES_HOME/honcho.json.""" |
| 50 | + |
| 51 | + def test_returns_hermes_home_path(self, isolated_home): |
| 52 | + assert _local_config_path() == isolated_home["local_config"] |
| 53 | + |
| 54 | + def test_differs_from_global(self, isolated_home): |
| 55 | + from honcho_integration.client import GLOBAL_CONFIG_PATH |
| 56 | + assert _local_config_path() != GLOBAL_CONFIG_PATH |
| 57 | + |
| 58 | + |
| 59 | +class TestWriteConfigIsolation: |
| 60 | + """_write_config defaults to the instance-local path.""" |
| 61 | + |
| 62 | + def test_write_creates_local_file(self, isolated_home): |
| 63 | + cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}} |
| 64 | + _write_config(cfg) |
| 65 | + |
| 66 | + assert isolated_home["local_config"].exists() |
| 67 | + written = json.loads(isolated_home["local_config"].read_text()) |
| 68 | + assert written["apiKey"] == "test-key" |
| 69 | + |
| 70 | + def test_write_does_not_touch_global(self, isolated_home): |
| 71 | + # Pre-populate global config |
| 72 | + isolated_home["global_config"].write_text( |
| 73 | + json.dumps({"apiKey": "global-key"}) |
| 74 | + ) |
| 75 | + |
| 76 | + cfg = {"apiKey": "profile-key"} |
| 77 | + _write_config(cfg) |
| 78 | + |
| 79 | + # Global should be untouched |
| 80 | + global_data = json.loads(isolated_home["global_config"].read_text()) |
| 81 | + assert global_data["apiKey"] == "global-key" |
| 82 | + |
| 83 | + # Local should have the new value |
| 84 | + local_data = json.loads(isolated_home["local_config"].read_text()) |
| 85 | + assert local_data["apiKey"] == "profile-key" |
| 86 | + |
| 87 | + def test_explicit_path_override_still_works(self, isolated_home): |
| 88 | + custom = isolated_home["hermes_home"] / "custom.json" |
| 89 | + _write_config({"custom": True}, path=custom) |
| 90 | + assert custom.exists() |
| 91 | + assert not isolated_home["local_config"].exists() |
| 92 | + |
| 93 | + |
| 94 | +class TestReadConfigFallback: |
| 95 | + """_read_config falls back to global when no local file exists.""" |
| 96 | + |
| 97 | + def test_reads_local_when_exists(self, isolated_home): |
| 98 | + isolated_home["local_config"].write_text( |
| 99 | + json.dumps({"source": "local"}) |
| 100 | + ) |
| 101 | + cfg = _read_config() |
| 102 | + assert cfg["source"] == "local" |
| 103 | + |
| 104 | + def test_falls_back_to_global(self, isolated_home): |
| 105 | + isolated_home["global_config"].write_text( |
| 106 | + json.dumps({"source": "global"}) |
| 107 | + ) |
| 108 | + # No local file exists |
| 109 | + assert not isolated_home["local_config"].exists() |
| 110 | + cfg = _read_config() |
| 111 | + assert cfg["source"] == "global" |
| 112 | + |
| 113 | + def test_local_takes_priority_over_global(self, isolated_home): |
| 114 | + isolated_home["local_config"].write_text( |
| 115 | + json.dumps({"source": "local"}) |
| 116 | + ) |
| 117 | + isolated_home["global_config"].write_text( |
| 118 | + json.dumps({"source": "global"}) |
| 119 | + ) |
| 120 | + cfg = _read_config() |
| 121 | + assert cfg["source"] == "local" |
| 122 | + |
| 123 | + |
| 124 | +class TestMultiProfileIsolation: |
| 125 | + """Two profiles writing config don't interfere with each other.""" |
| 126 | + |
| 127 | + def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch): |
| 128 | + home = tmp_path / "home" |
| 129 | + home.mkdir() |
| 130 | + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) |
| 131 | + |
| 132 | + profile_a = tmp_path / "profile_a" |
| 133 | + profile_b = tmp_path / "profile_b" |
| 134 | + profile_a.mkdir() |
| 135 | + profile_b.mkdir() |
| 136 | + |
| 137 | + # Profile A writes its config |
| 138 | + monkeypatch.setenv("HERMES_HOME", str(profile_a)) |
| 139 | + _write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}}) |
| 140 | + |
| 141 | + # Profile B writes its config |
| 142 | + monkeypatch.setenv("HERMES_HOME", str(profile_b)) |
| 143 | + _write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}}) |
| 144 | + |
| 145 | + # Verify isolation |
| 146 | + a_data = json.loads((profile_a / "honcho.json").read_text()) |
| 147 | + b_data = json.loads((profile_b / "honcho.json").read_text()) |
| 148 | + |
| 149 | + assert a_data["hosts"]["hermes"]["peerName"] == "alice" |
| 150 | + assert b_data["hosts"]["hermes"]["peerName"] == "bob" |
| 151 | + |
| 152 | + def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch): |
| 153 | + """First setup reads global config, writes to local.""" |
| 154 | + home = tmp_path / "home" |
| 155 | + global_dir = home / ".honcho" |
| 156 | + global_dir.mkdir(parents=True) |
| 157 | + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) |
| 158 | + import honcho_integration.client as _client_mod |
| 159 | + import honcho_integration.cli as _cli_mod |
| 160 | + global_cfg_path = global_dir / "config.json" |
| 161 | + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) |
| 162 | + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) |
| 163 | + |
| 164 | + # Existing global config |
| 165 | + global_config = global_dir / "config.json" |
| 166 | + global_config.write_text(json.dumps({ |
| 167 | + "apiKey": "shared-key", |
| 168 | + "hosts": {"hermes": {"workspace": "shared-ws"}}, |
| 169 | + })) |
| 170 | + |
| 171 | + profile = tmp_path / "new_profile" |
| 172 | + profile.mkdir() |
| 173 | + monkeypatch.setenv("HERMES_HOME", str(profile)) |
| 174 | + |
| 175 | + # Read seeds from global |
| 176 | + cfg = _read_config() |
| 177 | + assert cfg["apiKey"] == "shared-key" |
| 178 | + |
| 179 | + # Modify and write goes to local |
| 180 | + cfg["hosts"]["hermes"]["peerName"] = "new-user" |
| 181 | + _write_config(cfg) |
| 182 | + |
| 183 | + local_config = profile / "honcho.json" |
| 184 | + assert local_config.exists() |
| 185 | + local_data = json.loads(local_config.read_text()) |
| 186 | + assert local_data["hosts"]["hermes"]["peerName"] == "new-user" |
| 187 | + |
| 188 | + # Global unchanged |
| 189 | + global_data = json.loads(global_config.read_text()) |
| 190 | + assert "peerName" not in global_data["hosts"]["hermes"] |
0 commit comments