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