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>
214 lines
8.0 KiB
Python
214 lines
8.0 KiB
Python
"""K1.3 端点测试:Kimi Code OAuth start/disconnect/status(内存替身,无 DB/无网络)。
|
||
|
||
覆盖:
|
||
- POST .../start → 202 + user_code/verification_uri + 创建 job;后台轮询 poll_token 成功
|
||
→ 存加密 oauth_enc + job done;**token 不进 job 结果/响应**;
|
||
- 后台轮询 authorization_pending → 继续轮询直到成功;
|
||
- POST .../disconnect → 清除凭据行;
|
||
- GET .../status → connected 真值(无 token 本体)。
|
||
|
||
后台 work 用 `run_job` 自建独立 session(请求 session 已关闭)——测试经依赖 override
|
||
注 fake http + fake store(共享实例,断后台落库)+ FakeSessionFactory;monkeypatch
|
||
`kimi_oauth.asyncio.sleep` 为 no-op(避免真等 interval 秒)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_providers import FakeCredentialStore
|
||
from fastapi.testclient import TestClient
|
||
from ww_api.services.credentials import STUB_OWNER_ID
|
||
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
|
||
from ww_db import get_session
|
||
|
||
|
||
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 _ScriptedHttp:
|
||
"""按调用序号返回预设响应(device auth → poll attempts)。aclose 无操作。"""
|
||
|
||
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:
|
||
idx = len(self.calls)
|
||
self.calls.append((url, data))
|
||
return self._responses[idx]
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
|
||
def _make_client(
|
||
*,
|
||
store: FakeCredentialStore,
|
||
http: _ScriptedHttp,
|
||
enc_key: str,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> TestClient:
|
||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||
from ww_config import get_settings
|
||
|
||
get_settings.cache_clear()
|
||
|
||
from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory
|
||
from ww_api import routers
|
||
from ww_api.main import create_app
|
||
from ww_api.services import job_runner
|
||
from ww_api.services.project_deps import (
|
||
get_job_repo,
|
||
get_session_factory,
|
||
)
|
||
from ww_api.services.provider_deps import get_credential_store
|
||
|
||
# 后台轮询不真等 interval 秒。
|
||
async def _no_sleep(_seconds: float) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
|
||
# 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store(断后台落库)。
|
||
monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store)
|
||
# 后台 work 内调模块级 `_default_http_client()` 自建 http(非 dep)——monkeypatch 它指到
|
||
# scripted fake,否则后台轮询会真联网(见 device-flow 注入纪律)。
|
||
monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http)
|
||
# `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute,
|
||
# 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake(见 memory/gotchas)。
|
||
job_repo = FakeJobRepo()
|
||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_credential_store] = lambda: store
|
||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
||
return TestClient(app)
|
||
|
||
|
||
def _device_auth_response() -> _FakeResponse:
|
||
return _FakeResponse(
|
||
200,
|
||
{
|
||
"device_code": "dev-1",
|
||
"user_code": "WXYZ-9999",
|
||
"verification_uri": "https://kimi.com/device",
|
||
"verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999",
|
||
"expires_in": 600,
|
||
"interval": 5,
|
||
},
|
||
)
|
||
|
||
|
||
def _token_response() -> _FakeResponse:
|
||
return _FakeResponse(
|
||
200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900}
|
||
)
|
||
|
||
|
||
def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
enc_key = Fernet.generate_key().decode()
|
||
store = FakeCredentialStore()
|
||
# device auth → poll #1 success(TestClient 同步跑完 background task)。
|
||
http = _ScriptedHttp([_device_auth_response(), _token_response()])
|
||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||
|
||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||
|
||
assert resp.status_code == 202
|
||
body = resp.json()
|
||
assert body["user_code"] == "WXYZ-9999"
|
||
assert body["verification_uri"] == "https://kimi.com/device"
|
||
assert body["interval"] == 5
|
||
assert "job_id" in body
|
||
# 响应绝不含 token。
|
||
assert "secret-acc" not in resp.text
|
||
assert "secret-ref" not in resp.text
|
||
|
||
# 后台轮询成功 → 加密 oauth 凭据落库(共享 store)。
|
||
cred = store.oauth.get((STUB_OWNER_ID, "kimi-code"))
|
||
assert cred is not None
|
||
token = decrypt_oauth_bundle(cred, key=enc_key)
|
||
assert token.access_token == "secret-acc"
|
||
assert token.refresh_token == "secret-ref"
|
||
|
||
|
||
def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
enc_key = Fernet.generate_key().decode()
|
||
store = FakeCredentialStore()
|
||
http = _ScriptedHttp(
|
||
[
|
||
_device_auth_response(),
|
||
_FakeResponse(400, {"error": "authorization_pending"}),
|
||
_token_response(),
|
||
]
|
||
)
|
||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||
|
||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||
|
||
assert resp.status_code == 202
|
||
# pending 后继续轮询 → 第二次成功落库。
|
||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||
|
||
|
||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
enc_key = Fernet.generate_key().decode()
|
||
store = FakeCredentialStore()
|
||
store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob"
|
||
http = _ScriptedHttp([])
|
||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||
|
||
resp = client.post("/settings/providers/kimi-code/oauth/disconnect")
|
||
|
||
assert resp.status_code == 200
|
||
assert resp.json()["disconnected"] is True
|
||
assert (STUB_OWNER_ID, "kimi-code") not in store.oauth
|
||
|
||
|
||
def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
|
||
|
||
enc_key = Fernet.generate_key().decode()
|
||
store = FakeCredentialStore()
|
||
expires = datetime.now(UTC) + timedelta(seconds=900)
|
||
token = TokenSet(access_token="a", refresh_token="r", expires_at=expires)
|
||
store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key)
|
||
http = _ScriptedHttp([])
|
||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||
|
||
resp = client.get("/settings/providers/kimi-code/oauth/status")
|
||
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["connected"] is True
|
||
assert body["expires_at"] is not None
|
||
# 状态绝不回 token 本体。
|
||
assert "access_token" not in body
|
||
assert "refresh_token" not in body
|
||
|
||
|
||
def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
enc_key = Fernet.generate_key().decode()
|
||
store = FakeCredentialStore()
|
||
http = _ScriptedHttp([])
|
||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||
|
||
resp = client.get("/settings/providers/kimi-code/oauth/status")
|
||
|
||
assert resp.status_code == 200
|
||
assert resp.json()["connected"] is False
|