```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
267
tests/test_feishu_multitenant_auth.py
Normal file
267
tests/test_feishu_multitenant_auth.py
Normal file
@@ -0,0 +1,267 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user