ba030bc0db
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a sibling module and the production path reads the sibling's binding. Where production reads through BOTH bindings the setattr is duplicated onto the defining module (import added next to the existing alias import); where only the sibling reads it the target is repointed. Seams whose production readers go through the facade are left alone.
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
|
|
|
|
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
|
|
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
|
|
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
|
|
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
|
|
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
|
|
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
|
|
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
|
|
ones (e.g. 429 quota, where the stored token is still valid).
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.auth as auth
|
|
import hermes_cli.auth_codex as auth_codex
|
|
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
|
|
|
|
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
|
|
|
|
|
|
def test_self_heals_on_stale_refresh_token(monkeypatch):
|
|
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
|
|
saved = {}
|
|
fresh = {
|
|
"access_token": "fresh-access",
|
|
"refresh_token": "fresh-refresh",
|
|
"last_refresh": "2026-06-12T00:00:00Z",
|
|
}
|
|
|
|
def _rejected(*_a, **_k):
|
|
raise AuthError(
|
|
"refresh token rejected",
|
|
provider="openai-codex",
|
|
code="invalid_grant",
|
|
relogin_required=True,
|
|
)
|
|
|
|
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
|
monkeypatch.setattr(auth_codex, "refresh_codex_oauth_pure", _rejected)
|
|
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
|
|
monkeypatch.setattr(auth_codex, "_import_codex_cli_tokens", lambda: dict(fresh))
|
|
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
|
monkeypatch.setattr(auth_codex, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
|
|
|
out = _refresh_codex_auth_tokens(STALE, 20.0)
|
|
|
|
assert out["access_token"] == "fresh-access"
|
|
assert out["refresh_token"] == "fresh-refresh"
|
|
# the recovered token was persisted to the Hermes auth store
|
|
assert saved["access_token"] == "fresh-access"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
|
|
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
|
|
hermes_home = tmp_path / "hermes"
|
|
codex_home = tmp_path / "codex"
|
|
hermes_home.mkdir()
|
|
codex_home.mkdir()
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"refresh_token": "stale-refresh"},
|
|
"last_refresh": "2026-06-01T00:00:00Z",
|
|
"auth_mode": "chatgpt",
|
|
},
|
|
},
|
|
}))
|
|
(codex_home / "auth.json").write_text(json.dumps({
|
|
"tokens": {
|
|
"access_token": "fresh-access",
|
|
"refresh_token": "fresh-refresh",
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
|
|
|
resolved = resolve_codex_runtime_credentials()
|
|
|
|
assert resolved["api_key"] == "fresh-access"
|
|
assert resolved["source"] == "hermes-auth-store"
|
|
stored = json.loads((hermes_home / "auth.json").read_text())
|
|
tokens = stored["providers"]["openai-codex"]["tokens"]
|
|
assert tokens["access_token"] == "fresh-access"
|
|
assert tokens["refresh_token"] == "fresh-refresh"
|
|
|
|
|