"""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": "

hi

"}) 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"