/ tests / hermes_cli / test_dingtalk_auth.py
test_dingtalk_auth.py
  1  """Unit tests for hermes_cli/dingtalk_auth.py (QR device-flow registration)."""
  2  from __future__ import annotations
  3  
  4  import sys
  5  from unittest.mock import MagicMock, patch
  6  
  7  import pytest
  8  
  9  
 10  # ---------------------------------------------------------------------------
 11  # API layer — _api_post + error mapping
 12  # ---------------------------------------------------------------------------
 13  
 14  
 15  class TestApiPost:
 16  
 17      def test_raises_on_network_error(self):
 18          import requests
 19          from hermes_cli.dingtalk_auth import _api_post, RegistrationError
 20  
 21          with patch("hermes_cli.dingtalk_auth.requests.post",
 22                     side_effect=requests.ConnectionError("nope")):
 23              with pytest.raises(RegistrationError, match="Network error"):
 24                  _api_post("/app/registration/init", {"source": "hermes"})
 25  
 26      def test_raises_on_nonzero_errcode(self):
 27          from hermes_cli.dingtalk_auth import _api_post, RegistrationError
 28  
 29          mock_resp = MagicMock()
 30          mock_resp.raise_for_status = MagicMock()
 31          mock_resp.json.return_value = {"errcode": 42, "errmsg": "boom"}
 32  
 33          with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
 34              with pytest.raises(RegistrationError, match=r"boom \(errcode=42\)"):
 35                  _api_post("/app/registration/init", {"source": "hermes"})
 36  
 37      def test_returns_data_on_success(self):
 38          from hermes_cli.dingtalk_auth import _api_post
 39  
 40          mock_resp = MagicMock()
 41          mock_resp.raise_for_status = MagicMock()
 42          mock_resp.json.return_value = {"errcode": 0, "nonce": "abc"}
 43  
 44          with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
 45              result = _api_post("/app/registration/init", {"source": "hermes"})
 46              assert result["nonce"] == "abc"
 47  
 48  
 49  # ---------------------------------------------------------------------------
 50  # begin_registration — 2-step nonce → device_code chain
 51  # ---------------------------------------------------------------------------
 52  
 53  
 54  class TestBeginRegistration:
 55  
 56      def test_chains_init_then_begin(self):
 57          from hermes_cli.dingtalk_auth import begin_registration
 58  
 59          responses = [
 60              {"errcode": 0, "nonce": "nonce123"},
 61              {
 62                  "errcode": 0,
 63                  "device_code": "dev-xyz",
 64                  "verification_uri_complete": "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=ABCD",
 65                  "expires_in": 7200,
 66                  "interval": 2,
 67              },
 68          ]
 69          with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
 70              result = begin_registration()
 71  
 72          assert result["device_code"] == "dev-xyz"
 73          assert "verification_uri_complete" in result
 74          assert result["interval"] == 2
 75          assert result["expires_in"] == 7200
 76  
 77      def test_missing_nonce_raises(self):
 78          from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
 79  
 80          with patch("hermes_cli.dingtalk_auth._api_post",
 81                     return_value={"errcode": 0, "nonce": ""}):
 82              with pytest.raises(RegistrationError, match="missing nonce"):
 83                  begin_registration()
 84  
 85      def test_missing_device_code_raises(self):
 86          from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
 87  
 88          responses = [
 89              {"errcode": 0, "nonce": "n1"},
 90              {"errcode": 0, "verification_uri_complete": "http://x"},  # no device_code
 91          ]
 92          with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
 93              with pytest.raises(RegistrationError, match="missing device_code"):
 94                  begin_registration()
 95  
 96      def test_missing_verification_uri_raises(self):
 97          from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
 98  
 99          responses = [
100              {"errcode": 0, "nonce": "n1"},
101              {"errcode": 0, "device_code": "dev"},  # no verification_uri_complete
102          ]
103          with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
104              with pytest.raises(RegistrationError,
105                                 match="missing verification_uri_complete"):
106                  begin_registration()
107  
108  
109  # ---------------------------------------------------------------------------
110  # wait_for_registration_success — polling loop
111  # ---------------------------------------------------------------------------
112  
113  
114  class TestWaitForSuccess:
115  
116      def test_returns_credentials_on_success(self):
117          from hermes_cli.dingtalk_auth import wait_for_registration_success
118  
119          responses = [
120              {"status": "WAITING"},
121              {"status": "WAITING"},
122              {"status": "SUCCESS", "client_id": "cid-1", "client_secret": "sec-1"},
123          ]
124          with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
125               patch("hermes_cli.dingtalk_auth.time.sleep"):
126              cid, secret = wait_for_registration_success(
127                  device_code="dev", interval=0, expires_in=60
128              )
129              assert cid == "cid-1"
130              assert secret == "sec-1"
131  
132      def test_success_without_credentials_raises(self):
133          from hermes_cli.dingtalk_auth import wait_for_registration_success, RegistrationError
134  
135          with patch("hermes_cli.dingtalk_auth.poll_registration",
136                     return_value={"status": "SUCCESS", "client_id": "", "client_secret": ""}), \
137               patch("hermes_cli.dingtalk_auth.time.sleep"):
138              with pytest.raises(RegistrationError, match="credentials are missing"):
139                  wait_for_registration_success(
140                      device_code="dev", interval=0, expires_in=60
141                  )
142  
143      def test_invokes_waiting_callback(self):
144          from hermes_cli.dingtalk_auth import wait_for_registration_success
145  
146          callback = MagicMock()
147          responses = [
148              {"status": "WAITING"},
149              {"status": "WAITING"},
150              {"status": "SUCCESS", "client_id": "cid", "client_secret": "sec"},
151          ]
152          with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
153               patch("hermes_cli.dingtalk_auth.time.sleep"):
154              wait_for_registration_success(
155                  device_code="dev", interval=0, expires_in=60, on_waiting=callback
156              )
157          assert callback.call_count == 2
158  
159  
160  # ---------------------------------------------------------------------------
161  # QR rendering — terminal output
162  # ---------------------------------------------------------------------------
163  
164  
165  class TestRenderQR:
166  
167      def test_returns_false_when_qrcode_missing(self, monkeypatch):
168          from hermes_cli import dingtalk_auth
169  
170          # Simulate qrcode import failure
171          monkeypatch.setitem(sys.modules, "qrcode", None)
172          assert dingtalk_auth.render_qr_to_terminal("https://example.com") is False
173  
174      def test_prints_when_qrcode_available(self, capsys):
175          """End-to-end: render a real QR and verify SOMETHING got printed."""
176          try:
177              import qrcode  # noqa: F401
178          except ImportError:
179              pytest.skip("qrcode library not available")
180  
181          from hermes_cli.dingtalk_auth import render_qr_to_terminal
182          result = render_qr_to_terminal("https://example.com/test")
183          captured = capsys.readouterr()
184          assert result is True
185          assert len(captured.out) > 100  # rendered matrix is non-trivial
186  
187  
188  # ---------------------------------------------------------------------------
189  # Configuration — env var overrides
190  # ---------------------------------------------------------------------------
191  
192  
193  class TestConfigOverrides:
194  
195      def test_base_url_default(self, monkeypatch):
196          monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False)
197          # Force module reload to pick up current env
198          import importlib
199          import hermes_cli.dingtalk_auth as mod
200          importlib.reload(mod)
201          assert mod.REGISTRATION_BASE_URL == "https://oapi.dingtalk.com"
202  
203      def test_base_url_override_via_env(self, monkeypatch):
204          monkeypatch.setenv("DINGTALK_REGISTRATION_BASE_URL",
205                             "https://test.example.com/")
206          import importlib
207          import hermes_cli.dingtalk_auth as mod
208          importlib.reload(mod)
209          # Trailing slash stripped
210          assert mod.REGISTRATION_BASE_URL == "https://test.example.com"
211  
212      def test_source_default(self, monkeypatch):
213          monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False)
214          import importlib
215          import hermes_cli.dingtalk_auth as mod
216          importlib.reload(mod)
217          assert mod.REGISTRATION_SOURCE == "openClaw"