Files
AegisGitea-MCP/tests/test_oauth_startup.py
T

189 lines
6.5 KiB
Python

"""Startup and wiring of per-user OAuth: OAuth-only deployments, store failures, log hygiene."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import transports
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.oauth import components as components_module
from aegis_gitea_mcp.oauth.components import RATE_LIMITED_PATHS, build_oauth_components
from aegis_gitea_mcp.oauth.resolver import OAuthGiteaExecutor
from aegis_gitea_mcp.oauth.store import STORE_FILENAME
from aegis_gitea_mcp.transports import AccessLogQueryFilter, http_startup_errors, run_http
from tests.test_oauth_helpers import (
API,
BEARER_TOKEN,
USER_TOKEN,
browser,
build_oauth_app,
gitea_requests,
mcp_client,
oauth_settings,
payload,
sign_in,
)
from tests.test_server_helpers import ANY_HOST
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
class UvicornSpy:
def __init__(self) -> None:
self.calls: list[tuple[Any, dict[str, Any]]] = []
def __call__(self, app: Any, **kwargs: Any) -> None:
self.calls.append((app, kwargs))
@pytest.fixture
def uvicorn_spy(monkeypatch: pytest.MonkeyPatch) -> UvicornSpy:
spy = UvicornSpy()
monkeypatch.setattr(transports.uvicorn, "run", spy)
monkeypatch.setattr(logging.getLogger("uvicorn.access"), "filters", [])
return spy
def oauth_only(**overrides: Any) -> Any:
return oauth_settings(gitea_token=None, mcp_bearer_token=None, **overrides)
def test_oauth_only_deployment_starts_on_a_public_bind(uvicorn_spy: UvicornSpy) -> None:
assert run_http(oauth_only(mcp_host=ANY_HOST)) == 0
assert len(uvicorn_spy.calls) == 1
assert (
http_startup_errors(oauth_only(mcp_host=ANY_HOST), build_oauth_components(oauth_only()))
== []
)
def test_oauth_with_a_bearer_but_no_gitea_token_is_refused(
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
assert run_http(oauth_settings(gitea_token=None)) == 2
err = capsys.readouterr().err
assert "GITEA_TOKEN is required" in err and "unset MCP_BEARER_TOKEN" in err
assert uvicorn_spy.calls == []
def test_undecryptable_store_stops_startup_and_keeps_the_file(
tmp_path: Path, uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / STORE_FILENAME).write_bytes(b"garbage")
assert run_http(oauth_settings(data_dir=tmp_path)) == 2
err = capsys.readouterr().err
assert "per-user OAuth cannot start: cannot decrypt" in err
assert (tmp_path / STORE_FILENAME).read_bytes() == b"garbage"
assert uvicorn_spy.calls == []
def test_unreadable_store_directory_is_a_setup_error(monkeypatch: pytest.MonkeyPatch) -> None:
def denied(*args: Any, **kwargs: Any) -> None:
raise PermissionError("denied")
monkeypatch.setattr(components_module.OAuthStore, "load", denied)
with pytest.raises(OAuthSetupError, match="cannot read the OAuth token store"):
build_oauth_components(oauth_settings())
@pytest.mark.parametrize(
"settings",
[
{"oauth_enabled": False},
{"gitea_oauth_client_secret": None},
],
)
def test_components_need_a_complete_oauth_configuration(settings: dict[str, Any]) -> None:
with pytest.raises(OAuthSetupError):
build_oauth_components(oauth_settings(**settings))
def test_http_public_base_url_is_refused_for_oauth() -> None:
settings = oauth_settings().model_copy(update={"public_base_url": "http://mcp.example.com"})
errors = http_startup_errors(settings, build_oauth_components(oauth_settings()))
assert errors == ["PUBLIC_BASE_URL must use https unless the host is loopback"]
def test_loopback_http_public_base_url_is_allowed() -> None:
settings = oauth_settings(public_base_url="http://127.0.0.1:8080")
assert http_startup_errors(settings, build_oauth_components(settings)) == []
def test_components_wiring() -> None:
settings = oauth_settings()
extras = build_oauth_components(settings)
assert isinstance(extras.executor, OAuthGiteaExecutor)
assert extras.rate_limited_paths == RATE_LIMITED_PATHS
paths = {route.path for route in extras.extra_routes} # type: ignore[attr-defined]
assert paths == {"/oauth/consent", "/oauth/callback", "/.well-known/oauth-protected-resource"}
assert extras.client_registration_options is not None
assert extras.client_registration_options.default_scopes == ["gitea"]
assert extras.revocation_options is not None and extras.revocation_options.enabled
assert extras.resolver._admin is not None # type: ignore[attr-defined]
assert build_oauth_components(oauth_only()).resolver._admin is None # type: ignore[union-attr]
async def test_oauth_only_app_serves_users_and_has_no_admin_path(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app(oauth_only())
flow = sign_in(browser(oauth.app), httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with mcp_client(oauth.app, flow.tokens["access_token"]) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert payload(result)["identity"]["mode"] == "oauth"
(request,) = gitea_requests(httpx_mock, f"{API}/repos")
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
rejected = browser(oauth.app).post(
"/mcp", json={}, headers={"Authorization": f"Bearer {BEARER_TOKEN}"}
)
assert rejected.status_code == 401
def test_access_log_filter_drops_query_strings() -> None:
record = logging.LogRecord(
"uvicorn.access",
logging.INFO,
__file__,
1,
'%s - "%s %s HTTP/%s" %d',
("1.2.3.4:5", "GET", "/oauth/callback?code=secret-code&state=secret-state", "1.1", 302),
None,
)
plain = logging.LogRecord(
"uvicorn.access", logging.INFO, __file__, 1, "%s", ("no query",), None
)
assert AccessLogQueryFilter().filter(record) is True
assert AccessLogQueryFilter().filter(plain) is True
message = record.getMessage()
assert "secret" not in message
assert '"GET /oauth/callback?[redacted] HTTP/1.1" 302' in message
assert plain.getMessage() == "no query"
def test_run_http_installs_the_access_log_filter_once(uvicorn_spy: UvicornSpy) -> None:
run_http(oauth_settings())
run_http(oauth_settings())
filters = logging.getLogger("uvicorn.access").filters
assert sum(isinstance(item, AccessLogQueryFilter) for item in filters) == 1