mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
337 lines
10 KiB
Python
337 lines
10 KiB
Python
"""
|
||
GitHub Token 设备授权应用服务的协议边界测试。
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
from threading import RLock
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from app.agent.llm import auth as llm_auth
|
||
from app.agent.llm.auth import _ProviderAuth
|
||
from app.agent.llm.catalog import ProviderAuthMethod, ProviderSpec
|
||
from app.agent.llm.session import _ProviderSession
|
||
from app.application.github_auth import GithubAuthService
|
||
from app.domain.github import GithubDeviceCode, GithubTokenExchange, GithubUser
|
||
from app.schemas.types import SystemConfigKey
|
||
|
||
|
||
class _CopilotAuthHarness(_ProviderAuth, _ProviderSession):
|
||
"""
|
||
提供 Copilot 设备授权测试所需的最小 Provider 运行时。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
"""
|
||
初始化临时会话和保存结果。
|
||
"""
|
||
self._lock = RLock()
|
||
self._pending_sessions = {}
|
||
self._oauth_state_index = {}
|
||
self.saved_auth: dict[str, dict] = {}
|
||
|
||
async def _get_provider_async(self, provider_id: str, **kwargs) -> ProviderSpec:
|
||
"""
|
||
返回支持设备授权的 Copilot 测试 Provider。
|
||
"""
|
||
assert provider_id == "github-copilot"
|
||
return ProviderSpec(
|
||
id=provider_id,
|
||
name="GitHub Copilot",
|
||
runtime="github_copilot",
|
||
oauth_methods=(
|
||
ProviderAuthMethod(
|
||
id="device_code",
|
||
type="device",
|
||
label="GitHub 设备码授权",
|
||
),
|
||
),
|
||
)
|
||
|
||
async def save_auth(self, provider_id: str, auth_data: dict) -> None:
|
||
"""
|
||
保存测试中的 Provider 授权结果。
|
||
"""
|
||
self.saved_auth[provider_id] = auth_data
|
||
|
||
|
||
class _FakeCopilotGithubClient:
|
||
"""
|
||
模拟公共 GitHub 传输模块供 Copilot 授权测试复用。
|
||
"""
|
||
|
||
polled = False
|
||
|
||
async def request_device_code(
|
||
self,
|
||
client_id: str,
|
||
scope: str = "read:user",
|
||
) -> GithubDeviceCode:
|
||
"""
|
||
返回公共模块规范化的设备码。
|
||
"""
|
||
assert client_id == llm_auth.GITHUB_DEVICE_CLIENT_ID
|
||
assert scope == "read:user"
|
||
return GithubDeviceCode(
|
||
"copilot-device",
|
||
"COPI-LOT",
|
||
"https://github.com/login/device",
|
||
900,
|
||
5,
|
||
)
|
||
|
||
async def exchange_device_code(
|
||
self,
|
||
client_id: str,
|
||
device_code: str,
|
||
) -> GithubTokenExchange:
|
||
"""
|
||
返回先 pending 后成功的公共模块交换结果。
|
||
"""
|
||
assert client_id == llm_auth.GITHUB_DEVICE_CLIENT_ID
|
||
assert device_code == "copilot-device"
|
||
if not type(self).polled:
|
||
type(self).polled = True
|
||
return GithubTokenExchange(error="authorization_pending")
|
||
return GithubTokenExchange(access_token="copilot-access")
|
||
|
||
|
||
class _FakeSettings:
|
||
"""
|
||
提供 GitHub 授权服务所需的最小运行设置端口。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
"""
|
||
初始化空 Token。
|
||
"""
|
||
self.values = {"GITHUB_TOKEN": None}
|
||
|
||
def get(self, key: str, default=None):
|
||
"""
|
||
读取测试运行设置。
|
||
"""
|
||
return self.values.get(key, default)
|
||
|
||
def update(self, key: str, value):
|
||
"""
|
||
记录并更新测试运行设置。
|
||
"""
|
||
self.values[key] = value
|
||
return True, ""
|
||
|
||
|
||
class _FakeSystemConfig:
|
||
"""
|
||
提供 OAuth 元数据的异步配置端口。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
"""
|
||
初始化空元数据。
|
||
"""
|
||
self.values = {}
|
||
|
||
def get(self, key):
|
||
"""
|
||
读取测试系统配置。
|
||
"""
|
||
return self.values.get(key)
|
||
|
||
async def async_set(self, key, value):
|
||
"""
|
||
保存测试系统配置。
|
||
"""
|
||
self.values[key] = value
|
||
return True
|
||
|
||
async def async_delete(self, key):
|
||
"""
|
||
删除测试系统配置。
|
||
"""
|
||
self.values.pop(key, None)
|
||
|
||
|
||
@dataclass
|
||
class _FakeTransport:
|
||
"""
|
||
模拟 GitHub 设备授权、用户校验和刷新响应。
|
||
|
||
:param exchanges: 设备码轮询按顺序消费的令牌交换结果
|
||
:param refresh_exchange: 预设刷新结果;None 表示尚未配置,调用刷新前必须设置
|
||
"""
|
||
|
||
exchanges: list[GithubTokenExchange]
|
||
refresh_exchange: GithubTokenExchange | None = None
|
||
|
||
async def request_device_code(
|
||
self,
|
||
client_id: str,
|
||
scope: str = "read:user",
|
||
) -> GithubDeviceCode:
|
||
"""
|
||
返回固定的设备码。
|
||
"""
|
||
assert client_id == "client-id"
|
||
assert scope == "read:user repo workflow"
|
||
return GithubDeviceCode("device", "ABCD-EFGH", "https://github.com/login/device", 900, 5)
|
||
|
||
async def exchange_device_code(self, client_id: str, device_code: str) -> GithubTokenExchange:
|
||
"""
|
||
按测试顺序返回设备授权响应。
|
||
"""
|
||
assert client_id == "client-id"
|
||
assert device_code == "device"
|
||
return self.exchanges.pop(0)
|
||
|
||
async def refresh_access_token(self, client_id: str, refresh_token: str) -> GithubTokenExchange:
|
||
"""
|
||
返回固定的刷新响应。
|
||
"""
|
||
assert client_id == "client-id"
|
||
assert refresh_token == "refresh-old"
|
||
assert self.refresh_exchange is not None
|
||
return self.refresh_exchange
|
||
|
||
async def get_user(self, access_token: str) -> GithubUser:
|
||
"""
|
||
返回 Token 对应的测试用户。
|
||
"""
|
||
assert access_token
|
||
return GithubUser(login="moviepilot")
|
||
|
||
|
||
def _build_service(clock) -> tuple[GithubAuthService, _FakeSettings, _FakeSystemConfig]:
|
||
"""
|
||
构造不访问真实网络的 GitHub 授权服务。
|
||
"""
|
||
settings = _FakeSettings()
|
||
system_config = _FakeSystemConfig()
|
||
transport = _FakeTransport(
|
||
exchanges=[
|
||
GithubTokenExchange(error="authorization_pending"),
|
||
GithubTokenExchange(
|
||
access_token="gho_access_token",
|
||
refresh_token="refresh-old",
|
||
expires_in=3600,
|
||
scope="read:user repo workflow",
|
||
),
|
||
]
|
||
)
|
||
service = GithubAuthService(
|
||
# 测试替身仅实现当前用例需要的设置接口
|
||
settings=settings, # type: ignore[arg-type]
|
||
# 内存配置替身用于隔离真实数据库写入
|
||
system_config=system_config, # type: ignore[arg-type]
|
||
transport=transport,
|
||
client_id="client-id",
|
||
clock=clock,
|
||
)
|
||
return service, settings, system_config
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_device_flow_persists_only_server_side_token_state() -> None:
|
||
"""
|
||
设备授权成功后前端状态只能看到脱敏摘要,刷新令牌不能进入响应。
|
||
"""
|
||
now = [1000.0]
|
||
service, settings, system_config = _build_service(lambda: now[0])
|
||
|
||
started = await service.start_device_auth()
|
||
pending = await service.poll_device_auth(started.session_id)
|
||
assert pending.state == "pending"
|
||
|
||
now[0] = 1005.0
|
||
authorized = await service.poll_device_auth(started.session_id)
|
||
|
||
assert authorized.state == "authorized"
|
||
assert settings.values["GITHUB_TOKEN"] == "gho_access_token"
|
||
assert authorized.status is not None
|
||
assert authorized.status.masked_token == "gho_…oken"
|
||
assert authorized.status.source == "oauth"
|
||
metadata = system_config.values[SystemConfigKey.GithubAuth]
|
||
assert metadata["refresh_token"] == "refresh-old"
|
||
assert "access_token" not in metadata
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_expiring_oauth_token_refreshes_before_status_validation() -> None:
|
||
"""
|
||
OAuth Token 临近过期时,状态查询应刷新访问 Token 并更新元数据。
|
||
"""
|
||
now = [2000.0]
|
||
service, settings, system_config = _build_service(lambda: now[0])
|
||
settings.values["GITHUB_TOKEN"] = "old-access"
|
||
system_config.values[SystemConfigKey.GithubAuth] = {
|
||
"login": "moviepilot",
|
||
"refresh_token": "refresh-old",
|
||
"expires_at": 2000,
|
||
}
|
||
transport = service._transport
|
||
assert isinstance(transport, _FakeTransport)
|
||
transport.refresh_exchange = GithubTokenExchange(
|
||
access_token="new-access",
|
||
refresh_token="refresh-new",
|
||
expires_in=3600,
|
||
)
|
||
|
||
status = await service.status()
|
||
|
||
assert settings.values["GITHUB_TOKEN"] == "new-access"
|
||
assert status.valid is True
|
||
assert status.source == "oauth"
|
||
assert system_config.values[SystemConfigKey.GithubAuth]["refresh_token"] == "refresh-new"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_device_flow_accepts_alternate_expired_error_code() -> None:
|
||
"""
|
||
设备码过期响应的两种 GitHub 错误拼写都应收敛为 expired 状态。
|
||
"""
|
||
now = [2500.0]
|
||
service, _, _ = _build_service(lambda: now[0])
|
||
transport = service._transport
|
||
assert isinstance(transport, _FakeTransport)
|
||
transport.exchanges = [GithubTokenExchange(error="token_expired")]
|
||
|
||
started = await service.start_device_auth()
|
||
result = await service.poll_device_auth(started.session_id)
|
||
|
||
assert result.state == "expired"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_manual_token_clears_oauth_metadata() -> None:
|
||
"""
|
||
手动 PAT 兼容入口应切换来源并清理旧 OAuth 刷新信息。
|
||
"""
|
||
now = [3000.0]
|
||
service, settings, system_config = _build_service(lambda: now[0])
|
||
system_config.values[SystemConfigKey.GithubAuth] = {"refresh_token": "refresh-old"}
|
||
|
||
status = await service.set_manual_token("github_pat_manual")
|
||
|
||
assert settings.values["GITHUB_TOKEN"] == "github_pat_manual"
|
||
assert status.source == "manual"
|
||
assert SystemConfigKey.GithubAuth not in system_config.values
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_copilot_reuses_common_github_device_flow_client() -> None:
|
||
"""
|
||
Copilot 设备授权应复用公共 GitHub 模块,而不是重复 HTTP 协议实现。
|
||
"""
|
||
harness = _CopilotAuthHarness()
|
||
|
||
with patch.object(llm_auth, "GithubAuthClient", _FakeCopilotGithubClient):
|
||
started = await harness.start_auth("github-copilot", "device_code")
|
||
pending = await harness.poll_auth_session(started["session_id"])
|
||
authorized = await harness.poll_auth_session(started["session_id"])
|
||
|
||
assert started["user_code"] == "COPI-LOT"
|
||
assert pending["status"] == "pending"
|
||
assert authorized["status"] == "authorized"
|
||
assert harness.saved_auth["github-copilot"]["access_token"] == "copilot-access"
|