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