Files
AegisGitea-MCP/tests/test_oauth_upstream.py
T

182 lines
5.9 KiB
Python

"""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="<html>")
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