feat: 优化上游认证复用与 wangwang888 分组接口
## 上游认证优化 ### 核心改进 - login_password 类型上游优先复用已保存 token,过期后自动 refresh,失败再重新登录 - 新增 ensure_authenticated() 方法替代直接调用 login(),减少不必要的登录请求 - 初始化时从 auth_config 自动加载已保存的 token 和 user_id - 请求遇到 401 时自动尝试 refresh 或 login 并重试一次 ### 实现细节 - UpstreamClient.__init__: 初始化时加载 auth_config 中的 token/new_api_user - _is_login_password_with_refresh(): 判断是否支持 refresh - _token_is_expired(): 检查 token 是否过期(提前 60 秒) - _refresh_login_password_token(): 刷新 login_password 类型的 token - ensure_authenticated(): 优先复用 token,过期后 refresh,失败再 login - _send_request(): 401 时针对 login_password 类型先 refresh 再 login 并重试 ### 调用点更新 - scheduler.py: _check_upstream, _sync_upstream_keys - website_sync.py: reconcile_upstream_keys_full - upstreams.py: list_generated_keys, generate_keys_by_groups, test_all, check_now ## wangwang888 分组接口优化 - 提供数据库更新脚本 update_wangwang888_groups_endpoint.py - 将 wangwang888 的 groups_endpoint 从 /groups 改为 /groups/all - 减少不必要的 /api/v1/admin/groups 统计开销 - website_client 已有 fallback 逻辑保证兼容性 ## 测试 - 新增 test_upstream_login_password_refresh.py(7 个测试用例) - 验证 token 复用、refresh、401 重试等逻辑 - 更新 test_upstream_key_sync.py 的 FakeClient 增加 ensure_authenticated - 所有现有测试保持通过(87 passed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9c9a4b3423
commit
c96d55665d
@@ -0,0 +1,272 @@
|
||||
"""测试 login_password 类型的 token 复用与 refresh 逻辑。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import upstream_client
|
||||
from app.services.upstream_client import UpstreamClient
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self, handler):
|
||||
self.handler = handler
|
||||
self.calls = []
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
request = httpx.Request(method, url)
|
||||
self.calls.append({
|
||||
"method": method,
|
||||
"path": request.url.path,
|
||||
"headers": kwargs.get("headers") or {},
|
||||
"json": kwargs.get("json"),
|
||||
})
|
||||
return self.handler(request, kwargs)
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _install_fake_client(monkeypatch, handler) -> FakeHttpClient:
|
||||
fake = FakeHttpClient(handler)
|
||||
monkeypatch.setattr(upstream_client.httpx, "Client", lambda timeout=None: fake)
|
||||
return fake
|
||||
|
||||
|
||||
def _response(request: httpx.Request, status_code: int, payload=None) -> httpx.Response:
|
||||
return httpx.Response(status_code, json=payload, request=request)
|
||||
|
||||
|
||||
def test_ensure_authenticated_with_valid_token_does_not_login(monkeypatch):
|
||||
"""已有未过期 token 时不调用 login。"""
|
||||
def handler(request: httpx.Request, kwargs):
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
fake = _install_fake_client(monkeypatch, handler)
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "valid-token",
|
||||
"token_expires_at": int(time.time()) + 3600,
|
||||
},
|
||||
)
|
||||
|
||||
client.ensure_authenticated()
|
||||
|
||||
assert len(fake.calls) == 0
|
||||
assert client._token == "valid-token"
|
||||
|
||||
|
||||
def test_ensure_authenticated_with_expired_token_refreshes(monkeypatch):
|
||||
"""token 过期且 refresh 成功时只调用 refresh。"""
|
||||
updates: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request, kwargs):
|
||||
if request.url.path == "/api/v1/auth/refresh":
|
||||
assert kwargs["json"] == {"refresh_token": "old-refresh"}
|
||||
return _response(request, 200, {
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 1800,
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
fake = _install_fake_client(monkeypatch, handler)
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "expired-token",
|
||||
"refresh_token": "old-refresh",
|
||||
"token_expires_at": int(time.time()) - 100,
|
||||
},
|
||||
on_auth_config_update=updates.append,
|
||||
)
|
||||
|
||||
client.ensure_authenticated()
|
||||
|
||||
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||
("POST", "/api/v1/auth/refresh"),
|
||||
]
|
||||
assert client._token == "new-access"
|
||||
assert updates[-1]["token"] == "new-access"
|
||||
assert updates[-1]["refresh_token"] == "new-refresh"
|
||||
|
||||
|
||||
def test_ensure_authenticated_refresh_failure_falls_back_to_login(monkeypatch):
|
||||
"""refresh 失败时回退 login。"""
|
||||
updates: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request, kwargs):
|
||||
if request.url.path == "/api/v1/auth/refresh":
|
||||
return _response(request, 401, {"detail": "refresh token expired"})
|
||||
if request.url.path == "/api/v1/auth/login":
|
||||
assert kwargs["json"] == {"email": "user@example.com", "password": "secret"}
|
||||
return _response(request, 200, {
|
||||
"access_token": "login-access",
|
||||
"refresh_token": "login-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
fake = _install_fake_client(monkeypatch, handler)
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "expired-token",
|
||||
"refresh_token": "invalid-refresh",
|
||||
"token_expires_at": int(time.time()) - 100,
|
||||
},
|
||||
on_auth_config_update=updates.append,
|
||||
)
|
||||
|
||||
client.ensure_authenticated()
|
||||
|
||||
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||
("POST", "/api/v1/auth/refresh"),
|
||||
("POST", "/api/v1/auth/login"),
|
||||
]
|
||||
assert client._token == "login-access"
|
||||
assert updates[-1]["token"] == "login-access"
|
||||
assert updates[-1]["refresh_token"] == "login-refresh"
|
||||
|
||||
|
||||
def test_request_401_triggers_refresh_then_retry(monkeypatch):
|
||||
"""请求 401 时 refresh 后重试成功。"""
|
||||
updates: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request, kwargs):
|
||||
if request.url.path == "/api/v1/groups/available":
|
||||
auth = kwargs["headers"].get("Authorization")
|
||||
if auth == "Bearer old-token":
|
||||
return _response(request, 401, {"detail": "token expired"})
|
||||
assert auth == "Bearer refreshed-token"
|
||||
return _response(request, 200, [{"id": "g1", "name": "G1"}])
|
||||
if request.url.path == "/api/v1/auth/refresh":
|
||||
assert kwargs["json"] == {"refresh_token": "old-refresh"}
|
||||
return _response(request, 200, {
|
||||
"access_token": "refreshed-token",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 1800,
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
fake = _install_fake_client(monkeypatch, handler)
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "old-token",
|
||||
"refresh_token": "old-refresh",
|
||||
"token_expires_at": int(time.time()) + 3600,
|
||||
},
|
||||
on_auth_config_update=updates.append,
|
||||
)
|
||||
|
||||
groups = client.get_available_groups("/groups/available")
|
||||
|
||||
assert groups == [{"id": "g1", "name": "G1"}]
|
||||
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||
("GET", "/api/v1/groups/available"),
|
||||
("POST", "/api/v1/auth/refresh"),
|
||||
("GET", "/api/v1/groups/available"),
|
||||
]
|
||||
assert updates[-1]["token"] == "refreshed-token"
|
||||
|
||||
|
||||
def test_request_401_refresh_fails_then_login_and_retry(monkeypatch):
|
||||
"""请求 401 时 refresh 失败,login 并重试成功。"""
|
||||
updates: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request, kwargs):
|
||||
if request.url.path == "/api/v1/groups/available":
|
||||
auth = kwargs["headers"].get("Authorization")
|
||||
if auth == "Bearer old-token":
|
||||
return _response(request, 401, {"detail": "token expired"})
|
||||
assert auth == "Bearer login-token"
|
||||
return _response(request, 200, [{"id": "g1", "name": "G1"}])
|
||||
if request.url.path == "/api/v1/auth/refresh":
|
||||
return _response(request, 401, {"detail": "refresh token expired"})
|
||||
if request.url.path == "/api/v1/auth/login":
|
||||
return _response(request, 200, {
|
||||
"access_token": "login-token",
|
||||
"refresh_token": "login-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
fake = _install_fake_client(monkeypatch, handler)
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "old-token",
|
||||
"refresh_token": "old-refresh",
|
||||
"token_expires_at": int(time.time()) + 3600,
|
||||
},
|
||||
on_auth_config_update=updates.append,
|
||||
)
|
||||
|
||||
groups = client.get_available_groups("/groups/available")
|
||||
|
||||
assert groups == [{"id": "g1", "name": "G1"}]
|
||||
assert [(c["method"], c["path"]) for c in fake.calls] == [
|
||||
("GET", "/api/v1/groups/available"),
|
||||
("POST", "/api/v1/auth/refresh"),
|
||||
("POST", "/api/v1/auth/login"),
|
||||
("GET", "/api/v1/groups/available"),
|
||||
]
|
||||
assert updates[-1]["token"] == "login-token"
|
||||
|
||||
|
||||
def test_non_login_password_calls_login_directly(monkeypatch):
|
||||
"""非 login_password 认证类型行为不变。"""
|
||||
fake = _install_fake_client(monkeypatch, lambda r, k: _response(r, 200, []))
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="bearer",
|
||||
auth_config={"token": "static-token"},
|
||||
)
|
||||
|
||||
client.ensure_authenticated()
|
||||
|
||||
assert len(fake.calls) == 0
|
||||
|
||||
|
||||
def test_init_loads_saved_token_from_auth_config(monkeypatch):
|
||||
"""初始化时从 auth_config 加载已保存的 token。"""
|
||||
_install_fake_client(monkeypatch, lambda r, k: _response(r, 200, []))
|
||||
client = UpstreamClient(
|
||||
base_url="http://api.local",
|
||||
api_prefix="/api/v1",
|
||||
auth_type="login_password",
|
||||
auth_config={
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"token": "saved-token",
|
||||
"new_api_user": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert client._token == "saved-token"
|
||||
assert client._new_api_user == "123"
|
||||
Reference in New Issue
Block a user