270 lines
8.3 KiB
Python
270 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Unit tests for Feishu QR Code Auth Handler."""
|
|
# pylint: disable=redefined-outer-name,protected-access
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_request():
|
|
"""Mock FastAPI Request object."""
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def feishu_handler():
|
|
"""Create Feishu handler instance."""
|
|
from pineagents.app.channels.qrcode_auth_handler import (
|
|
FeishuQRCodeAuthHandler,
|
|
)
|
|
|
|
return FeishuQRCodeAuthHandler()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_httpx_client():
|
|
"""Create mock httpx.AsyncClient context manager."""
|
|
|
|
def _create_mock(responses):
|
|
"""Create mock with given responses.
|
|
|
|
Args:
|
|
responses: Single response or list of responses for side_effect
|
|
"""
|
|
mock_client = MagicMock()
|
|
if isinstance(responses, list):
|
|
mock_client.post = AsyncMock(side_effect=responses)
|
|
else:
|
|
mock_client.post = AsyncMock(return_value=responses)
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.return_value.__aenter__ = AsyncMock(
|
|
return_value=mock_client,
|
|
)
|
|
mock_async_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
return mock_async_client
|
|
|
|
return _create_mock
|
|
|
|
|
|
def _mock_response(json_data):
|
|
"""Create mock HTTP response with JSON data."""
|
|
response = MagicMock()
|
|
response.raise_for_status = MagicMock()
|
|
response.json.return_value = json_data
|
|
return response
|
|
|
|
|
|
class TestFeishuQRCodeAuthHandler:
|
|
"""Tests for FeishuQRCodeAuthHandler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_domain_default_feishu(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
):
|
|
"""Should default to feishu domain."""
|
|
domain = await feishu_handler._get_domain(mock_request)
|
|
assert domain == "feishu"
|
|
|
|
def test_get_accounts_domain(self, feishu_handler):
|
|
"""Should return correct accounts domain."""
|
|
assert (
|
|
feishu_handler._get_accounts_domain("feishu")
|
|
== "https://accounts.feishu.cn"
|
|
)
|
|
assert (
|
|
feishu_handler._get_accounts_domain("lark")
|
|
== "https://accounts.larksuite.com"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_qrcode_success(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
mock_httpx_client,
|
|
):
|
|
"""Should successfully fetch QR code."""
|
|
init_resp = _mock_response(
|
|
{"supported_auth_methods": ["client_secret"]},
|
|
)
|
|
begin_resp = _mock_response(
|
|
{
|
|
"device_code": "device_123",
|
|
"verification_uri_complete": "https://example.com/qr?code=abc",
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"httpx.AsyncClient",
|
|
mock_httpx_client([init_resp, begin_resp]),
|
|
):
|
|
result = await feishu_handler.fetch_qrcode(mock_request)
|
|
|
|
assert result.poll_token == "device_123"
|
|
assert "source=QwenPaw" in result.scan_url
|
|
assert "code=abc" in result.scan_url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_qrcode_unsupported_auth_method(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
mock_httpx_client,
|
|
):
|
|
"""Should raise error for unsupported auth methods."""
|
|
init_resp = _mock_response(
|
|
{"supported_auth_methods": ["other_method"]},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient", mock_httpx_client(init_resp)):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await feishu_handler.fetch_qrcode(mock_request)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert "unsupported auth methods" in str(exc.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_qrcode_missing_device_code(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
mock_httpx_client,
|
|
):
|
|
"""Should raise error when device_code is missing."""
|
|
init_resp = _mock_response(
|
|
{"supported_auth_methods": ["client_secret"]},
|
|
)
|
|
begin_resp = _mock_response(
|
|
{"verification_uri_complete": "https://example.com/qr"},
|
|
)
|
|
|
|
with patch(
|
|
"httpx.AsyncClient",
|
|
mock_httpx_client([init_resp, begin_resp]),
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await feishu_handler.fetch_qrcode(mock_request)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert "missing device_code" in str(exc.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_success(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
mock_httpx_client,
|
|
):
|
|
"""Should return success when credentials are ready."""
|
|
response = _mock_response(
|
|
{
|
|
"client_id": "cli_abc123",
|
|
"client_secret": "secret_xyz789",
|
|
"user_info": {
|
|
"open_id": "ou_user123",
|
|
"tenant_brand": "feishu",
|
|
},
|
|
},
|
|
)
|
|
|
|
with patch("httpx.AsyncClient", mock_httpx_client(response)):
|
|
result = await feishu_handler.poll_status(
|
|
"device_123",
|
|
mock_request,
|
|
)
|
|
|
|
assert result.status == "success"
|
|
assert result.credentials["app_id"] == "cli_abc123"
|
|
assert result.credentials["app_secret"] == "secret_xyz789"
|
|
assert result.credentials["open_id"] == "ou_user123"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"error,expected_status",
|
|
[
|
|
("authorization_pending", "waiting"),
|
|
("slow_down", "waiting"),
|
|
("expired_token", "expired"),
|
|
("invalid_grant", "expired"),
|
|
("access_denied", "fail"),
|
|
],
|
|
)
|
|
async def test_poll_status_errors(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
mock_httpx_client,
|
|
error,
|
|
expected_status,
|
|
):
|
|
"""Should handle various error responses correctly."""
|
|
response = _mock_response({"error": error})
|
|
|
|
with patch("httpx.AsyncClient", mock_httpx_client(response)):
|
|
result = await feishu_handler.poll_status(
|
|
"device_123",
|
|
mock_request,
|
|
)
|
|
assert result.status == expected_status
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_status_network_error(
|
|
self,
|
|
feishu_handler,
|
|
mock_request,
|
|
):
|
|
"""Should raise HTTPException on network error."""
|
|
mock_client = MagicMock()
|
|
mock_client.post = AsyncMock(side_effect=Exception("Network error"))
|
|
|
|
mock_async_client = MagicMock()
|
|
mock_async_client.return_value.__aenter__ = AsyncMock(
|
|
return_value=mock_client,
|
|
)
|
|
mock_async_client.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("httpx.AsyncClient", mock_async_client):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await feishu_handler.poll_status("device_123", mock_request)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert "status check failed" in str(exc.value.detail)
|
|
|
|
|
|
class TestQRCodeAuthHandlerRegistry:
|
|
"""Tests for the global handler registry."""
|
|
|
|
def test_registry_contains_all_channels(self):
|
|
"""Should contain handlers for all supported channels."""
|
|
from pineagents.app.channels.qrcode_auth_handler import (
|
|
QRCODE_AUTH_HANDLERS,
|
|
)
|
|
|
|
expected_channels = {"wechat", "wecom", "dingtalk", "feishu", "qq"}
|
|
assert set(QRCODE_AUTH_HANDLERS.keys()) == expected_channels
|
|
|
|
def test_registry_handlers_are_correct_type(self):
|
|
"""Should contain FeishuQRCodeAuthHandler for feishu."""
|
|
from pineagents.app.channels.qrcode_auth_handler import (
|
|
QRCODE_AUTH_HANDLERS,
|
|
FeishuQRCodeAuthHandler,
|
|
QRCodeAuthHandler,
|
|
)
|
|
|
|
# All handlers should be QRCodeAuthHandler instances
|
|
for handler in QRCODE_AUTH_HANDLERS.values():
|
|
assert isinstance(handler, QRCodeAuthHandler)
|
|
|
|
# Feishu handler should be FeishuQRCodeAuthHandler
|
|
assert isinstance(
|
|
QRCODE_AUTH_HANDLERS["feishu"],
|
|
FeishuQRCodeAuthHandler,
|
|
)
|