189 lines
6.5 KiB
Python
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
|