test: cover the per-user OAuth flow, threat cases, store and startup
This commit is contained in:
1 parent
566d0b65dd
commit
7286440d64
9 files changed
+2505
-7
No files matched your search
@@ -0,0 +1,181 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user