"""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. """ from unittest.mock import patch 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 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"), ): 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_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_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()