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"