"""K1.3 单测:Kimi Code OAuth device-flow 服务(fake httpx,绝不联网)。 覆盖:start_device_authorization、poll_token(成功/pending/slow_down/失败)、refresh、 encrypt/decrypt round-trip、needs_refresh 启发式。 """ from __future__ import annotations from datetime import UTC, datetime, timedelta from typing import Any import pytest from cryptography.fernet import Fernet from ww_api.services.kimi_oauth import ( DEVICE_AUTHORIZATION_URL, TOKEN_URL, AuthorizationPending, SlowDown, TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle, needs_refresh, poll_token, refresh, start_device_authorization, ) from ww_shared import AppError, ErrorCode class _FakeResponse: def __init__(self, status_code: int, body: dict[str, Any]) -> None: self.status_code = status_code self._body = body def json(self) -> Any: return self._body class _FakeHttp: """记录每次 post 的 url + data,按 url 返回预设响应。""" def __init__(self, responses: list[_FakeResponse]) -> None: self._responses = responses self.calls: list[tuple[str, dict[str, str]]] = [] async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse: self.calls.append((url, data)) return self._responses[len(self.calls) - 1] @pytest.mark.asyncio async def test_start_device_authorization_parses_response_and_sends_scope() -> None: http = _FakeHttp( [ _FakeResponse( 200, { "device_code": "dev-123", "user_code": "ABCD-1234", "verification_uri": "https://kimi.com/device", "verification_uri_complete": "https://kimi.com/device?code=ABCD-1234", "expires_in": 600, "interval": 5, }, ) ] ) device = await start_device_authorization(http) assert device.device_code == "dev-123" assert device.user_code == "ABCD-1234" assert device.interval == 5 assert device.expires_in == 600 # 请求打到 device authorization 端点,且**带 scope=kimi-code**(coding entitlement)。 url, data = http.calls[0] assert url == DEVICE_AUTHORIZATION_URL assert "client_id" in data assert data["scope"] == "kimi-code" @pytest.mark.asyncio async def test_poll_token_success_returns_token_set() -> None: http = _FakeHttp( [ _FakeResponse( 200, { "access_token": "acc-xyz", "refresh_token": "ref-xyz", "expires_in": 900, }, ) ] ) token = await poll_token(http, "dev-123") assert token.access_token == "acc-xyz" assert token.refresh_token == "ref-xyz" assert token.expires_at > datetime.now(UTC) # 打到 token 端点 + device_code grant。 url, data = http.calls[0] assert url == TOKEN_URL assert data["device_code"] == "dev-123" assert data["grant_type"].endswith("device_code") @pytest.mark.asyncio async def test_poll_token_authorization_pending_raises_pending() -> None: http = _FakeHttp([_FakeResponse(400, {"error": "authorization_pending"})]) with pytest.raises(AuthorizationPending): await poll_token(http, "dev-123") @pytest.mark.asyncio async def test_poll_token_slow_down_raises_slow_down() -> None: http = _FakeHttp([_FakeResponse(400, {"error": "slow_down"})]) with pytest.raises(SlowDown): await poll_token(http, "dev-123") @pytest.mark.asyncio async def test_poll_token_expired_raises_app_error() -> None: http = _FakeHttp([_FakeResponse(400, {"error": "expired_token"})]) with pytest.raises(AppError) as exc: await poll_token(http, "dev-123") assert exc.value.code == ErrorCode.LLM_UNAVAILABLE @pytest.mark.asyncio async def test_refresh_returns_new_token_set() -> None: http = _FakeHttp( [ _FakeResponse( 200, {"access_token": "new-acc", "refresh_token": "new-ref", "expires_in": 900} ) ] ) token = await refresh(http, "ref-old") assert token.access_token == "new-acc" assert token.refresh_token == "new-ref" url, data = http.calls[0] assert url == TOKEN_URL assert data["grant_type"] == "refresh_token" assert data["refresh_token"] == "ref-old" @pytest.mark.asyncio async def test_refresh_failure_raises_app_error() -> None: http = _FakeHttp([_FakeResponse(400, {"error": "invalid_grant"})]) with pytest.raises(AppError) as exc: await refresh(http, "ref-bad") assert exc.value.code == ErrorCode.LLM_UNAVAILABLE def test_encrypt_decrypt_oauth_bundle_round_trip() -> None: key = Fernet.generate_key().decode() expires = datetime.now(UTC) + timedelta(seconds=900) token = TokenSet(access_token="acc", refresh_token="ref", expires_at=expires) blob = encrypt_oauth_bundle(token, key=key) restored = decrypt_oauth_bundle(blob, key=key) assert restored.access_token == "acc" assert restored.refresh_token == "ref" assert restored.expires_at == expires # 密文不含明文 token(加密真生效)。 assert b"acc" not in blob assert b"ref" not in blob def test_needs_refresh_true_when_near_expiry() -> None: now = datetime.now(UTC) near = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=60)) assert needs_refresh(near, now=now) is True def test_needs_refresh_false_when_fresh() -> None: now = datetime.now(UTC) fresh = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=900)) assert needs_refresh(fresh, now=now) is False