doctor: report ChatGPT token health via /api/auth/session error field

ChatGPT session tokens are JWEs whose `exp` is encrypted and unreadable
client-side, so the old JWT-decode path could never yield an expiry, and the
`/api/auth/session` `expires` is a misleading rolling window (it advances on
every call even for a dead token). The honest signal is the `error` field:
`RefreshAccessTokenError` means the session token is dead while `expires` and
a stale `accessToken` are still echoed (verified live).

- Add ChatGPTProvider.session_health() and a "ChatGPT token active" doctor
  check based on it; drop the dead JWE/exp decode path in doctor and auth.
- Fix _fetch_access_token to fail fast on a set `error` instead of returning
  the stale accessToken (which produced a confusing downstream 401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JesseMarkowitz
2026-06-28 01:37:01 -04:00
co-authored by Claude Opus 4.8
parent 4a7ee5773f
commit ef603cf659
3 changed files with 168 additions and 39 deletions
+100
View File
@@ -873,3 +873,103 @@ class TestChatGPTConvIdFallback:
p = self._get_provider()
result = p.normalize_conversation(raw)
assert result["id"] == "from-id"
# ---------------------------------------------------------------------------
# ChatGPT session-token health (§9): /api/auth/session `error` is the signal,
# not `expires`/`accessToken`. Verified live 2026-06-28 — a dead token returns
# HTTP 200 with error=RefreshAccessTokenError and a stale accessToken.
# ---------------------------------------------------------------------------
class _FakeResp:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self):
return self._payload
class _FakeSession:
"""Minimal stand-in for the provider's HTTP session."""
def __init__(self, payload=None, status=200, raises=None):
self._payload = payload
self._status = status
self._raises = raises
def get(self, *args, **kwargs):
if self._raises is not None:
raise self._raises
return _FakeResp(self._payload, self._status)
class TestChatGPTSessionHealth:
def _provider(self, session):
from src.providers.chatgpt import ChatGPTProvider
p = ChatGPTProvider.__new__(ChatGPTProvider)
p._session = session
return p
def test_fetch_access_token_returns_token_when_healthy(self):
p = self._provider(_FakeSession({"accessToken": "tok-123", "error": None}))
assert p._fetch_access_token() == "tok-123"
def test_fetch_access_token_fails_fast_on_refresh_error(self):
from src.providers.base import ProviderError
# Dead token: HTTP 200, error set, stale accessToken still present.
p = self._provider(
_FakeSession({"accessToken": "stale", "error": "RefreshAccessTokenError"})
)
with pytest.raises(ProviderError) as exc:
p._fetch_access_token()
assert "expired" in str(exc.value.original).lower()
def test_fetch_access_token_fails_when_no_token(self):
from src.providers.base import ProviderError
p = self._provider(_FakeSession({"error": None}))
with pytest.raises(ProviderError):
p._fetch_access_token()
def test_session_health_ok_when_no_error(self):
p = self._provider(_FakeSession({"accessToken": "tok", "error": None}))
ok, detail = p.session_health()
assert ok is True
assert detail == "Session active"
def test_session_health_reports_expired_on_error(self):
p = self._provider(
_FakeSession({"accessToken": "stale", "error": "RefreshAccessTokenError"})
)
ok, detail = p.session_health()
assert ok is False
assert "RefreshAccessTokenError" in detail
assert "refresh" in detail.lower()
def test_session_health_does_not_use_rolling_expires(self):
# A far-future `expires` must NOT make a dead token look healthy.
p = self._provider(
_FakeSession(
{
"accessToken": "stale",
"error": "RefreshAccessTokenError",
"expires": "2026-09-26T05:07:31.108Z",
}
)
)
ok, _ = p.session_health()
assert ok is False
def test_session_health_graceful_on_network_error(self):
p = self._provider(_FakeSession(raises=RuntimeError("boom")))
ok, detail = p.session_health()
assert ok is False
assert "unreachable" in detail.lower()