Files
company-ai-platform/tests/test_feishu_multitenant_auth.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

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"