Add PUBLIC_BASE_URL and refine OAuth scopes
This commit is contained in:
@@ -31,6 +31,8 @@ def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
monkeypatch.setenv("WRITE_MODE", "false")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -104,6 +106,52 @@ def test_oauth_authorization_server_metadata(client: TestClient) -> None:
|
||||
assert payload["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
|
||||
|
||||
def test_oauth_metadata_uses_public_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Public base URL is used for externally advertised OAuth metadata links."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app)
|
||||
metadata_response = client.get("/.well-known/oauth-authorization-server")
|
||||
assert metadata_response.status_code == 200
|
||||
payload = metadata_response.json()
|
||||
assert payload["token_endpoint"] == "https://mcp.example.com/oauth/token"
|
||||
|
||||
challenge_response = client.post(
|
||||
"/mcp/tool/call",
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert challenge_response.status_code == 401
|
||||
challenge = challenge_response.headers["WWW-Authenticate"]
|
||||
assert (
|
||||
'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
|
||||
in challenge
|
||||
)
|
||||
|
||||
|
||||
def test_scope_compatibility_write_implies_read() -> None:
|
||||
"""write:repository grants read-level access for read tools."""
|
||||
from aegis_gitea_mcp.server import READ_SCOPE, _has_required_scope
|
||||
|
||||
assert _has_required_scope(READ_SCOPE, {"write:repository"})
|
||||
|
||||
|
||||
def test_scope_compatibility_repository_aliases() -> None:
|
||||
"""Legacy/broad repository scopes satisfy MCP read/write requirements."""
|
||||
from aegis_gitea_mcp.server import READ_SCOPE, WRITE_SCOPE, _has_required_scope
|
||||
|
||||
assert _has_required_scope(READ_SCOPE, {"repository"})
|
||||
assert _has_required_scope(WRITE_SCOPE, {"repository"})
|
||||
assert _has_required_scope(WRITE_SCOPE, {"repo"})
|
||||
|
||||
|
||||
def test_list_tools_without_auth(client: TestClient) -> None:
|
||||
"""Tool listing remains discoverable without auth."""
|
||||
response = client.get("/mcp/tools")
|
||||
@@ -313,3 +361,187 @@ async def test_startup_event_succeeds_when_discovery_ready(
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
def test_probe_scopeless_token_returns_401(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Middleware returns 401 with re-auth guidance when Gitea probe returns 403."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 403
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
|
||||
def test_probe_valid_token_proceeds(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Middleware allows request through when Gitea probe returns 200."""
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
def test_gitea_authorization_error_returns_403(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthorizationError from tool handler returns 403 with re-auth guidance."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthorizationError("403 Forbidden")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert "re-authorize" in body["error"].lower()
|
||||
|
||||
|
||||
def test_gitea_authentication_error_returns_401(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthenticationError from tool handler returns 401."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthenticationError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert "re-authenticate" in body["error"].lower()
|
||||
|
||||
|
||||
def test_sse_gitea_authorization_error_returns_jsonrpc_error(
|
||||
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GiteaAuthorizationError in SSE handler returns JSON-RPC -32000 with guidance."""
|
||||
from aegis_gitea_mcp import server
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
|
||||
|
||||
server._api_scope_cache.clear()
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
raise GiteaAuthorizationError("403 Forbidden")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
mock_probe_response = MagicMock()
|
||||
mock_probe_response.status_code = 200
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_probe_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "err-1",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_repositories", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "re-authorize" in body["error"]["message"].lower()
|
||||
|
||||
Reference in New Issue
Block a user