"""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.gitea import GiteaExecutor 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.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, GiteaExecutor) 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