Files
hermes-webui/tests/test_issue4662_reload_config_mtime_cache.py
T
nesquena-hermes 50f7f7fcd3 test(#4741): add two #798 isolation regression guards (Opus gate hardening)
- env-change-with-unchanged-mtime must re-expand (proves the cache stores raw
  un-expanded YAML, not the expanded result -> no stale cross-profile expansion)
- no-cross-serve between two profile config paths (proves the parse cache is
  path-keyed). Both pass on the current impl; locks down the highest-risk
  cross-profile credential-bleed invariant per Opus's non-blocking note.
2026-06-23 17:13:22 +00:00

187 lines
7.8 KiB
Python

"""Phase 2 (#4662): reload_config() must not re-run yaml.safe_load on the hot path
when config.yaml is unchanged. It now routes its parse through the mtime-keyed
_load_yaml_config_file cache (#4652). Behavior-preserving: the process-global
_cfg_cache is still pinned to the unscoped process-env expansion (#798 TLS), env
expansion still runs per call, and an mtime change still busts the cache.
"""
import os
import time
import yaml as _yaml
def test_reload_config_uses_mtime_cache(tmp_path, monkeypatch):
import api.config as cfg
config_path = tmp_path / "config.yaml"
config_path.write_text("providers:\n openai:\n models: [gpt-5.5]\n", encoding="utf-8")
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
parse_calls = {"n": 0}
real_safe_load = _yaml.safe_load
def _counting_safe_load(s):
parse_calls["n"] += 1
return real_safe_load(s)
monkeypatch.setattr(_yaml, "safe_load", _counting_safe_load)
# Clear the shared file cache so the first reload is a genuine miss.
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
cfg.reload_config()
first = parse_calls["n"]
cfg.reload_config() # same file, unchanged mtime -> must hit cache
second = parse_calls["n"]
assert first >= 1, "first reload should parse the file at least once"
assert second == first, f"unchanged config.yaml was reparsed (parse went {first}->{second})"
def test_reload_config_busts_on_mtime_change(tmp_path, monkeypatch):
import api.config as cfg
config_path = tmp_path / "config.yaml"
config_path.write_text("providers: {}\n", encoding="utf-8")
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
cfg.reload_config()
assert (cfg.get_config().get("providers") or {}) == {}
# Edit + bump mtime; the next reload must pick up the change, not the cache.
time.sleep(0.01)
config_path.write_text("providers:\n openai: {}\n", encoding="utf-8")
os.utime(config_path, None)
cfg.reload_config()
assert "openai" in (cfg.get_config().get("providers") or {}), "mtime change not picked up"
def test_reload_config_expands_env_vars(tmp_path, monkeypatch):
"""The pinned process-env expansion must still run: a ${VAR} in config.yaml
resolves against os.environ after reload_config (the #798 invariant)."""
import api.config as cfg
monkeypatch.setenv("HERMES_TEST_RELOAD_TOKEN", "expanded-value-xyz")
config_path = tmp_path / "config.yaml"
config_path.write_text(
"providers:\n openai:\n api_key: ${HERMES_TEST_RELOAD_TOKEN}\n",
encoding="utf-8",
)
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
cfg.reload_config()
key = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
assert key == "expanded-value-xyz", f"env var not expanded after reload: {key!r}"
def test_reload_config_reexpands_env_when_mtime_unchanged(tmp_path, monkeypatch):
"""#798 hardening (Opus gate): the mtime cache must store the RAW, un-expanded
YAML — not the expanded result — so that a ${VAR} re-expands against the CURRENT
os.environ on every reload even when config.yaml's mtime is unchanged. If the
cache stored the expanded value, a profile/env change with an unchanged file
would serve a stale expansion (cross-profile credential bleed)."""
import api.config as cfg
config_path = tmp_path / "config.yaml"
config_path.write_text(
"providers:\n openai:\n api_key: ${HERMES_TEST_REEXPAND_TOKEN}\n",
encoding="utf-8",
)
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
# First reload under env value A.
monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-A")
cfg.reload_config()
key_a = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
assert key_a == "value-A", f"first expansion wrong: {key_a!r}"
# Change ONLY the env var — the file (and its mtime) is untouched, so the YAML
# parse cache will hit. The expansion must still pick up the new env value.
monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-B")
cfg.reload_config()
key_b = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key")
assert key_b == "value-B", (
f"env change with unchanged mtime did not re-expand (got {key_b!r}); "
"the mtime cache is storing the EXPANDED value instead of raw YAML "
"-> cross-profile credential-bleed risk (#798)"
)
def test_reload_config_does_not_cross_serve_between_profile_paths(tmp_path, monkeypatch):
"""#798 hardening (Opus gate): the YAML parse cache is keyed on the config PATH
(plus mtime+size), so two different profiles' config.yaml files must never serve
each other's parsed content — even within the same process. A path-blind cache
would leak one profile's providers/keys into another."""
import api.config as cfg
path_a = tmp_path / "profile_a" / "config.yaml"
path_b = tmp_path / "profile_b" / "config.yaml"
path_a.parent.mkdir(parents=True, exist_ok=True)
path_b.parent.mkdir(parents=True, exist_ok=True)
path_a.write_text("providers:\n openai:\n models: [model-a]\n", encoding="utf-8")
path_b.write_text("providers:\n anthropic:\n models: [model-b]\n", encoding="utf-8")
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
monkeypatch.setattr(cfg, "_get_config_path", lambda: path_a)
cfg.reload_config()
providers_a = cfg.get_config().get("providers") or {}
assert "openai" in providers_a and "anthropic" not in providers_a, (
f"profile A served the wrong config: {sorted(providers_a)}"
)
monkeypatch.setattr(cfg, "_get_config_path", lambda: path_b)
cfg.reload_config()
providers_b = cfg.get_config().get("providers") or {}
assert "anthropic" in providers_b and "openai" not in providers_b, (
f"profile B was cross-served profile A's config (cache not path-keyed): "
f"{sorted(providers_b)}"
)
def test_reload_config_empty_dict_config_does_not_spin(tmp_path, monkeypatch):
"""An empty ``{}`` config must still stamp _cfg_mtime, or get_config()'s
`current_mtime != _cfg_mtime` stale check fires forever and re-enters
reload_config() under _cfg_lock on every call. The cache-update is correctly
skipped for {} (no-op), but the mtime stamp must not be. (Opus gate finding —
a {} config is reachable on the profile-switch hot path via a freshly
created/reset profile, and this also fixes the pre-existing empty/None-config
spin on master.)
"""
import api.config as cfg
config_path = tmp_path / "config.yaml"
config_path.write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
with cfg._yaml_file_cache_lock:
cfg._yaml_file_cache.clear()
cfg.reload_config()
# _cfg_mtime must equal the file's real mtime, not 0.0.
assert cfg._cfg_mtime == config_path.stat().st_mtime, (
f"_cfg_mtime not stamped for empty-dict config (got {cfg._cfg_mtime!r}); "
"get_config() would spin reload_config() forever"
)
# And get_config() must NOT re-enter reload_config() on subsequent calls.
reloads = {"n": 0}
real_reload = cfg.reload_config
def _counting_reload():
reloads["n"] += 1
return real_reload()
monkeypatch.setattr(cfg, "reload_config", _counting_reload)
cfg.get_config()
cfg.get_config()
cfg.get_config()
assert reloads["n"] == 0, f"get_config() re-entered reload_config() {reloads['n']}x on a stable {{}} config (spin)"