mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
test(dingtalk): cover QR device-flow auth + OpenClaw branding disclosure
Adds 15 regression tests for hermes_cli/dingtalk_auth.py covering:
* _api_post — network error mapping, errcode-nonzero mapping, success path
* begin_registration — 2-step chain, missing-nonce/device_code/uri
error cases
* wait_for_registration_success — success path, missing-creds guard,
on_waiting callback invocation
* render_qr_to_terminal — returns False when qrcode missing, prints
when available
* Configuration — BASE_URL default + override, SOURCE default
Also adds a one-line disclosure in dingtalk_qr_auth() telling users
the scan page will be OpenClaw-branded. Interim measure: DingTalk's
registration portal is hardcoded to route all sources to /openapp/
registration/openClaw, so users see OpenClaw branding regardless of
what 'source' value we send. We keep 'openClaw' as the source token
until DingTalk-Real-AI registers a Hermes-specific template.
Also adds meng93 to scripts/release.py AUTHOR_MAP.
This commit is contained in:
parent
9deeee7bb7
commit
13f2d997b0
3 changed files with 220 additions and 0 deletions
|
|
@ -238,6 +238,8 @@ def dingtalk_qr_auth() -> Optional[Tuple[str, str]]:
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print_info(" Initializing DingTalk device authorization...")
|
print_info(" Initializing DingTalk device authorization...")
|
||||||
|
print_info(" Note: the scan page is branded 'OpenClaw' — DingTalk's")
|
||||||
|
print_info(" ecosystem onboarding bridge. Safe to use.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reg = begin_registration()
|
reg = begin_registration()
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ AUTHOR_MAP = {
|
||||||
"lovre.pesut@gmail.com": "rovle",
|
"lovre.pesut@gmail.com": "rovle",
|
||||||
"kevinskysunny@gmail.com": "kevinskysunny",
|
"kevinskysunny@gmail.com": "kevinskysunny",
|
||||||
"xiewenxuan462@gmail.com": "yule975",
|
"xiewenxuan462@gmail.com": "yule975",
|
||||||
|
"yiweimeng.dlut@hotmail.com": "meng93",
|
||||||
"hakanerten02@hotmail.com": "teyrebaz33",
|
"hakanerten02@hotmail.com": "teyrebaz33",
|
||||||
"ruzzgarcn@gmail.com": "Ruzzgar",
|
"ruzzgarcn@gmail.com": "Ruzzgar",
|
||||||
"alireza78.crypto@gmail.com": "alireza78a",
|
"alireza78.crypto@gmail.com": "alireza78a",
|
||||||
|
|
|
||||||
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
"""Unit tests for hermes_cli/dingtalk_auth.py (QR device-flow registration)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API layer — _api_post + error mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiPost:
|
||||||
|
|
||||||
|
def test_raises_on_network_error(self):
|
||||||
|
import requests
|
||||||
|
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||||
|
|
||||||
|
with patch("hermes_cli.dingtalk_auth.requests.post",
|
||||||
|
side_effect=requests.ConnectionError("nope")):
|
||||||
|
with pytest.raises(RegistrationError, match="Network error"):
|
||||||
|
_api_post("/app/registration/init", {"source": "hermes"})
|
||||||
|
|
||||||
|
def test_raises_on_nonzero_errcode(self):
|
||||||
|
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"errcode": 42, "errmsg": "boom"}
|
||||||
|
|
||||||
|
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||||
|
with pytest.raises(RegistrationError, match=r"boom \(errcode=42\)"):
|
||||||
|
_api_post("/app/registration/init", {"source": "hermes"})
|
||||||
|
|
||||||
|
def test_returns_data_on_success(self):
|
||||||
|
from hermes_cli.dingtalk_auth import _api_post
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"errcode": 0, "nonce": "abc"}
|
||||||
|
|
||||||
|
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||||
|
result = _api_post("/app/registration/init", {"source": "hermes"})
|
||||||
|
assert result["nonce"] == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# begin_registration — 2-step nonce → device_code chain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBeginRegistration:
|
||||||
|
|
||||||
|
def test_chains_init_then_begin(self):
|
||||||
|
from hermes_cli.dingtalk_auth import begin_registration
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"errcode": 0, "nonce": "nonce123"},
|
||||||
|
{
|
||||||
|
"errcode": 0,
|
||||||
|
"device_code": "dev-xyz",
|
||||||
|
"verification_uri_complete": "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=ABCD",
|
||||||
|
"expires_in": 7200,
|
||||||
|
"interval": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||||
|
result = begin_registration()
|
||||||
|
|
||||||
|
assert result["device_code"] == "dev-xyz"
|
||||||
|
assert "verification_uri_complete" in result
|
||||||
|
assert result["interval"] == 2
|
||||||
|
assert result["expires_in"] == 7200
|
||||||
|
|
||||||
|
def test_missing_nonce_raises(self):
|
||||||
|
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||||
|
|
||||||
|
with patch("hermes_cli.dingtalk_auth._api_post",
|
||||||
|
return_value={"errcode": 0, "nonce": ""}):
|
||||||
|
with pytest.raises(RegistrationError, match="missing nonce"):
|
||||||
|
begin_registration()
|
||||||
|
|
||||||
|
def test_missing_device_code_raises(self):
|
||||||
|
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"errcode": 0, "nonce": "n1"},
|
||||||
|
{"errcode": 0, "verification_uri_complete": "http://x"}, # no device_code
|
||||||
|
]
|
||||||
|
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||||
|
with pytest.raises(RegistrationError, match="missing device_code"):
|
||||||
|
begin_registration()
|
||||||
|
|
||||||
|
def test_missing_verification_uri_raises(self):
|
||||||
|
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"errcode": 0, "nonce": "n1"},
|
||||||
|
{"errcode": 0, "device_code": "dev"}, # no verification_uri_complete
|
||||||
|
]
|
||||||
|
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||||
|
with pytest.raises(RegistrationError,
|
||||||
|
match="missing verification_uri_complete"):
|
||||||
|
begin_registration()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# wait_for_registration_success — polling loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForSuccess:
|
||||||
|
|
||||||
|
def test_returns_credentials_on_success(self):
|
||||||
|
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||||
|
|
||||||
|
responses = [
|
||||||
|
{"status": "WAITING"},
|
||||||
|
{"status": "WAITING"},
|
||||||
|
{"status": "SUCCESS", "client_id": "cid-1", "client_secret": "sec-1"},
|
||||||
|
]
|
||||||
|
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||||
|
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||||
|
cid, secret = wait_for_registration_success(
|
||||||
|
device_code="dev", interval=0, expires_in=60
|
||||||
|
)
|
||||||
|
assert cid == "cid-1"
|
||||||
|
assert secret == "sec-1"
|
||||||
|
|
||||||
|
def test_success_without_credentials_raises(self):
|
||||||
|
from hermes_cli.dingtalk_auth import wait_for_registration_success, RegistrationError
|
||||||
|
|
||||||
|
with patch("hermes_cli.dingtalk_auth.poll_registration",
|
||||||
|
return_value={"status": "SUCCESS", "client_id": "", "client_secret": ""}), \
|
||||||
|
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||||
|
with pytest.raises(RegistrationError, match="credentials are missing"):
|
||||||
|
wait_for_registration_success(
|
||||||
|
device_code="dev", interval=0, expires_in=60
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invokes_waiting_callback(self):
|
||||||
|
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
responses = [
|
||||||
|
{"status": "WAITING"},
|
||||||
|
{"status": "WAITING"},
|
||||||
|
{"status": "SUCCESS", "client_id": "cid", "client_secret": "sec"},
|
||||||
|
]
|
||||||
|
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||||
|
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||||
|
wait_for_registration_success(
|
||||||
|
device_code="dev", interval=0, expires_in=60, on_waiting=callback
|
||||||
|
)
|
||||||
|
assert callback.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# QR rendering — terminal output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderQR:
|
||||||
|
|
||||||
|
def test_returns_false_when_qrcode_missing(self, monkeypatch):
|
||||||
|
from hermes_cli import dingtalk_auth
|
||||||
|
|
||||||
|
# Simulate qrcode import failure
|
||||||
|
monkeypatch.setitem(sys.modules, "qrcode", None)
|
||||||
|
assert dingtalk_auth.render_qr_to_terminal("https://example.com") is False
|
||||||
|
|
||||||
|
def test_prints_when_qrcode_available(self, capsys):
|
||||||
|
"""End-to-end: render a real QR and verify SOMETHING got printed."""
|
||||||
|
try:
|
||||||
|
import qrcode # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("qrcode library not available")
|
||||||
|
|
||||||
|
from hermes_cli.dingtalk_auth import render_qr_to_terminal
|
||||||
|
result = render_qr_to_terminal("https://example.com/test")
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert result is True
|
||||||
|
assert len(captured.out) > 100 # rendered matrix is non-trivial
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration — env var overrides
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigOverrides:
|
||||||
|
|
||||||
|
def test_base_url_default(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False)
|
||||||
|
# Force module reload to pick up current env
|
||||||
|
import importlib
|
||||||
|
import hermes_cli.dingtalk_auth as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
assert mod.REGISTRATION_BASE_URL == "https://oapi.dingtalk.com"
|
||||||
|
|
||||||
|
def test_base_url_override_via_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("DINGTALK_REGISTRATION_BASE_URL",
|
||||||
|
"https://test.example.com/")
|
||||||
|
import importlib
|
||||||
|
import hermes_cli.dingtalk_auth as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
# Trailing slash stripped
|
||||||
|
assert mod.REGISTRATION_BASE_URL == "https://test.example.com"
|
||||||
|
|
||||||
|
def test_source_default(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False)
|
||||||
|
import importlib
|
||||||
|
import hermes_cli.dingtalk_auth as mod
|
||||||
|
importlib.reload(mod)
|
||||||
|
assert mod.REGISTRATION_SOURCE == "openClaw"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue