"""K1 端到端:Kimi Code OAuth device-flow(device/token/refresh **全 mock**)→ 真 pg。 证明 K1 闭环(DoD:device-flow 连接 → 加密 token 持久化(DB 真源,无明文) → 网关建出 KimiCodeAdapter(coding base + 伪造头 + bearer) → 建网关时按需刷新更新存量 token → 断开清除)。 **绝不联网**:device authorization / token / refresh 全经注入的 fake httpx 客户端, auth.kimi.com / api.kimi.com 一次都不碰。 用例 1 · 连接 device flow → token 加密持久化(DB 真源) `POST .../oauth/start` → 202 `{job_id, user_code, verification_uri, ...}`。fake `device_authorization` 返 device_code;fake `token` 端点先 `authorization_pending` 两次、再成功 `{access_token, refresh_token, expires_in}`。后台 `run_job` 轮询循环跑在 **真 e2e_sm session** 上(ASGITransport await background task;override `get_session_factory`→e2e_sm、monkeypatch 模块级 `_default_http_client`→fake、 `asyncio.sleep`→no-op)。`GET /jobs/{id}` → done,result `{connected:true, provider:"kimi-code"}` 且**不含任何 token 串**。DB 真源:`provider_credentials` 行 `auth_type="oauth"`、`oauth_enc` 非空、`api_key_enc` 为 None;解密 `oauth_enc` 回环 access/refresh token(证明加密持久化、非明文)。`GET .../oauth/status` → `{connected:true, expires_at}` 无 token。 用例 2 · 网关建出 Kimi Code 适配器(正确 token + 伪造头 + coding base) 存 oauth 凭据 + `tier_routing` 行把某档位指向 `kimi-code:kimi-for-coding` → `build_gateway_for_tier(...,"writer")` → 断言适配器是 `KimiCodeAdapter`,其 `AsyncOpenAI` 客户端 `base_url=https://api.kimi.com/coding/v1`、带伪造 `User-Agent` + 全 `X-Msh-*` 默认头、`api_key`(=bearer) = 存量 access token。 用例 3 · 建网关时刷新(refresh-on-build) 存一个 `expires_at` 临近过期的 oauth 包;fake token 端点对 `grant_type=refresh_token` 返**新** token 组。`_build_provider_adapter(store,"kimi-code")`(经真 e2e_sm session) → 断言 refresh 被调、适配器用**新** access token、`oauth_enc`(DB 真源)已更新为新包。 用例 4 · 断开(disconnect) `POST .../oauth/disconnect` → 凭据行被清除(DB 真源:之后无 oauth 凭据)。 零成本 & 确定性:fake httpx(device/token/refresh)+ 真 `SqlCredentialStore`/真 pg。无 pg → skip。 坑(见 memory/gotchas): - 后台 work 自建 http 走**模块级** `routers.kimi_oauth._default_http_client()`(非 dep)—— 须 monkeypatch 它指到同一 scripted fake(端点 device-auth call[0] + work poll call[1..] 顺序共享);端点 device-auth 走 `http` dep,再 override `_default_http_client` dep。 - 真 pg 持久化:**不** monkeypatch `SqlCredentialStore`(让真 store 落库),override `get_session_factory`→`e2e_sm`(同测试 engine/loop)使后台 work 用真 session 写库。 - monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op(不真等 interval 秒)。 - `_resolve_kimi_code_token` 刷新走 `project_deps.httpx.AsyncClient` + `project_deps.kimi_refresh`—— 用字符串 target monkeypatch(mypy strict 不静态校验字符串属性)。 - token 绝不进 job 结果/状态响应/start 响应——显式负向断言。 - get_sessionmaker engine 绑首个 loop → 每 DB 测清缓存重建、结束 dispose(同 M3/M4/M5)。 """ from __future__ import annotations import os from collections.abc import AsyncIterator from datetime import UTC, datetime, timedelta from typing import Any import httpx import pytest from asgi_lifespan import LifespanManager from cryptography.fernet import Fernet from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ww_db import get_sessionmaker from ww_db.models import Job, ProviderCredential, TierRouting from ww_llm_gateway.adapters.kimi_code import ( KIMI_CODE_BASE_URL, KIMI_CODE_PROVIDER, KIMI_CODE_USER_AGENT, KimiCodeAdapter, ) _ACCESS_TOKEN = "kimi-acc-e2e-not-real-12345" _REFRESH_TOKEN = "kimi-ref-e2e-not-real-67890" _NEW_ACCESS_TOKEN = "kimi-acc-e2e-refreshed-abcde" _NEW_REFRESH_TOKEN = "kimi-ref-e2e-refreshed-fghij" _OAUTH_BASE = "/settings/providers/kimi-code/oauth" _START_URL = f"{_OAUTH_BASE}/start" _DISCONNECT_URL = f"{_OAUTH_BASE}/disconnect" _STATUS_URL = f"{_OAUTH_BASE}/status" _REQUIRED_HEADER_KEYS = ( "User-Agent", "X-Msh-Platform", "X-Msh-Version", "X-Msh-Device-Name", "X-Msh-Device-Model", "X-Msh-Os-Version", "X-Msh-Device-Id", ) class _FakeResponse: """`HttpResponse` Protocol 最小实现(status_code + json())。""" 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 无操作。 端点的 device authorization 调用是 call[0],后台 work 的 token 轮询是 call[1..]。 同一实例供两者顺序共享(须 monkeypatch 模块级 `_default_http_client` 指到它)。 """ 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 _device_auth_response() -> _FakeResponse: return _FakeResponse( 200, { "device_code": "dev-e2e-1", "user_code": "ABCD-1234", "verification_uri": "https://auth.kimi.com/device", "verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234", "expires_in": 600, "interval": 5, }, ) def _pending_response() -> _FakeResponse: return _FakeResponse(400, {"error": "authorization_pending"}) def _token_success_response() -> _FakeResponse: return _FakeResponse( 200, { "access_token": _ACCESS_TOKEN, "refresh_token": _REFRESH_TOKEN, "expires_in": 900, }, ) def _enc_key() -> str: """设置 `CREDENTIAL_ENC_KEY` 为有效 Fernet 密钥 + 清 settings 缓存,返回该密钥。""" key = Fernet.generate_key().decode() os.environ["CREDENTIAL_ENC_KEY"] = key from ww_config import get_settings get_settings.cache_clear() return key @pytest.fixture async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]: """真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。""" get_sessionmaker.cache_clear() maker = get_sessionmaker() try: async with maker() as probe: await probe.execute(select(1)) except Exception: pytest.skip("postgres not reachable") yield maker await maker.kw["bind"].dispose() get_sessionmaker.cache_clear() async def _cleanup_credentials(e2e_sm: async_sessionmaker[AsyncSession]) -> None: """清理本测试族写入的 kimi-code 凭据 + kimi_oauth job 行(单用户 stub owner)。""" from ww_api.services.credentials import STUB_OWNER_ID async with e2e_sm() as cleanup: await cleanup.execute( delete(ProviderCredential).where( ProviderCredential.owner_id == STUB_OWNER_ID, ProviderCredential.provider == KIMI_CODE_PROVIDER, ) ) await cleanup.execute(delete(Job).where(Job.kind == "kimi_oauth")) await cleanup.execute(delete(TierRouting).where(TierRouting.project_id.is_(None))) await cleanup.commit() async def _seed_oauth_credential(e2e_sm: async_sessionmaker[AsyncSession], blob: bytes) -> None: """直接经真 store 在真 pg 写一行 oauth 凭据(用例 2/3 前置)。""" from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore async with e2e_sm() as session: store = SqlCredentialStore(session) await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob) # store 只 flush;测试前置须显式提交,否则独立 session(建网关/校验)看不到(新契约)。 await session.commit() async def test_k1_connect_device_flow_persists_encrypted_token( e2e_sm: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch, ) -> None: """用例 1:device flow 连接 → 后台轮询(pending×2→成功) → 加密 token 真落 pg → status 已连接。 全程零联网(fake httpx),token 绝不进 job 结果/状态响应/start 响应(负向断言)。 """ import ww_api.routers.kimi_oauth as kimi_mod from ww_api.main import create_app from ww_api.services.credentials import ( AUTH_TYPE_OAUTH, STUB_OWNER_ID, SqlCredentialStore, ) from ww_api.services.kimi_oauth import decrypt_oauth_bundle from ww_api.services.project_deps import get_session_factory enc_key = _enc_key() # device auth(call0) → poll pending(call1) → poll pending(call2) → poll success(call3)。 http = _ScriptedHttp( [ _device_auth_response(), _pending_response(), _pending_response(), _token_success_response(), ] ) # 后台 work 不真等 interval 秒。 async def _no_sleep(_seconds: float) -> None: return None monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep) # 后台 work 自建 http 走模块级 `_default_http_client()`(非 dep)→ 指到同一 scripted fake。 monkeypatch.setattr(kimi_mod, "_default_http_client", lambda: http) app = create_app() # 端点 device-auth 走 http dep → 也指到同一 fake(保证顺序共享)。 app.dependency_overrides[kimi_mod._default_http_client] = lambda: http # 后台 work 用 run_job 自建独立 session → 真 e2e_sm(同测试 engine/loop),真 store 落库。 app.dependency_overrides[get_session_factory] = lambda: e2e_sm transport = httpx.ASGITransport(app=app) try: await _cleanup_credentials(e2e_sm) async with LifespanManager(app): async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: # 1) 发起 device flow → 202 + user_code/verification_uri(ASGITransport 等后台)。 start_resp = await client.post(_START_URL) assert start_resp.status_code == 202 start_body = start_resp.json() assert start_body["user_code"] == "ABCD-1234" assert start_body["verification_uri"] == "https://auth.kimi.com/device" assert start_body["interval"] == 5 job_id = start_body["job_id"] # start 响应绝不含 token。 assert _ACCESS_TOKEN not in start_resp.text assert _REFRESH_TOKEN not in start_resp.text # 2) 轮询 job → done,result 只 {connected, provider},无 token。 job_resp = await client.get(f"/jobs/{job_id}") assert job_resp.status_code == 200 job = job_resp.json() assert job["status"] == "done" assert job["kind"] == "kimi_oauth" assert job["result"] == {"connected": True, "provider": KIMI_CODE_PROVIDER} # job 整体(含 result)绝不泄露 token。 assert _ACCESS_TOKEN not in job_resp.text assert _REFRESH_TOKEN not in job_resp.text # 3) status → 已连接 + expires_at,无 token 本体。 status_resp = await client.get(_STATUS_URL) assert status_resp.status_code == 200 status_body = status_resp.json() assert status_body["connected"] is True assert status_body["expires_at"] is not None assert "access_token" not in status_body assert "refresh_token" not in status_body assert _ACCESS_TOKEN not in status_resp.text assert _REFRESH_TOKEN not in status_resp.text # 后台经历两次 pending 才成功(证明 pending 路径真走):device + 3 token = 4 调用。 assert len(http.calls) == 4 # DB 真源断言:oauth 凭据行加密持久化(非明文),解密回环 token。 async with e2e_sm() as verify: row = ( await verify.execute( select(ProviderCredential).where( ProviderCredential.owner_id == STUB_OWNER_ID, ProviderCredential.provider == KIMI_CODE_PROVIDER, ) ) ).scalar_one() assert row.auth_type == AUTH_TYPE_OAUTH assert row.oauth_enc is not None assert row.api_key_enc is None # 密文不是明文(token 不出现在原始字节里)。 assert _ACCESS_TOKEN.encode() not in row.oauth_enc assert _REFRESH_TOKEN.encode() not in row.oauth_enc # 解密回环:证明真的是这套 token 被加密持久化。 token = decrypt_oauth_bundle(row.oauth_enc, key=enc_key) assert token.access_token == _ACCESS_TOKEN assert token.refresh_token == _REFRESH_TOKEN # 经真 store 读 status 也是已连接(store 层一致)。 store = SqlCredentialStore(verify) cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER) assert cred is not None assert cred.auth_type == AUTH_TYPE_OAUTH finally: await _cleanup_credentials(e2e_sm) async def test_k1_gateway_builds_kimi_code_adapter_with_headers_and_bearer( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: """用例 2:存 oauth + 档位路由指向 kimi-code → 建网关 → 适配器带 coding base/伪造头/bearer。""" from ww_api.services.credentials import SqlCredentialStore, StoredRouting from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle from ww_api.services.project_deps import build_gateway_for_tier enc_key = _enc_key() fresh = TokenSet( access_token=_ACCESS_TOKEN, refresh_token=_REFRESH_TOKEN, expires_at=datetime.now(UTC) + timedelta(seconds=900), # 远未过期 → 不刷新 ) blob = encrypt_oauth_bundle(fresh, key=enc_key) try: await _cleanup_credentials(e2e_sm) await _seed_oauth_credential(e2e_sm, blob) # 档位路由:writer → kimi-code:kimi-for-coding(DB 真源)。 async with e2e_sm() as seed: store = SqlCredentialStore(seed) await store.upsert_routing( StoredRouting( tier="writer", provider=KIMI_CODE_PROVIDER, model="kimi-for-coding", fallback=[], ) ) # store 只 flush;显式提交使独立的建网关 session 可见该路由(新契约)。 await seed.commit() async with e2e_sm() as session: store = SqlCredentialStore(session) gateway = await build_gateway_for_tier(session, store, "writer") adapter = gateway._adapters[KIMI_CODE_PROVIDER] assert isinstance(adapter, KimiCodeAdapter) client = adapter._client # coding base。 assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/") # 伪造头:UA + 全 X-Msh-*。 for key in _REQUIRED_HEADER_KEYS: assert key in client.default_headers assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT # bearer = 存量 access token(SDK 据 api_key 自动发 Authorization: Bearer)。 assert client.api_key == _ACCESS_TOKEN # 未刷新 → DB oauth 凭据未变(仍是原 token)。 async with e2e_sm() as verify: from ww_api.services.kimi_oauth import decrypt_oauth_bundle row = ( await verify.execute( select(ProviderCredential).where( ProviderCredential.provider == KIMI_CODE_PROVIDER ) ) ).scalar_one() assert row.oauth_enc is not None assert decrypt_oauth_bundle(row.oauth_enc, key=enc_key).access_token == _ACCESS_TOKEN finally: await _cleanup_credentials(e2e_sm) async def test_k1_refresh_on_build_updates_stored_token( e2e_sm: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch, ) -> None: """用例 3:存量 token 临近过期 → 建适配器触发刷新 → 用新 token + DB oauth_enc 更新为新包。 刷新经 fake httpx + fake `kimi_refresh`(不联网);新包真落 pg(DB 真源)。 """ from ww_api.services import project_deps from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore from ww_api.services.kimi_oauth import ( TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle, ) from ww_api.services.project_deps import _build_provider_adapter enc_key = _enc_key() near = TokenSet( access_token=_ACCESS_TOKEN, refresh_token=_REFRESH_TOKEN, expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期 → 触发刷新 ) blob = encrypt_oauth_bundle(near, key=enc_key) refreshed = TokenSet( access_token=_NEW_ACCESS_TOKEN, refresh_token=_NEW_REFRESH_TOKEN, expires_at=datetime.now(UTC) + timedelta(seconds=900), ) refresh_calls: list[str] = [] async def _fake_refresh(_http: Any, refresh_token: str) -> TokenSet: # 证明用存量 refresh token 刷新。 refresh_calls.append(refresh_token) return refreshed class _FakeAsyncClient: async def __aenter__(self) -> _FakeAsyncClient: return self async def __aexit__(self, *exc: object) -> None: return None monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh) monkeypatch.setattr( "ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient() ) try: await _cleanup_credentials(e2e_sm) await _seed_oauth_credential(e2e_sm, blob) async with e2e_sm() as session: store = SqlCredentialStore(session) adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER) # refresh 被调,且用存量 refresh token。 assert refresh_calls == [_REFRESH_TOKEN] # 适配器用新 access token。 assert isinstance(adapter, KimiCodeAdapter) assert adapter._client.api_key == _NEW_ACCESS_TOKEN # DB 真源:oauth_enc 已更新为新包(下次建网关复用刷新结果)。 async with e2e_sm() as verify: row = ( await verify.execute( select(ProviderCredential).where( ProviderCredential.owner_id == STUB_OWNER_ID, ProviderCredential.provider == KIMI_CODE_PROVIDER, ) ) ).scalar_one() assert row.oauth_enc is not None stored = decrypt_oauth_bundle(row.oauth_enc, key=enc_key) assert stored.access_token == _NEW_ACCESS_TOKEN assert stored.refresh_token == _NEW_REFRESH_TOKEN finally: await _cleanup_credentials(e2e_sm) async def test_k1_disconnect_clears_credential( e2e_sm: async_sessionmaker[AsyncSession], ) -> None: """用例 4:disconnect → 凭据行被清除(DB 真源:之后无 oauth 凭据)。""" from ww_api.main import create_app from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle enc_key = _enc_key() token = TokenSet( access_token=_ACCESS_TOKEN, refresh_token=_REFRESH_TOKEN, expires_at=datetime.now(UTC) + timedelta(seconds=900), ) blob = encrypt_oauth_bundle(token, key=enc_key) app = create_app() transport = httpx.ASGITransport(app=app) try: await _cleanup_credentials(e2e_sm) await _seed_oauth_credential(e2e_sm, blob) # 前置:凭据确实在库。 async with e2e_sm() as pre: assert ( await pre.execute( select(ProviderCredential).where( ProviderCredential.owner_id == STUB_OWNER_ID, ProviderCredential.provider == KIMI_CODE_PROVIDER, ) ) ).scalar_one_or_none() is not None async with LifespanManager(app): async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post(_DISCONNECT_URL) assert resp.status_code == 200 assert resp.json()["disconnected"] is True # DB 真源:凭据行已清除。 async with e2e_sm() as verify: assert ( await verify.execute( select(ProviderCredential).where( ProviderCredential.owner_id == STUB_OWNER_ID, ProviderCredential.provider == KIMI_CODE_PROVIDER, ) ) ).scalar_one_or_none() is None finally: await _cleanup_credentials(e2e_sm)