Files
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
Build Skills Index / trigger-deploy (push) Waiting to run
CI / Deny unrelated histories (push) Has been skipped
CI / CI timing report (push) Blocked by required conditions
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / All required checks pass (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

812 lines
29 KiB
Python

"""Regression tests for the OAuth dispatcher in hermes_cli.web_server.
Bug history (2026-05-09): the `_OAUTH_PROVIDER_CATALOG` had two entries
flagged ``flow: "pkce"`` — anthropic and minimax-oauth — and the
dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()``
for any pkce-flagged provider. So clicking "Login" next to MiniMax in
the dashboard's Keys tab silently launched the Anthropic/Claude OAuth
flow.
The fix:
1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to
``flow: "device_code"`` (the actual UX is verification URI + user
code + background poll, with PKCE as a security extension).
2. New MiniMax branch added to ``_start_device_code_flow``.
3. Dispatcher tightened: pkce branch now requires
``provider_id == "anthropic"``, so any future PKCE provider added
without an explicit branch gets a clean ``400 Unsupported flow``
instead of silently launching Anthropic OAuth.
These tests pin the corrected behavior.
"""
import asyncio
import time
from datetime import datetime, timezone
from unittest.mock import patch
import httpx
import pytest
from fastapi.testclient import TestClient
from hermes_cli.web_server import _SESSION_TOKEN, app
client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profile_home = tmp_path / "profiles" / profile
profile_home.mkdir(parents=True)
return profile_home
def _fake_nous_device_data():
return {
"device_code": "device-code",
"user_code": "NOUS-1234",
"verification_uri": "https://portal.nousresearch.com/device",
"verification_uri_complete": (
"https://portal.nousresearch.com/device?user_code=NOUS-1234"
),
"expires_in": 600,
"interval": 5,
}
def _invoke_scope_refusal():
request = httpx.Request("POST", "https://portal.nousresearch.com/oauth/device/code")
response = httpx.Response(
400,
json={
"error": "invalid_scope",
"error_description": "unsupported scope inference:invoke",
},
request=request,
)
return httpx.HTTPStatusError("invalid scope", request=request, response=response)
def test_minimax_login_does_not_launch_anthropic_flow():
"""Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url."""
fake_user_code_resp = {
"user_code": "ABCD-1234",
"verification_uri": "https://api.minimax.io/oauth/verify",
# `expired_in` < 1e12 so the heuristic treats it as seconds.
"expired_in": 600,
"interval": 2000,
"state": "stub-state",
}
with patch(
"hermes_cli.auth._minimax_request_user_code",
return_value=fake_user_code_resp,
), patch(
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
return_value=None,
):
resp = client.post(
"/api/providers/oauth/minimax-oauth/start",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text
body = resp.json()
# The bug used to return Anthropic's auth_url — make sure the response
# references neither the auth_url field nor anything Claude-related.
assert "auth_url" not in body
assert "claude.ai" not in str(body).lower()
# And the response IS the device-code shape pointing at MiniMax.
assert body["flow"] == "device_code"
assert "minimax" in body["verification_url"].lower()
assert body["user_code"] == "ABCD-1234"
assert body["expires_in"] == 600
def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
requested_scopes = []
def fake_request_device_code(**kwargs):
requested_scopes.append(kwargs["scope"])
return _fake_nous_device_data()
monkeypatch.setenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", "true")
monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code)
monkeypatch.setattr(ws, "_nous_poller", lambda sid: None)
result = asyncio.run(ws._start_device_code_flow("nous"))
try:
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
assert result["flow"] == "device_code"
assert result["user_code"] == "NOUS-1234"
assert (
ws._oauth_sessions[result["session_id"]]["scope"]
== auth_mod.DEFAULT_NOUS_SCOPE
)
finally:
ws._oauth_sessions.pop(result["session_id"], None)
def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
observed_homes = []
def fake_status():
observed_homes.append(get_hermes_home())
return {"logged_in": False, "source": None}
fake_catalog = ({
"id": "fake-oauth",
"name": "Fake OAuth",
"flow": "pkce",
"cli_command": "hermes auth add fake-oauth",
"docs_url": "https://example.com",
"status_fn": fake_status,
},)
monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog)
resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS)
assert resp.status_code == 200, resp.text
assert observed_homes == [profile_home]
def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
_make_profile_home(tmp_path, monkeypatch)
fake_user_code_resp = {
"user_code": "ABCD-1234",
"verification_uri": "https://api.minimax.io/oauth/verify",
"expired_in": 600,
"interval": 2000,
"state": "stub-state",
}
with patch(
"hermes_cli.auth._minimax_request_user_code",
return_value=fake_user_code_resp,
), patch(
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
return_value=None,
):
resp = client.post(
"/api/providers/oauth/minimax-oauth/start?profile=coder",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
try:
assert ws._oauth_sessions[session_id]["profile"] == "coder"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
requested_scopes = []
def fake_request_device_code(**kwargs):
requested_scopes.append(kwargs["scope"])
raise _invoke_scope_refusal()
monkeypatch.delenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", raising=False)
monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code)
monkeypatch.setattr(ws, "_nous_poller", lambda sid: None)
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(ws._start_device_code_flow("nous"))
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
from hermes_cli.auth import get_active_provider
from hermes_cli.runtime_provider import resolve_runtime_provider
access_token = "h.eyJleHAiOjk5OTk5OTk5OTl9.s"
class _Resp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
if url.endswith("/deviceauth/usercode"):
return _Resp(200, {
"device_auth_id": "device-auth-id",
"interval": 3,
"user_code": "CODEX-1234",
})
if url.endswith("/deviceauth/token"):
return _Resp(200, {
"authorization_code": "authorization-code",
"code_verifier": "code-verifier",
})
return _Resp(200, {
"access_token": access_token,
"refresh_token": "codex-refresh",
})
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
sid, _ = ws._new_oauth_session("openai-codex", "device_code")
try:
ws._codex_full_login_worker(sid)
assert ws._oauth_sessions[sid]["status"] == "approved"
assert get_active_provider() == "openai-codex"
runtime = resolve_runtime_provider(requested=None)
assert runtime["provider"] == "openai-codex"
assert runtime["api_key"] == access_token
assert runtime["api_mode"] == "codex_responses"
finally:
ws._oauth_sessions.pop(sid, None)
def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
class _Resp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
if url.endswith("/deviceauth/usercode"):
return _Resp(200, {
"device_auth_id": "device-auth-id",
"interval": 3,
"user_code": "CODEX-1234",
})
if url.endswith("/deviceauth/token"):
return _Resp(200, {
"authorization_code": "authorization-code",
"code_verifier": "code-verifier",
})
return _Resp(200, {
"access_token": "codex-access",
"refresh_token": "codex-refresh",
})
saved_homes = []
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
monkeypatch.setattr(
auth_mod,
"_save_codex_tokens",
lambda tokens: saved_homes.append(get_hermes_home()),
)
sid, _ = ws._new_oauth_session(
"openai-codex",
"device_code",
profile="coder",
)
try:
ws._codex_full_login_worker(sid)
assert ws._oauth_sessions[sid]["status"] == "approved"
assert saved_homes == [profile_home]
finally:
ws._oauth_sessions.pop(sid, None)
def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
from hermes_cli import web_server as ws
before_sessions = set(ws._oauth_sessions)
class _Resp:
status_code = 400
text = "Enable device code authorization"
def json(self):
return {
"error": {
"message": "Enable device code authorization",
"code": "device_authorization_not_enabled",
}
}
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
assert url.endswith("/deviceauth/usercode")
return _Resp()
monkeypatch.setattr(httpx, "Client", _Client)
try:
resp = client.post(
"/api/providers/oauth/openai-codex/start",
headers=HEADERS,
)
assert resp.status_code == 500
detail = resp.json()["detail"]
assert "OpenAI rejected the device-code login request" in detail
assert "Enable device-code authorization in OpenAI" in detail
assert "click Login again" in detail
assert "hermes auth" not in detail
finally:
for sid in set(ws._oauth_sessions) - before_sessions:
ws._oauth_sessions.pop(sid, None)
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "nous-effective-scope-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "nous",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"portal_base_url": "https://portal.nousresearch.com",
"client_id": "hermes-cli",
"device_code": "device-code",
"interval": 5,
"expires_at": time.time() + 600,
"scope": auth_mod.DEFAULT_NOUS_SCOPE,
}
captured_state = {}
def fake_refresh_nous_oauth_from_state(state, **kwargs):
captured_state.update(state)
return {**state, "agent_key": "jwt-agent-key"}
monkeypatch.setattr(
auth_mod,
"_poll_for_token",
lambda **kwargs: {
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
},
)
monkeypatch.setattr(
auth_mod,
"refresh_nous_oauth_from_state",
fake_refresh_nous_oauth_from_state,
)
monkeypatch.setattr(auth_mod, "persist_nous_credentials", lambda state: None)
try:
ws._nous_poller(session_id)
assert captured_state["scope"] == auth_mod.DEFAULT_NOUS_SCOPE
assert ws._oauth_sessions[session_id]["status"] == "approved"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
"""Dashboard MiniMax completion must accept unix-ms token expiry values."""
from hermes_cli import web_server as ws
now = datetime.now(timezone.utc)
abs_ms = int((now.timestamp() + 1800) * 1000)
session_id = "minimax-absolute-ms-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "minimax-oauth",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"portal_base_url": "https://api.minimax.io",
"client_id": "client-id",
"user_code": "ABCD-1234",
"code_verifier": "verifier",
"interval_ms": 2000,
"expired_in_raw": abs_ms,
"region": "global",
}
captured_state = {}
try:
with patch(
"hermes_cli.auth._minimax_poll_token",
return_value={
"status": "success",
"access_token": "access",
"refresh_token": "refresh",
"expired_in": abs_ms,
"token_type": "Bearer",
},
), patch(
"hermes_cli.auth._minimax_save_auth_state",
side_effect=lambda state: captured_state.update(state),
):
ws._minimax_poller(session_id)
finally:
ws._oauth_sessions.pop(session_id, None)
assert captured_state["access_token"] == "access"
assert 1790 <= captured_state["expires_in"] <= 1810
assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999
def test_anthropic_pkce_branch_still_works():
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
fake_anthropic_response = {
"session_id": "stub-session",
"flow": "pkce",
"auth_url": "https://claude.ai/oauth/authorize?code=true&...",
"expires_in": 600,
}
with patch(
"hermes_cli.web_server._start_anthropic_pkce",
return_value=fake_anthropic_response,
):
resp = client.post(
"/api/providers/oauth/anthropic/start",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["flow"] == "pkce"
assert "claude.ai" in body["auth_url"]
def test_xai_oauth_listed_as_device_code_flow():
"""xAI Grok OAuth must surface in the catalog as a device-code flow."""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "xai-oauth" in providers
assert providers["xai-oauth"]["flow"] == "device_code"
assert "grok" in providers["xai-oauth"]["name"].lower()
def test_accounts_offers_every_oauth_provider_from_catalog():
"""PARITY CONTRACT: every accounts-tab provider in the unified catalog (the
`hermes model` universe) must be offered by /api/providers/oauth. This keeps
the desktop Accounts tab in lockstep with the CLI picker — no provider the
CLI can sign into may be missing from the GUI.
"""
from hermes_cli.provider_catalog import provider_catalog
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
offered = {p["id"] for p in resp.json()["providers"]}
for d in provider_catalog():
if d.tab == "accounts":
assert d.slug in offered, (
f"{d.slug} is an accounts-tab provider in `hermes model` but is "
f"missing from the desktop Accounts tab (/api/providers/oauth)"
)
def test_copilot_acp_now_in_accounts():
"""Regression: copilot-acp was a canonical provider the CLI could configure,
but had no Accounts card (the reported GUI/CLI drift).
"""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "copilot-acp" in providers
# copilot-acp is managed by an external CLI: read-only card, not auto-removable.
assert providers["copilot-acp"]["flow"] == "external"
assert providers["copilot-acp"]["disconnectable"] is False
def test_oauth_catalog_marks_external_providers_not_disconnectable():
"""External CLI credentials are visible in Accounts but cannot be removed by Hermes."""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
# Qwen: external and not auto-removable, and we don't know a clear command,
# so it stays a manual hint with no runnable disconnect command.
assert providers["qwen-oauth"]["flow"] == "external"
assert providers["qwen-oauth"]["disconnectable"] is False
assert "provider's CLI" in providers["qwen-oauth"]["disconnect_hint"]
assert providers["qwen-oauth"]["disconnect_command"] is None
# Claude Code: still not API-disconnectable, but we hand the GUI a runnable
# command (clears the keychain entry / credentials file) so it can offer a
# one-click "run in terminal" disconnect.
assert providers["claude-code"]["flow"] == "external"
assert providers["claude-code"]["disconnectable"] is False
assert providers["claude-code"]["disconnect_hint"]
cmd = providers["claude-code"]["disconnect_command"]
assert cmd and ".claude/.credentials.json" in cmd
def test_external_oauth_disconnect_rejected_before_auth_mutation(monkeypatch):
"""DELETE must not pretend to remove credentials owned by another CLI."""
from hermes_cli import auth as auth_mod
def fail_clear_provider_auth(provider_id=None):
raise AssertionError("external providers must not reach clear_provider_auth")
monkeypatch.setattr(auth_mod, "clear_provider_auth", fail_clear_provider_auth)
resp = client.delete("/api/providers/oauth/qwen-oauth", headers=HEADERS)
assert resp.status_code == 400, resp.text
assert "cannot be disconnected automatically" in resp.text
assert "provider's CLI" in resp.text
def test_env_sourced_oauth_status_is_not_disconnectable(monkeypatch):
"""An env/.env-backed Anthropic API key is removed from Keys, not OAuth Accounts."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert providers["anthropic"]["status"]["source"] == "env_var"
assert providers["anthropic"]["disconnectable"] is False
assert providers["anthropic"]["disconnect_hint"] == "Remove the API key from Settings → Keys instead."
delete_resp = client.delete("/api/providers/oauth/anthropic", headers=HEADERS)
assert delete_resp.status_code == 400, delete_resp.text
assert "Settings" in delete_resp.text
def test_xai_oauth_device_code_start_returns_user_code(monkeypatch):
"""Start MUST hand back xAI's verification URL and user code."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
monkeypatch.setattr(
auth_mod,
"_xai_oauth_request_device_code",
lambda *a, **k: {
"device_code": "device-code",
"user_code": "ABCD-EFGH",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
"expires_in": 1800,
"interval": 5,
},
)
# Don't let the background poller hit the real token endpoint.
monkeypatch.setattr(ws, "_xai_device_poller", lambda sid: None)
resp = client.post("/api/providers/oauth/xai-oauth/start", headers=HEADERS)
assert resp.status_code == 200, resp.text
body = resp.json()
try:
assert body["flow"] == "device_code"
assert body["user_code"] == "ABCD-EFGH"
assert body["verification_url"].startswith("https://accounts.x.ai/oauth2/device")
sess = ws._oauth_sessions[body["session_id"]]
assert sess["provider"] == "xai-oauth"
assert sess["flow"] == "device_code"
assert sess["device_code"] == "device-code"
finally:
ws._oauth_sessions.pop(body["session_id"], None)
def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path, monkeypatch):
"""The dashboard device-code poller must leave exactly ONE pool entry — the
singleton-seeded ``device_code`` source — and must NOT create a parallel
``manual:dashboard_*`` entry.
Dedupe: a parallel dashboard entry would share the singleton's single-use
refresh token, and two entries racing the same rotation ->
``refresh_token_reused`` (on main, the dashboard login inserted exactly
such a duplicate alongside the singleton seed). The poller writes the
singleton only; the seed is the single source of truth.
Suppression: an interactive dashboard login must also clear any
``device_code`` suppression left by a prior ``hermes auth remove
xai-oauth``.
"""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
from agent.credential_pool import load_pool
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
# Prior `hermes auth remove xai-oauth` left the source suppressed.
auth_mod.suppress_credential_source("xai-oauth", "device_code")
assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is True
monkeypatch.setattr(
auth_mod,
"_xai_oauth_discovery",
lambda *a, **k: {"token_endpoint": "https://auth.x.ai/token"},
)
monkeypatch.setattr(
auth_mod,
"_xai_oauth_poll_device_token",
lambda client, **kwargs: {
"access_token": "xai-dashboard-access",
"refresh_token": "rt-dashboard",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
},
)
session_id = "xai-dashboard-dedupe-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"device_code": "device-code",
"interval": 5,
"expires_at": time.time() + 600,
}
try:
ws._xai_device_poller(session_id)
assert ws._oauth_sessions[session_id]["status"] == "approved"
finally:
ws._oauth_sessions.pop(session_id, None)
# The interactive dashboard login cleared the suppression marker.
assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is False
# The credential pool has exactly one entry, seeded from the
# singleton as ``device_code`` — no parallel ``manual:dashboard_*``
# duplicate sharing the single-use refresh token.
entries = load_pool("xai-oauth").entries()
assert len(entries) == 1
assert entries[0].source == "device_code"
assert entries[0].refresh_token == "rt-dashboard"
assert not any(
getattr(e, "source", "").startswith("manual:dashboard") for e in entries
)
def test_unknown_pkce_provider_rejected_cleanly():
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
Simulates a hypothetical catalog entry with ``flow: "pkce"`` and an
id other than "anthropic". The dispatcher should fall through past
the pkce branch (now gated on provider_id) and the device_code
branch, then hit "Unsupported flow" — proving the bug class is
structurally prevented.
"""
from hermes_cli import web_server as ws
# Inject a hypothetical catalog entry that's pkce-flagged but isn't
# anthropic. This shape mirrors what would happen if a developer
# added a new provider entry without remembering to wire up its
# start function.
fake_entry = {
"id": "hypothetical-pkce-provider",
"name": "Hypothetical PKCE Provider",
"flow": "pkce",
"cli_command": "hermes auth add hypothetical-pkce-provider",
"docs_url": "https://example.com",
"status_fn": None,
}
original_catalog = ws._OAUTH_PROVIDER_CATALOG
try:
ws._OAUTH_PROVIDER_CATALOG = original_catalog + (fake_entry,)
resp = client.post(
"/api/providers/oauth/hypothetical-pkce-provider/start",
headers=HEADERS,
)
finally:
ws._OAUTH_PROVIDER_CATALOG = original_catalog
# Either 400 "Unsupported flow" (the explicit fall-through) or any
# 4xx — what we MUST NOT see is a 200 with claude.ai in the body.
assert resp.status_code >= 400, resp.text
assert "claude.ai" not in resp.text.lower()
def test_status_falls_through_to_generic_dispatcher_for_catalog_only_provider():
"""Accounts-tab providers with no hardcoded branch reflect REAL status.
Providers appended to the Accounts tab from the unified provider_catalog()
carry status_fn=None and may have no explicit branch in
_resolve_provider_status. Before the fallthrough they rendered permanently
logged-out; now they dispatch to hermes_cli.auth.get_auth_status (the
canonical slug dispatcher) so membership AND status both auto-extend.
"""
import hermes_cli.web_server as ws
fake_status = {
"logged_in": True,
"provider": "some-future-oauth",
"name": "Future OAuth Provider",
"access_token": "sk-future-secret-token-xyz",
"expires_at": "2026-12-01T00:00:00Z",
"has_refresh_token": True,
}
with patch("hermes_cli.auth.get_auth_status", return_value=fake_status):
out = ws._resolve_provider_status("some-future-oauth", None)
assert out["logged_in"] is True
assert out["source"] == "some-future-oauth"
assert out["source_label"] == "Future OAuth Provider"
# Token is previewed, never returned whole.
assert out["token_preview"] and "sk-future-secret-token-xyz" not in out["token_preview"]
assert out["expires_at"] == "2026-12-01T00:00:00Z"
assert out["has_refresh_token"] is True
def test_status_hardcoded_branch_wins_over_generic_fallback():
"""An existing hardcoded branch (nous) is unaffected by the fallthrough."""
import hermes_cli.web_server as ws
with patch(
"hermes_cli.auth.get_nous_auth_status",
return_value={"logged_in": True, "portal_base_url": "https://portal.test"},
):
out = ws._resolve_provider_status("nous", None)
assert out["source"] == "nous_portal"
assert out["source_label"] == "https://portal.test"
def test_status_unknown_provider_degrades_to_logged_out():
"""A provider the generic dispatcher can't resolve stays logged-out cleanly."""
import hermes_cli.web_server as ws
with patch("hermes_cli.auth.get_auth_status", return_value={"logged_in": False}):
out = ws._resolve_provider_status("totally-unknown", None)
assert out["logged_in"] is False