182 lines
5.9 KiB
Python
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
|