feat: safe full-API coverage via classified gitea_request dispatch
Add a deterministic (method, path) read/write classifier with an explicit render-only override table that can only downgrade provably side-effect-free POSTs (markdown/markup) to reads, never the reverse — so a mutating call cannot slip past the write-mode gate. Add a known-Gitea-prefix gate: gitea_request now fails closed on any path whose top segment is not a recognized /api/v1 route instead of passing unknown paths through. Expose raw_relative_segments for the authorization layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""Tests for the gitea_request read/write classifier and known-path gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.errors import ToolError
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
normalize_raw_endpoint,
|
||||
raw_is_known_api_path,
|
||||
raw_request_is_write,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""API-key-mode settings with default policy (read allow, write deny)."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
||||
|
||||
|
||||
class StubRawGitea:
|
||||
"""Stub Gitea client capturing raw_request calls."""
|
||||
|
||||
def __init__(self, response: Any = None) -> None:
|
||||
self._response: Any = {"ok": True} if response is None else response
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def raw_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
self.calls.append({"method": method, "endpoint": endpoint})
|
||||
return self._response
|
||||
|
||||
|
||||
# --- Pure classifier --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "expected_write"),
|
||||
[
|
||||
("GET", "/repos/o/r/issues", False),
|
||||
("HEAD", "/repos/o/r", False),
|
||||
("POST", "/repos/o/r/issues", True),
|
||||
("PUT", "/repos/o/r/pulls/1/merge", True),
|
||||
("PATCH", "/repos/o/r/issues/1", True),
|
||||
("DELETE", "/repos/o/r/issues/1", True),
|
||||
# Render-only overrides are reads even though they are POSTs.
|
||||
("POST", "/markdown", False),
|
||||
("POST", "/markdown/raw", False),
|
||||
("POST", "/repos/o/r/markup", False),
|
||||
],
|
||||
)
|
||||
def test_raw_request_is_write(method: str, path: str, expected_write: bool) -> None:
|
||||
endpoint = normalize_raw_endpoint(path)
|
||||
assert raw_request_is_write(method, endpoint) is expected_write
|
||||
|
||||
|
||||
def test_override_never_upgrades_a_mutating_post() -> None:
|
||||
"""A normal mutating POST is never reclassified as a read."""
|
||||
endpoint = normalize_raw_endpoint("/repos/o/r/issues")
|
||||
assert raw_request_is_write("POST", endpoint) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "known"),
|
||||
[
|
||||
("/repos/o/r", True),
|
||||
("/orgs/acme/repos", True),
|
||||
("/admin/users", True),
|
||||
("/user/repos", True),
|
||||
("/markdown", True),
|
||||
("/version", True),
|
||||
("/definitely/not/a/real/prefix", False),
|
||||
("/wibble", False),
|
||||
],
|
||||
)
|
||||
def test_raw_is_known_api_path(path: str, known: bool) -> None:
|
||||
assert raw_is_known_api_path(normalize_raw_endpoint(path)) is known
|
||||
|
||||
|
||||
# --- Handler: unknown path is denied before any network call ----------------
|
||||
|
||||
|
||||
async def test_unknown_prefix_denied_before_network(raw_env: None) -> None:
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "GET", "path": "/wibble/wobble"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "known Gitea API route prefix" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
# --- Write-mode bypass: a write that "looks like a read" is still a write ----
|
||||
|
||||
|
||||
async def test_write_method_denied_with_write_mode_off_even_on_readish_path(
|
||||
raw_env: None,
|
||||
) -> None:
|
||||
"""A POST to a known repo path is a write and is denied while write-mode is off."""
|
||||
stub = StubRawGitea()
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "write mode is disabled" in str(exc_info.value.detail)
|
||||
assert stub.calls == []
|
||||
|
||||
|
||||
async def test_render_only_post_allowed_as_read_without_write_mode(raw_env: None) -> None:
|
||||
"""A markdown-render POST is classified read and proceeds with write-mode off."""
|
||||
stub = StubRawGitea({"rendered": "<p>hi</p>"})
|
||||
result = await raw_api_request_tool(stub, {"method": "POST", "path": "/markdown"})
|
||||
assert result["write"] is False
|
||||
assert stub.calls and stub.calls[0]["endpoint"] == "/api/v1/markdown"
|
||||
Reference in New Issue
Block a user