from __future__ import annotations import base64 import json 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 _jwt_token(exp_timestamp: int) -> str: """Build a fake JWT with the given exp claim.""" payload_b64 = base64.urlsafe_b64encode( json.dumps({"exp": exp_timestamp}).encode() ).rstrip(b"=").decode() return f"header.{payload_b64}.sig" # ── Existing Sub2API bearer tests (unchanged) ───────────────────────────── def test_sub2api_bearer_refreshes_on_401_and_retries(monkeypatch): 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-access": return _response(request, 401, {"detail": "expired"}) assert auth == "Bearer new-access" 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": "new-access", "refresh_token": "new-refresh", "expires_in": 3600, }) raise AssertionError(f"unexpected request: {request.method} {request.url}") fake = _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://sub2api.local", api_prefix="/api/v1", auth_type="bearer", auth_config={"token": "old-access", "refresh_token": "old-refresh"}, 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"] == "new-access" assert updates[-1]["refresh_token"] == "new-refresh" assert updates[-1]["expires_in"] == 3600 def test_sub2api_bearer_without_refresh_token_does_not_refresh(monkeypatch): def handler(request: httpx.Request, kwargs): assert request.url.path == "/api/v1/groups/available" return _response(request, 401, {"detail": "expired"}) fake = _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://sub2api.local", api_prefix="/api/v1", auth_type="bearer", auth_config={"token": "old-access"}, ) with pytest.raises(httpx.HTTPStatusError): client.get_available_groups("/groups/available") assert [(c["method"], c["path"]) for c in fake.calls] == [ ("GET", "/api/v1/groups/available"), ] # ── Updated: non-Sub2API bearer now refreshes with refresh_token ────────── def test_bearer_refreshes_on_401_with_default_refresh_path(monkeypatch): """Non-sub2api bearer with refresh_token refreshes on 401 (new behaviour).""" updates: list[dict] = [] def handler(request: httpx.Request, kwargs): if request.url.path == "/groups/available": auth = kwargs["headers"].get("Authorization") if auth == "Bearer old-access": return _response(request, 401, {"detail": "expired"}) assert auth == "Bearer new-access" return _response(request, 200, [{"id": "g1", "name": "G1"}]) if request.url.path == "/api/user/refresh": assert kwargs["json"] == {"refresh_token": "old-refresh"} return _response(request, 200, { "access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600, }) raise AssertionError(f"unexpected: {request.method} {request.url}") fake = _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://plain-bearer.local", api_prefix="", auth_type="bearer", auth_config={"token": "old-access", "refresh_token": "old-refresh"}, 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", "/groups/available"), ("POST", "/api/user/refresh"), ("GET", "/groups/available"), ] assert updates[-1]["token"] == "new-access" def test_bearer_without_refresh_token_still_fails_on_401(monkeypatch): """Bearer without refresh_token does NOT retry on 401.""" def handler(request: httpx.Request, kwargs): assert request.url.path == "/groups/available" return _response(request, 401, {"detail": "expired"}) fake = _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://plain-bearer.local", api_prefix="", auth_type="bearer", auth_config={"token": "old-access"}, ) with pytest.raises(httpx.HTTPStatusError): client.get_available_groups("/groups/available") assert [(c["method"], c["path"]) for c in fake.calls] == [ ("GET", "/groups/available"), ] # ── New: proactive refresh tests ───────────────────────────────────────── def test_bearer_proactive_refresh_when_expiry_approaching(monkeypatch): """ensure_authenticated proactively refreshes token expiring within 1 h.""" updates: list[dict] = [] def handler(request: httpx.Request, kwargs): if request.url.path == "/api/user/refresh": assert kwargs["json"] == {"refresh_token": "the-refresh"} return _response(request, 200, { "access_token": "new-token", "refresh_token": "new-refresh", "expires_in": 7200, }) raise AssertionError(f"unexpected: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://bearer.local", api_prefix="", auth_type="bearer", auth_config={ "token": "old-token", "refresh_token": "the-refresh", "token_expires_at": int(time.time()) + 1800, # 30 min left }, on_auth_config_update=updates.append, ) client.ensure_authenticated() assert updates[-1]["token"] == "new-token" assert updates[-1]["refresh_token"] == "new-refresh" def test_bearer_proactive_skips_when_token_fresh(monkeypatch): """ensure_authenticated does NOT refresh token that is far from expiry.""" def handler(request: httpx.Request, kwargs): raise AssertionError(f"unexpected request: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://bearer.local", api_prefix="", auth_type="bearer", auth_config={ "token": "still-fresh", "refresh_token": "the-refresh", "token_expires_at": int(time.time()) + 86400 * 7, # 7 days }, ) # Should not make any HTTP request client.ensure_authenticated() def test_bearer_proactive_parses_jwt_exp_when_missing(monkeypatch): """Missing token_expires_at is derived from JWT exp claim.""" future_exp = int(time.time()) + 86400 * 7 # 7 days → far away, no refresh fake_jwt = _jwt_token(future_exp) def handler(request: httpx.Request, kwargs): raise AssertionError(f"unexpected request: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://bearer.local", api_prefix="", auth_type="bearer", auth_config={"token": fake_jwt, "refresh_token": "the-refresh"}, ) client.ensure_authenticated() assert client.auth_config.get("token_expires_at") == future_exp def test_bearer_proactive_refresh_from_jwt_exp(monkeypatch): """When token_expires_at is missing but JWT exp is within 1h, refresh.""" updates: list[dict] = [] def handler(request: httpx.Request, kwargs): if request.url.path == "/api/user/refresh": return _response(request, 200, { "access_token": "refreshed-token", "expires_in": 7200, }) raise AssertionError(f"unexpected: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) near_exp = int(time.time()) + 600 # 10 min left client = UpstreamClient( base_url="http://bearer.local", api_prefix="", auth_type="bearer", auth_config={"token": _jwt_token(near_exp), "refresh_token": "the-refresh"}, on_auth_config_update=updates.append, ) client.ensure_authenticated() assert updates[-1]["token"] == "refreshed-token" def test_bearer_refresh_uses_custom_refresh_path(monkeypatch): """refresh_path in auth_config overrides the default refresh endpoint.""" updates: list[dict] = [] def handler(request: httpx.Request, kwargs): if request.url.path == "/custom/refresh/endpoint": assert kwargs["json"] == {"refresh_token": "my-refresh"} return _response(request, 200, { "access_token": "custom-refreshed", "expires_in": 7200, }) raise AssertionError(f"unexpected: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) # Token is within 1h → triggers proactive refresh near_exp = int(time.time()) + 1800 client = UpstreamClient( base_url="http://bearer.local", api_prefix="", # default would be /api/user/refresh auth_type="bearer", auth_config={ "token": "stale-token", "refresh_token": "my-refresh", "refresh_path": "/custom/refresh/endpoint", "token_expires_at": near_exp, }, on_auth_config_update=updates.append, ) client.ensure_authenticated() assert updates[-1]["token"] == "custom-refreshed" def test_bearer_refresh_backfills_token_expires_at_from_jwt(monkeypatch): """When refresh response has no expires_in, derive token_expires_at from JWT exp.""" updates: list[dict] = [] future_exp = int(time.time()) + 3600 # 1 h from now new_access_token = _jwt_token(future_exp) def handler(request: httpx.Request, kwargs): if request.url.path == "/api/user/refresh": assert kwargs["json"] == {"refresh_token": "my-refresh"} # Response deliberately omits expires_in return _response(request, 200, { "access_token": new_access_token, "refresh_token": "new-refresh", }) raise AssertionError(f"unexpected: {request.method} {request.url}") _install_fake_client(monkeypatch, handler) # Token is within 1h → triggers proactive refresh near_exp = int(time.time()) + 1800 client = UpstreamClient( base_url="http://bearer.local", api_prefix="", auth_type="bearer", auth_config={ "token": "stale-token", "refresh_token": "my-refresh", "token_expires_at": near_exp, }, on_auth_config_update=updates.append, ) client.ensure_authenticated() assert updates[-1]["token"] == new_access_token assert updates[-1]["token_expires_at"] == future_exp # expires_in was NOT in the response → should not appear in the update assert "expires_in" not in updates[-1] # ── Existing login_password tests (unchanged) ───────────────────────────── def test_login_password_saves_sub2api_refresh_token(monkeypatch): updates: list[dict] = [] def handler(request: httpx.Request, kwargs): assert 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": 1800, }) _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://sub2api.local", api_prefix="/api/v1", auth_type="login_password", auth_config={"email": "user@example.com", "password": "secret"}, on_auth_config_update=updates.append, ) client.login() assert client._headers()["Authorization"] == "Bearer login-access" assert updates[-1]["token"] == "login-access" assert updates[-1]["refresh_token"] == "login-refresh" assert updates[-1]["expires_in"] == 1800 def test_login_password_empty_prefix_defaults_to_user_api_login(monkeypatch): def handler(request: httpx.Request, kwargs): assert request.url.path == "/api/user/login" assert kwargs["json"] == {"username": "admin", "password": "secret"} return _response(request, 200, { "token": "nox-access", "user": {"id": 9}, }) _install_fake_client(monkeypatch, handler) client = UpstreamClient( base_url="http://nox.local", api_prefix="", auth_type="login_password", auth_config={"email": "admin", "password": "secret"}, ) client.login() headers = client._headers() assert headers["Authorization"] == "Bearer nox-access" assert headers["Nox-Api-User"] == "9" assert headers["New-Api-User"] == "9"