hermes-agent/tests/hermes_cli/test_nous_auth_status_cache.py
Gille 0441b7f19f
fix(desktop): route global remote profile REST calls (#47011)
* fix(desktop): route global remote profile REST calls

* fix(dashboard): scope oauth provider routes by profile

* test(tui): isolate notification poller queue
2026-06-15 23:24:55 -05:00

181 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the get_nous_auth_status() process-level cache.
The cache avoids re-validating Nous credentials on every menu paint —
`hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs
against portal.nousresearch.com during one render. The cache is keyed
on auth.json path + mtime so profile switches stay isolated while
login/logout flows invalidate naturally; tests and other writers can
also call invalidate_nous_auth_status_cache().
"""
from __future__ import annotations
import json
import os
from unittest.mock import patch
def _seed_auth_file(tmp_path):
"""Drop a placeholder auth.json into the test HERMES_HOME.
The exact content doesn't matter for cache-key purposes — only that
the file exists and we can mutate it to bump mtime.
"""
auth = tmp_path / "auth.json"
auth.write_text(json.dumps({"providers": {}}), encoding="utf-8")
return auth
def test_get_nous_auth_status_caches_consecutive_calls(tmp_path, monkeypatch):
"""A second call within the TTL skips re-computing the snapshot."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_seed_auth_file(tmp_path)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
def fake_compute():
call_count["n"] += 1
return {"logged_in": False, "source": "auth_store", "call": call_count["n"]}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
first = auth_mod.get_nous_auth_status()
second = auth_mod.get_nous_auth_status()
third = auth_mod.get_nous_auth_status()
assert call_count["n"] == 1, (
f"_compute_nous_auth_status was called {call_count['n']}×"
"cache is not deduplicating within TTL."
)
# Each call returns a copy so callers can't mutate the cached dict.
assert first == second == third
first["mutated"] = True
assert "mutated" not in auth_mod.get_nous_auth_status()
auth_mod.invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypatch):
"""Touching auth.json (login/logout) forces a re-compute."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
auth_path = _seed_auth_file(tmp_path)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
def fake_compute():
call_count["n"] += 1
return {"logged_in": False, "source": "auth_store", "call": call_count["n"]}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
auth_mod.get_nous_auth_status()
# Bump mtime forward so coarse-resolution filesystems still record
# a change.
future = auth_path.stat().st_mtime + 5.0
os.utime(auth_path, (future, future))
auth_mod.get_nous_auth_status()
assert call_count["n"] == 2, (
"auth.json mtime change should invalidate the cache, but only "
f"{call_count['n']} compute call(s) happened."
)
auth_mod.invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_cache_is_scoped_by_auth_file_path(tmp_path, monkeypatch):
"""Two profile homes with missing auth.json must not share cached status."""
profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
seen_auth_files = []
def fake_compute():
call_count["n"] += 1
seen_auth_files.append(auth_mod._auth_file_path())
return {"logged_in": False, "call": call_count["n"]}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
monkeypatch.setenv("HERMES_HOME", str(profile_a))
first = auth_mod.get_nous_auth_status()
monkeypatch.setenv("HERMES_HOME", str(profile_b))
second = auth_mod.get_nous_auth_status()
assert call_count["n"] == 2
assert first["call"] == 1
assert second["call"] == 2
assert seen_auth_files == [
profile_a / "auth.json",
profile_b / "auth.json",
]
auth_mod.invalidate_nous_auth_status_cache()
def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch):
"""Explicit invalidate forces the next call to re-compute."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_seed_auth_file(tmp_path)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
def fake_compute():
call_count["n"] += 1
return {"logged_in": False, "source": "auth_store"}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
auth_mod.get_nous_auth_status()
auth_mod.invalidate_nous_auth_status_cache()
auth_mod.get_nous_auth_status()
assert call_count["n"] == 2
auth_mod.invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_caches_failure_path(tmp_path, monkeypatch):
"""Logged-out snapshots are cached too — that's where the cost was.
Teknium's case: ~31 cache misses per `hermes tools` "All Platforms"
menu paint, all returning logged_in=False after a failed refresh POST.
The whole point of the cache is to memoise that failure path too.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_seed_auth_file(tmp_path)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
def fake_compute():
call_count["n"] += 1
return {"logged_in": False, "source": "auth_store", "error": "refresh failed"}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
for _ in range(10):
auth_mod.get_nous_auth_status()
assert call_count["n"] == 1, (
f"Logged-out snapshots must cache; got {call_count['n']} computes for 10 calls."
)
auth_mod.invalidate_nous_auth_status_cache()