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