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:
co-authored by
Claude Opus 4.8
parent
4a7ee5773f
commit
ef603cf659
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user