Files
MoviePilot/tests/test_github_auth.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

337 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"