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
218 lines
8.5 KiB
Python
218 lines
8.5 KiB
Python
"""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"
|