"""Tests for the Gitea upstream OAuth client: parsing, error kinds, no secret in errors.""" from __future__ import annotations from typing import Any import httpx import pytest from pytest_httpx import HTTPXMock from aegis_gitea_mcp.oauth.upstream import ( GiteaUpstream, UpstreamError, UpstreamInvalidGrant, UpstreamTokens, ) from tests.test_oauth_helpers import ( APP_CLIENT_SECRET, BASE, TOKEN_URL, USER_REFRESH, USER_TOKEN, USER_URL, FakeClock, form_of, oauth_settings, query_of, ) def upstream(clock: FakeClock | None = None) -> GiteaUpstream: return GiteaUpstream(oauth_settings(), clock=clock or FakeClock()) def test_authorize_url_uses_pkce_our_callback_and_the_scope_list() -> None: settings = oauth_settings(oauth_scopes="read:repository,write:issue") url = GiteaUpstream(settings).authorize_url(state="st", code_challenge="ch") assert url.startswith("https://gitea.example.com/login/oauth/authorize?") assert query_of(url) == { "client_id": "gitea-app-client-id", "redirect_uri": f"{BASE}/oauth/callback", "response_type": "code", "state": "st", "scope": "read:repository write:issue", "code_challenge": "ch", "code_challenge_method": "S256", } async def test_refresh_parses_tokens_and_expiry(httpx_mock: HTTPXMock) -> None: clock = FakeClock() httpx_mock.add_response( method="POST", url=TOKEN_URL, json={ "access_token": USER_TOKEN, "refresh_token": USER_REFRESH, "expires_in": 3600, "token_type": "Bearer", }, ) tokens = await upstream(clock).refresh("old-refresh") assert tokens == UpstreamTokens(USER_TOKEN, USER_REFRESH, clock.now + 3600) assert USER_TOKEN not in repr(tokens) and USER_REFRESH not in repr(tokens) request = httpx_mock.get_requests()[0] assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" assert form_of(request)["client_secret"] == APP_CLIENT_SECRET @pytest.mark.parametrize("expires_in", [None, 0, -5, True, "3600"]) async def test_unusable_expires_in_means_no_expiry(httpx_mock: HTTPXMock, expires_in: Any) -> None: httpx_mock.add_response( method="POST", url=TOKEN_URL, json={"access_token": USER_TOKEN, "expires_in": expires_in} ) tokens = await upstream().refresh("r") assert tokens.expires_at is None and tokens.refresh_token is None @pytest.mark.parametrize( "body", [ {"token_type": "bearer"}, {"access_token": "", "token_type": "bearer"}, {"access_token": 5}, {"access_token": "a\nb"}, {"access_token": USER_TOKEN, "token_type": 5}, {"access_token": USER_TOKEN, "token_type": "mac"}, {"access_token": USER_TOKEN, "refresh_token": ""}, ], ) async def test_malformed_token_answers_are_errors(httpx_mock: HTTPXMock, body: Any) -> None: httpx_mock.add_response(method="POST", url=TOKEN_URL, json=body) with pytest.raises(UpstreamError) as caught: await upstream().exchange_code("code", "verifier") assert not isinstance(caught.value, UpstreamInvalidGrant) assert USER_TOKEN not in str(caught.value) async def test_non_json_success_is_an_error(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(method="POST", url=TOKEN_URL, text="") with pytest.raises(UpstreamError, match="malformed"): await upstream().refresh("r") @pytest.mark.parametrize( ("status", "body", "invalid_grant"), [ (400, {"error": "invalid_grant"}, True), (401, {"error": "invalid_grant"}, True), (400, {"error": "unauthorized_client"}, False), (500, None, False), ], ) async def test_token_endpoint_errors( httpx_mock: HTTPXMock, status: int, body: Any, invalid_grant: bool ) -> None: httpx_mock.add_response(method="POST", url=TOKEN_URL, status_code=status, json=body) with pytest.raises(UpstreamError) as caught: await upstream().refresh(USER_REFRESH) assert isinstance(caught.value, UpstreamInvalidGrant) is invalid_grant assert USER_REFRESH not in str(caught.value) async def test_unreachable_gitea_is_an_error(httpx_mock: HTTPXMock) -> None: httpx_mock.add_exception(httpx.ConnectError("boom"), url=TOKEN_URL) httpx_mock.add_exception(httpx.ConnectError("boom"), url=USER_URL) with pytest.raises(UpstreamError, match="unreachable"): await upstream().refresh("r") with pytest.raises(UpstreamError, match="unreachable"): await upstream().fetch_user(USER_TOKEN) async def test_fetch_user(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(url=USER_URL, json={"login": "alice", "is_admin": True}) httpx_mock.add_response(url=USER_URL, json={"login": "bob", "is_admin": "yes"}) alice = await upstream().fetch_user(USER_TOKEN) bob = await upstream().fetch_user(USER_TOKEN) assert (alice.login, alice.is_admin) == ("alice", True) assert (bob.login, bob.is_admin) == ("bob", False) assert httpx_mock.get_requests()[0].headers["Authorization"] == f"Bearer {USER_TOKEN}" @pytest.mark.parametrize( "response", [ {"status_code": 401, "json": {"message": "bad"}}, {"json": {"login": ""}}, {"json": {"login": "x" * 256}}, {"json": {"login": "alice\nINFO forged log line"}}, {"json": ["alice"]}, {"text": "nope"}, ], ) async def test_fetch_user_failures(httpx_mock: HTTPXMock, response: dict[str, Any]) -> None: httpx_mock.add_response(url=USER_URL, **response) with pytest.raises(UpstreamError, match="user lookup failed"): await upstream().fetch_user(USER_TOKEN) async def test_redirects_are_not_followed(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( method="POST", url=TOKEN_URL, status_code=302, headers={"Location": "https://evil.example"} ) with pytest.raises(UpstreamError, match="HTTP 302"): await upstream().refresh("r") assert len(httpx_mock.get_requests()) == 1