Files
hermes-webui/tests/test_issue_t16551f61_auth_token_churn_fingerprint.py
T
Isla-Liu 20f49e7bda docs(config): address PR #2964 review nits
* Comment alongside _AUTH_FINGERPRINT_VOLATILE_KEYS notes client_secret
  is treated as rotation-only on purpose, not a model-cache
  differentiator (maintainer §Concerns 3).
* _write_auth docstring at tests/test_issue_t16551f61_auth_token_churn_
  fingerprint.py:108 now describes actual behavior (writes + monkeypatch,
  no sleep+restat) — Copilot r3302471228.

Refs #2242. PR #2964 review.
2026-05-27 09:51:32 +08:00

297 lines
12 KiB
Python

"""Regression tests for RCA t_16551f61: auth.json ~14-min token churn must
NOT bust the 24h /api/models cache, but a real provider/model-set change
still must.
Bug shape (chained from RCA t_d127953d residual #2): the models cache has a
24h TTL, but its _source_fingerprint hashed auth.json via mtime/size
(#1699's _models_cache_file_fingerprint). credential-pool / OAuth token
refresh rewrites the WHOLE auth.json roughly every 14 minutes (RCA observed
17:12:51 -> 17:26:31), bumping mtime+size every time. So the fingerprint
mismatched every ~14 min, the 24h cache was effectively dead, and the hot
GET /api/session?resolve_model=1 path paid a cold ~11.5s rebuild over and
over.
Fix: fingerprint auth.json by *content* with the credential-rotation fields
deny-listed (_auth_store_semantic_fingerprint). Token rotation is invisible
to the fingerprint; anything that changes the available provider/model set
still changes it.
These tests are INVARIANTS, not change-detectors:
* churn-immune — rewriting only volatile token fields keeps the
fingerprint byte-identical AND keeps a valid disk cache
loadable.
* real-change-invalidates — active_provider change, a NEW credential_pool
entry, and a changed base_url each flip the fingerprint
AND reject the previously-valid disk cache.
A test that only asserted "fingerprint stable on churn" would pass even if
the function hard-coded `return {}` — hence the paired real-change half and
the explicit assertion that the two fingerprints actually differ on a real
change. Verified non-tautological by reverting the fix (see report.md): the
churn-immune half then fails.
"""
import json
import time
import api.config as config
# ── auth.json builders ────────────────────────────────────────────────────
def _auth_store(*, active_provider="anthropic", extra_pool_provider=None,
anthropic_base_url="https://api.anthropic.com",
access_token="tok-AAAA", request_count=1):
"""A realistic auth.json shaped like the production researcher profile
(version / providers / active_provider / credential_pool / updated_at)."""
pool = {
"anthropic": [
{
"id": "anthropic-oauth-1",
"label": "Claude Max",
"auth_type": "oauth",
"priority": 0,
"source": "claude-oauth",
"access_token": access_token,
"refresh_token": "refresh-" + access_token,
"last_status": "ok",
"last_status_at": None,
"last_error_code": None,
"expires_at_ms": 1779152637000,
"base_url": anthropic_base_url,
"request_count": request_count,
}
],
"openrouter": [
{
"id": "openrouter-key-1",
"label": "OpenRouter",
"auth_type": "api_key",
"priority": 1,
"source": "env",
"access_token": "or-" + access_token,
"last_status": "ok",
"base_url": "https://openrouter.ai/api/v1",
"request_count": request_count,
}
],
}
if extra_pool_provider:
pool[extra_pool_provider] = [
{
"id": f"{extra_pool_provider}-key-1",
"label": extra_pool_provider.title(),
"auth_type": "api_key",
"priority": 2,
"source": "manual",
"access_token": "new-provider-token",
"base_url": f"https://api.{extra_pool_provider}.test/v1",
"request_count": 0,
}
]
return {
"version": 2,
"providers": {},
"active_provider": active_provider,
"credential_pool": pool,
"updated_at": "2026-05-19T01:00:00+00:00",
}
def _write_auth(tmp_path, monkeypatch, store: dict):
"""Point _get_auth_store_path() at a tmp auth.json and write `store`.
Writes `store` as JSON to a tmp auth.json and monkeypatches
config._get_auth_store_path() to return that path. The mtime/size
sleep+restat dance lives at the call sites (the tests that actually
need to prove the OLD stat-based fingerprint WOULD have churned);
this helper itself only does the write + monkeypatch.
"""
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps(store, indent=2), encoding="utf-8")
monkeypatch.setattr(config, "_get_auth_store_path", lambda: auth_path)
return auth_path
# ── churn-immune invariant ─────────────────────────────────────────────────
def test_token_only_churn_keeps_auth_fingerprint_identical(tmp_path, monkeypatch):
"""Rotating ONLY the volatile credential fields (access/refresh token,
expiry, status, request_count, updated_at) must not change the auth.json
fingerprint at all — even though mtime_ns and size both change."""
p = _write_auth(tmp_path, monkeypatch, _auth_store(access_token="tok-AAAA",
request_count=1))
st1 = p.stat()
fp1 = config._auth_store_semantic_fingerprint(p)
time.sleep(0.01)
# Same logical config, fresh tokens + bumped counters + new save ts.
bigger = _auth_store(access_token="tok-BBBBBBBBBBBBBBBBBBBB",
request_count=999)
bigger["updated_at"] = "2026-05-19T01:14:00+00:00"
p.write_text(json.dumps(bigger, indent=2), encoding="utf-8")
st2 = p.stat()
fp2 = config._auth_store_semantic_fingerprint(p)
# Precondition: the OLD stat-based fingerprint WOULD have churned.
assert (st1.st_mtime_ns, st1.st_size) != (st2.st_mtime_ns, st2.st_size)
# Invariant: the content fingerprint does NOT churn.
assert fp1 == fp2
assert "semantic_sha256" in fp1
def test_token_churn_does_not_reject_valid_disk_models_cache(tmp_path, monkeypatch):
"""End-to-end: a disk models cache saved before a token refresh stays
loadable after the refresh (the actual user-visible bug — cold rebuild
every ~14 min)."""
_write_auth(tmp_path, monkeypatch, _auth_store(access_token="tok-1"))
monkeypatch.setattr(config, "_models_cache_path",
tmp_path / "models_cache.json")
shape = {
"active_provider": "anthropic",
"default_model": "claude-opus-4-7",
"configured_model_badges": {"claude-opus-4-7": "Anthropic"},
"groups": [{"name": "Anthropic", "models": ["claude-opus-4-7"]}],
}
config._save_models_cache_to_disk(shape)
assert config._load_models_cache_from_disk() is not None
# ~14 minutes later: credential-pool refresh rewrites auth.json.
time.sleep(0.01)
churned = _auth_store(access_token="tok-2-rotated", request_count=42)
churned["updated_at"] = "2026-05-19T01:14:00+00:00"
config._get_auth_store_path().write_text(
json.dumps(churned, indent=2), encoding="utf-8")
assert config._load_models_cache_from_disk() is not None, (
"Pure token-refresh churn of auth.json must NOT invalidate the 24h "
"models cache (RCA t_16551f61)"
)
# ── real-change-invalidates invariant ──────────────────────────────────────
def test_active_provider_change_changes_auth_fingerprint(tmp_path, monkeypatch):
p = _write_auth(tmp_path, monkeypatch, _auth_store(active_provider="anthropic"))
fp_before = config._auth_store_semantic_fingerprint(p)
p.write_text(json.dumps(_auth_store(active_provider="openrouter"), indent=2),
encoding="utf-8")
fp_after = config._auth_store_semantic_fingerprint(p)
assert fp_before != fp_after, (
"Changing active_provider changes the available-provider set and MUST "
"bust the cache"
)
def test_new_credential_pool_entry_changes_auth_fingerprint(tmp_path, monkeypatch):
"""The explicit edge from the task body: adding a credential_pool entry
that enables a NEW provider MUST still invalidate the cache."""
p = _write_auth(tmp_path, monkeypatch, _auth_store())
fp_before = config._auth_store_semantic_fingerprint(p)
p.write_text(json.dumps(_auth_store(extra_pool_provider="deepseek"),
indent=2), encoding="utf-8")
fp_after = config._auth_store_semantic_fingerprint(p)
assert fp_before != fp_after, (
"A new credential_pool provider entry expands the model set and MUST "
"bust the cache (task t_16551f61 boundary case)"
)
def test_changed_base_url_changes_auth_fingerprint(tmp_path, monkeypatch):
"""Endpoint / api-base is a non-volatile field — it stays in the
fingerprint, so pointing a provider at a different endpoint invalidates."""
p = _write_auth(tmp_path, monkeypatch, _auth_store())
fp_before = config._auth_store_semantic_fingerprint(p)
p.write_text(
json.dumps(_auth_store(anthropic_base_url="https://proxy.internal/v1"),
indent=2),
encoding="utf-8")
fp_after = config._auth_store_semantic_fingerprint(p)
assert fp_before != fp_after, "A changed provider base_url MUST bust the cache"
def test_real_change_rejects_previously_valid_disk_cache(tmp_path, monkeypatch):
"""End-to-end mirror of the churn test: a disk cache that was valid must
be REJECTED once a genuine provider-set change lands in auth.json."""
_write_auth(tmp_path, monkeypatch, _auth_store(active_provider="anthropic"))
monkeypatch.setattr(config, "_models_cache_path",
tmp_path / "models_cache.json")
config._save_models_cache_to_disk({
"active_provider": "anthropic",
"default_model": "claude-opus-4-7",
"configured_model_badges": {"claude-opus-4-7": "Anthropic"},
"groups": [{"name": "Anthropic", "models": ["claude-opus-4-7"]}],
})
assert config._load_models_cache_from_disk() is not None
config._get_auth_store_path().write_text(
json.dumps(_auth_store(active_provider="openrouter"), indent=2),
encoding="utf-8")
assert config._load_models_cache_from_disk() is None, (
"A real active_provider change MUST reject the stale disk cache — the "
"fix must not over-stabilise into serving wrong data"
)
# ── deny-list helper unit guards ───────────────────────────────────────────
def test_strip_volatile_is_pure_and_recurses():
src = {
"active_provider": "anthropic",
"credential_pool": {
"anthropic": [
{"id": "x", "base_url": "u", "access_token": "S",
"request_count": 7, "last_status": "ok"}
]
},
"updated_at": "ts",
}
import copy as _copy
snapshot = _copy.deepcopy(src)
out = config._strip_volatile_auth_fields(src)
assert src == snapshot # input untouched (pure)
entry = out["credential_pool"]["anthropic"][0]
assert entry == {"id": "x", "base_url": "u"} # volatile keys gone, rest kept
assert "updated_at" not in out
assert out["active_provider"] == "anthropic" # non-volatile preserved
def test_unknown_field_stays_in_fingerprint(tmp_path, monkeypatch):
"""Deny-list (not allow-list) safety: a field we don't know about is NOT
stripped, so a hypothetical future provider-gating field still busts the
cache."""
base = _auth_store()
base["some_future_provider_gate"] = "v1"
p = _write_auth(tmp_path, monkeypatch, base)
fp1 = config._auth_store_semantic_fingerprint(p)
base["some_future_provider_gate"] = "v2"
p.write_text(json.dumps(base, indent=2), encoding="utf-8")
fp2 = config._auth_store_semantic_fingerprint(p)
assert fp1 != fp2, (
"An unknown (non-deny-listed) field must remain in the fingerprint — "
"the deny-list must fail safe toward over-invalidation"
)
def test_missing_auth_file_fingerprint_is_stable_and_marked(tmp_path, monkeypatch):
missing = tmp_path / "nope" / "auth.json"
monkeypatch.setattr(config, "_get_auth_store_path", lambda: missing)
fp = config._auth_store_semantic_fingerprint(missing)
assert fp.get("missing") is True
assert config._auth_store_semantic_fingerprint(missing) == fp