Files
writer-work-flow/apps/api/tests/test_kimi_oauth_service.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

185 lines
5.7 KiB
Python
Raw 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.

"""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