feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
268 lines
8.3 KiB
Python
268 lines
8.3 KiB
Python
import sys
|
|
from types import ModuleType
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.core.config import get_settings
|
|
from app.modules.feishu.client import FeishuClient
|
|
from app.modules.feishu.errors import FeishuAPIError
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, payload: Any, status_code: int = 200):
|
|
self.payload = payload
|
|
self.status_code = status_code
|
|
|
|
def json(self) -> Any:
|
|
if isinstance(self.payload, Exception):
|
|
raise self.payload
|
|
return self.payload
|
|
|
|
|
|
class _HTTPClient:
|
|
responses: list[_Response] = []
|
|
requests: list[dict[str, Any]] = []
|
|
|
|
def __init__(self, *, timeout: int):
|
|
self.timeout = timeout
|
|
|
|
def __enter__(self) -> "_HTTPClient":
|
|
return self
|
|
|
|
def __exit__(self, *_: Any) -> None:
|
|
return None
|
|
|
|
def post(self, url: str, **kwargs: Any) -> _Response:
|
|
self.requests.append({"url": url, "timeout": self.timeout, **kwargs})
|
|
return self.responses.pop(0)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_settings_and_http(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_HTTPClient.responses = []
|
|
_HTTPClient.requests = []
|
|
monkeypatch.setattr("app.modules.feishu.client.httpx.Client", _HTTPClient)
|
|
get_settings.cache_clear()
|
|
yield
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def _configure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
app_type: str,
|
|
app_ticket: str = "",
|
|
) -> None:
|
|
monkeypatch.setenv("FEISHU_BASE_URL", "https://open.feishu.test/open-apis")
|
|
monkeypatch.setenv("FEISHU_APP_ID", "cli-test")
|
|
monkeypatch.setenv("FEISHU_APP_SECRET", "app-secret-value")
|
|
monkeypatch.setenv("FEISHU_APP_TYPE", app_type)
|
|
monkeypatch.setenv("FEISHU_APP_TICKET", app_ticket)
|
|
monkeypatch.delenv("FEISHU_DEFAULT_TENANT_KEY", raising=False)
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_self_app_uses_internal_tenant_token_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="self")
|
|
_HTTPClient.responses = [
|
|
_Response(
|
|
{
|
|
"code": 0,
|
|
"tenant_access_token": "self-tenant-token",
|
|
"expire": 7200,
|
|
}
|
|
)
|
|
]
|
|
|
|
token = FeishuClient()._get_tenant_access_token("ignored-tenant")
|
|
|
|
assert token == "self-tenant-token"
|
|
assert len(_HTTPClient.requests) == 1
|
|
request = _HTTPClient.requests[0]
|
|
assert request["url"].endswith("/auth/v3/tenant_access_token/internal")
|
|
assert request["json"] == {
|
|
"app_id": "cli-test",
|
|
"app_secret": "app-secret-value",
|
|
}
|
|
|
|
|
|
def test_store_app_caches_app_token_and_isolates_tenant_tokens(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
|
_HTTPClient.responses = [
|
|
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
|
_Response(
|
|
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
|
|
),
|
|
_Response(
|
|
{"code": 0, "tenant_access_token": "tenant-b-token", "expire": 7200}
|
|
),
|
|
]
|
|
client = FeishuClient()
|
|
|
|
tenant_a = client._get_tenant_access_token("tenant-a")
|
|
tenant_b = client._get_tenant_access_token("tenant-b")
|
|
tenant_a_again = client._get_tenant_access_token("tenant-a")
|
|
|
|
assert (tenant_a, tenant_b, tenant_a_again) == (
|
|
"tenant-a-token",
|
|
"tenant-b-token",
|
|
"tenant-a-token",
|
|
)
|
|
assert len(_HTTPClient.requests) == 3
|
|
app_request, tenant_a_request, tenant_b_request = _HTTPClient.requests
|
|
assert app_request["url"].endswith("/auth/v3/app_access_token")
|
|
assert app_request["json"] == {
|
|
"app_id": "cli-test",
|
|
"app_secret": "app-secret-value",
|
|
"app_ticket": "latest-ticket",
|
|
}
|
|
assert tenant_a_request["url"].endswith("/auth/v3/tenant_access_token")
|
|
assert tenant_a_request["json"] == {
|
|
"app_access_token": "app-token",
|
|
"tenant_key": "tenant-a",
|
|
}
|
|
assert tenant_b_request["json"] == {
|
|
"app_access_token": "app-token",
|
|
"tenant_key": "tenant-b",
|
|
}
|
|
|
|
|
|
def test_store_app_prefers_persisted_ticket(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="environment-ticket")
|
|
|
|
class _TicketService:
|
|
def __init__(self, db: object):
|
|
assert db is database
|
|
|
|
def get_ticket(self, app_id: str) -> str:
|
|
assert app_id == "cli-test"
|
|
return "persisted-ticket"
|
|
|
|
database = object()
|
|
ticket_module = ModuleType("app.modules.feishu.app_tickets")
|
|
ticket_module.FeishuAppTicketService = _TicketService
|
|
monkeypatch.setitem(sys.modules, ticket_module.__name__, ticket_module)
|
|
_HTTPClient.responses = [
|
|
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
|
_Response(
|
|
{"code": 0, "tenant_access_token": "tenant-token", "expire": 7200}
|
|
),
|
|
]
|
|
|
|
FeishuClient(database)._get_tenant_access_token("tenant-a")
|
|
|
|
assert _HTTPClient.requests[0]["json"]["app_ticket"] == "persisted-ticket"
|
|
|
|
|
|
def test_store_app_requires_tenant_key_before_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
FeishuClient()._get_tenant_access_token()
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert exc_info.value.detail == "tenant_key is required for Feishu store apps"
|
|
assert _HTTPClient.requests == []
|
|
|
|
|
|
def test_store_app_requires_ticket_before_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
FeishuClient()._get_tenant_access_token("tenant-a")
|
|
|
|
assert exc_info.value.status_code == 503
|
|
assert exc_info.value.detail == "Feishu store app ticket is not available"
|
|
assert _HTTPClient.requests == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status_code", "retryable"),
|
|
[(401, False), (429, True), (500, True)],
|
|
)
|
|
def test_token_http_errors_are_classified_without_exposing_secrets(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status_code: int,
|
|
retryable: bool,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="secret-ticket-value")
|
|
_HTTPClient.responses = [
|
|
_Response(
|
|
{
|
|
"code": 1,
|
|
"app_ticket": "secret-ticket-value",
|
|
"tenant_access_token": "secret-token-value",
|
|
},
|
|
status_code=status_code,
|
|
)
|
|
]
|
|
|
|
with pytest.raises(FeishuAPIError) as exc_info:
|
|
FeishuClient()._get_tenant_access_token("tenant-a")
|
|
|
|
error = exc_info.value
|
|
assert error.retryable is retryable
|
|
serialized = f"{error.detail} {error.provider_response}"
|
|
assert "secret-ticket-value" not in serialized
|
|
assert "secret-token-value" not in serialized
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "retryable"),
|
|
[
|
|
({"code": 99991400, "msg": "rate limited"}, True),
|
|
({"code": 10003, "msg": "invalid app credentials"}, False),
|
|
],
|
|
)
|
|
def test_token_business_errors_are_classified(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
payload: dict[str, Any],
|
|
retryable: bool,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
|
_HTTPClient.responses = [_Response(payload)]
|
|
|
|
with pytest.raises(FeishuAPIError) as exc_info:
|
|
FeishuClient()._get_tenant_access_token("tenant-a")
|
|
|
|
assert exc_info.value.provider_code == payload["code"]
|
|
assert exc_info.value.retryable is retryable
|
|
|
|
|
|
def test_send_text_uses_the_requested_tenant_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_configure(monkeypatch, app_type="store", app_ticket="latest-ticket")
|
|
_HTTPClient.responses = [
|
|
_Response({"code": 0, "app_access_token": "app-token", "expire": 7200}),
|
|
_Response(
|
|
{"code": 0, "tenant_access_token": "tenant-a-token", "expire": 7200}
|
|
),
|
|
_Response({"code": 0, "data": {"message_id": "om-message"}}),
|
|
]
|
|
|
|
result = FeishuClient().send_text(
|
|
"hello",
|
|
receive_id="ou-user",
|
|
receive_id_type="open_id",
|
|
uuid="stable-uuid",
|
|
tenant_key="tenant-a",
|
|
)
|
|
|
|
assert result["code"] == 0
|
|
message_request = _HTTPClient.requests[2]
|
|
assert message_request["headers"]["Authorization"] == "Bearer tenant-a-token"
|
|
assert message_request["json"]["uuid"] == "stable-uuid"
|