Files
AegisGitea-MCP/tests/test_server.py
T
Latte cf3d2e17d3 fix: fail closed on an unwritable audit log, bound the rate limiter and harden startup
The audit logger is opened at startup (exit 2 on failure) and obtained before
every call, which is refused with audit_unavailable otherwise. Audit records only
take validated path and sudo values and use the request id as correlation id. The
rate limiter evicts idle keys, caps tracked keys, audits the first excess per key
and window and uses the settings passed to the app. FORWARDED_ALLOW_IPS is now a
setting, and startup warns about a missing host allow-list and unavailable OAuth.
2026-10-14 16:24:51 +02:00

730 lines
27 KiB
Python

"""Tests for the MCP server core: tool list, filters, dispatcher, audit."""
from __future__ import annotations
import base64
import json
from typing import Any
import pytest
from mcp import Client, MCPError
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.audit import validate_audit_log_integrity
from aegis_gitea_mcp.catalog import load_catalog
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME, UNTRUSTED_NOTICE, GiteaExecutor, ToolOutcome
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.server import (
RAW_INPUT_SCHEMA,
ServerConfigError,
ToolRegistry,
build_raw_tool,
build_server,
max_request_body_size,
validate_arguments,
)
from tests.test_server_helpers import (
API,
GITEA_TOKEN,
audit_entries,
connected,
mock_user,
server_settings,
)
CATALOG = load_catalog()
REPO_ARGS = {"owner": "octo", "repo": "demo"}
def payload_of(result: Any) -> dict[str, Any]:
"""Decode the JSON text content of a tool result."""
assert len(result.content) == 1
decoded: dict[str, Any] = json.loads(result.content[0].text)
return decoded
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""Every test here needs GITEA_URL for the audit logger's settings."""
# Tool list
async def test_list_tools_has_every_operation_plus_raw_tool() -> None:
async with connected() as client:
tools = (await client.list_tools()).tools
names = [tool.name for tool in tools]
assert len(tools) == len(CATALOG.operations) + 1 == 538
assert len(set(names)) == len(names)
assert names == sorted(names)
assert RAW_TOOL_NAME in names
for tool in tools:
assert tool.input_schema["type"] == "object"
assert tool.annotations is not None
assert tool.annotations.open_world_hint is False
async def test_list_tools_annotations_follow_the_catalog() -> None:
async with connected() as client:
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
get_repo = tools["gitea_repo_get"].annotations
delete_repo = tools["gitea_repo_delete"].annotations
create_issue = tools["gitea_issue_create_issue"].annotations
assert get_repo is not None and delete_repo is not None and create_issue is not None
assert (get_repo.read_only_hint, get_repo.destructive_hint, get_repo.idempotent_hint) == (
True,
False,
True,
)
assert (delete_repo.read_only_hint, delete_repo.destructive_hint) == (False, True)
assert (create_issue.destructive_hint, create_issue.idempotent_hint) == (False, False)
assert tools["gitea_repo_get"].title == get_repo.title
assert tools["gitea_repo_get"].description
async def test_raw_tool_definition() -> None:
async with connected() as client:
raw = next(t for t in (await client.list_tools()).tools if t.name == RAW_TOOL_NAME)
assert raw.input_schema == RAW_INPUT_SCHEMA
assert raw.input_schema["additionalProperties"] is False
assert raw.input_schema["properties"]["method"]["enum"] == [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
]
assert raw.input_schema["properties"]["path"]["maxLength"] == 2048
assert raw.annotations is not None
assert (raw.annotations.read_only_hint, raw.annotations.destructive_hint) == (False, True)
async def test_list_tools_ignores_cursor() -> None:
async with connected() as client:
result = await client.list_tools(cursor="whatever")
assert len(result.tools) == 538
assert result.next_cursor is None
# Filters
async def test_toolsets_limit_listing_and_calling(httpx_mock: HTTPXMock) -> None:
settings = server_settings(toolsets=["issue"])
async with connected(settings) as client:
tools = (await client.list_tools()).tools
assert {CATALOG.get(t.name).domain for t in tools if CATALOG.get(t.name)} == {"issue"}
assert RAW_TOOL_NAME not in {t.name for t in tools}
with pytest.raises(MCPError):
await client.call_tool("gitea_repo_get", REPO_ARGS)
assert httpx_mock.get_requests() == []
async def test_misc_toolset_includes_the_raw_tool() -> None:
async with connected(server_settings(toolsets=["misc"])) as client:
names = {t.name for t in (await client.list_tools()).tools}
assert RAW_TOOL_NAME in names
assert {CATALOG.get(n).domain for n in names - {RAW_TOOL_NAME}} == {"misc"} # type: ignore[union-attr]
async def test_read_only_lists_only_read_operations_and_keeps_the_raw_tool() -> None:
async with connected(server_settings(read_only=True)) as client:
tools = {t.name: t for t in (await client.list_tools()).tools}
assert RAW_TOOL_NAME in tools
read_ops = [op for op in CATALOG.operations if op.read_only]
assert len(tools) == len(read_ops) + 1
assert "gitea_repo_delete" not in tools
raw = tools[RAW_TOOL_NAME]
assert raw.annotations is not None and raw.annotations.read_only_hint is True
assert raw.description is not None and "only GET" in raw.description
async def test_read_only_rejects_non_get_raw_requests(httpx_mock: HTTPXMock) -> None:
async with connected(server_settings(read_only=True)) as client:
blocked = await client.call_tool(
RAW_TOOL_NAME, {"method": "DELETE", "path": "/repos/octo/demo"}
)
assert blocked.is_error
assert payload_of(blocked)["error"]["kind"] == "read_only"
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "read_only"
async def test_read_only_allows_get_raw_requests(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.22"})
async with connected(server_settings(read_only=True)) as client:
result = await client.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert not result.is_error
assert payload_of(result)["data"] == {"version": "1.22"}
async def test_disabled_tools_are_hidden_and_unknown_when_called() -> None:
settings = server_settings(disabled_tools=["gitea_repo_delete", RAW_TOOL_NAME])
async with connected(settings) as client:
names = {t.name for t in (await client.list_tools()).tools}
assert len(names) == 536
assert "gitea_repo_delete" not in names
assert RAW_TOOL_NAME not in names
with pytest.raises(MCPError) as caught:
await client.call_tool("gitea_repo_delete", REPO_ARGS)
assert caught.value.code == -32602
def test_unknown_disabled_tool_names_are_a_startup_error() -> None:
settings = server_settings(disabled_tools=["gitea_repo_get", "gitea_nope", "other_nope"])
with pytest.raises(ServerConfigError) as caught:
build_server(settings)
message = str(caught.value)
assert "gitea_nope" in message and "other_nope" in message
assert "gitea_repo_get" not in message
def test_disabled_tool_hidden_by_read_only_is_still_a_valid_name() -> None:
settings = server_settings(read_only=True, disabled_tools=["gitea_repo_delete"])
registry = ToolRegistry(settings, CATALOG)
assert registry.get("gitea_repo_delete") is None
assert len(registry) == sum(op.read_only for op in CATALOG.operations) + 1
def test_registry_caches_validators() -> None:
registry = ToolRegistry(server_settings(), CATALOG)
assert registry.validator("gitea_repo_get") is registry.validator("gitea_repo_get")
def test_build_raw_tool_read_only_flag() -> None:
assert build_raw_tool().annotations is not None
assert build_raw_tool(read_only=True).annotations.read_only_hint is True # type: ignore[union-attr]
# Calls: success and Gitea errors
async def test_call_success_envelope(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error is False
payload = payload_of(result)
assert payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None}
assert payload["untrusted_content"] == UNTRUSTED_NOTICE
assert payload["data"] == {"full_name": "octo/demo"}
assert result.structured_content == payload
request = httpx_mock.get_requests(url=f"{API}/repos/octo/demo")[0]
assert request.headers["Authorization"] == f"token {GITEA_TOKEN}"
async def test_gitea_404_is_an_error_result_with_kind(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", status_code=404, json={"message": "nope"})
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error is True
error = payload_of(result)["error"]
assert error["kind"] == "not_found" and error["status"] == 404
entry = audit_entries()[-1]
assert entry["outcome"] == "not_found" and entry["result_status"] == "error"
assert entry["params"]["http_status"] == 404
async def test_none_arguments_are_treated_as_empty(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"})
async with connected() as client:
result = await client.call_tool("gitea_user_get_current")
assert not result.is_error
assert payload_of(result)["data"] == {"login": "admin"}
# Calls: validation
@pytest.mark.parametrize(
("tool", "arguments", "path", "needle"),
[
("gitea_repo_get", {"owner": "octo"}, "$", "repo"),
("gitea_repo_get", {"owner": 5, "repo": "demo"}, "$.owner", "type"),
("gitea_repo_get", {**REPO_ARGS, "surprise": 1}, "$", "unexpected argument"),
("gitea_repo_get", {"owner": "no spaces!", "repo": "demo"}, "$.owner", "pattern"),
("gitea_repo_get", {"owner": "o" * 300, "repo": "demo"}, "$.owner", "maxLength"),
("gitea_repo_get", {"owner": "", "repo": "demo"}, "$.owner", "minLength"),
(RAW_TOOL_NAME, {"method": "TRACE", "path": "/x"}, "$.method", "one of"),
(RAW_TOOL_NAME, {"method": "GET", "path": "no-slash"}, "$.path", "pattern"),
(RAW_TOOL_NAME, {"method": "GET", "path": "/x", "query": {"a": {"b": 1}}}, "$.query.a", ""),
],
)
async def test_validation_errors_do_not_call_gitea(
httpx_mock: HTTPXMock, tool: str, arguments: dict[str, Any], path: str, needle: str
) -> None:
async with connected() as client:
result = await client.call_tool(tool, arguments)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "invalid_argument"
assert error["path"] == path
assert needle in error["message"]
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "invalid_argument"
async def test_validation_error_never_echoes_the_offending_value() -> None:
secret = "sk-live-SUPERSECRET-0123456789"
async with connected() as client:
results = [
await client.call_tool("gitea_repo_get", {"owner": secret, "repo": "demo"}),
await client.call_tool("gitea_repo_get", {"owner": 1, "repo": "demo", secret: secret}),
await client.call_tool("gitea_repo_get", {"owner": ["a", secret], "repo": "demo"}),
]
for result in results:
assert result.is_error
assert secret not in result.content[0].text # type: ignore[union-attr]
assert "unexpected argument" in payload_of(results[1])["error"]["message"]
def test_validate_arguments_returns_none_for_valid_input() -> None:
registry = ToolRegistry(server_settings(), CATALOG)
assert validate_arguments(registry.validator("gitea_repo_get"), REPO_ARGS) is None
def test_validate_arguments_covers_remaining_keyword_messages() -> None:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(
{
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 3},
"t": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
"k": {"type": ["string", "null"]},
},
}
)
low = validate_arguments(validator, {"n": 1})
anyof = validate_arguments(validator, {"t": [1]})
multi = validate_arguments(validator, {"k": 5})
assert low is not None and "minimum 3" in low["message"]
assert anyof is not None and "'anyOf'" in anyof["message"] and anyof["path"] == "$.t"
assert multi is not None and '["string", "null"]' in multi["message"]
def test_validate_arguments_array_index_paths() -> None:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(
{"type": "object", "properties": {"ids": {"type": "array", "items": {"type": "integer"}}}}
)
error = validate_arguments(validator, {"ids": [1, "x"]})
assert error is not None and error["path"] == "$.ids[1]"
# Calls: unknown tools
async def test_unknown_tool_is_a_protocol_error_and_is_audited() -> None:
async with connected() as client:
with pytest.raises(MCPError) as caught:
await client.call_tool("gitea_does_not_exist", {"a": 1})
assert caught.value.code == -32602
assert "gitea_does_not_exist" in caught.value.message
entry = audit_entries()[-1]
assert entry["tool_name"] == "gitea_does_not_exist"
assert entry["outcome"] == "unknown_tool"
# Calls: raw tool
async def test_raw_tool_happy_path(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues?state=open&labels=a&labels=b",
method="POST",
status_code=201,
json={"number": 7},
)
async with connected() as client:
result = await client.call_tool(
RAW_TOOL_NAME,
{
"method": "post",
"path": "/repos/octo/demo/issues",
"query": {"state": "open", "labels": ["a", "b"]},
"body": {"title": "hello"},
"sudo": "alice",
},
)
# lower-case verbs are not in the enum
assert result.is_error
assert payload_of(result)["error"]["kind"] == "invalid_argument"
async with connected() as client:
result = await client.call_tool(
RAW_TOOL_NAME,
{
"method": "POST",
"path": "/repos/octo/demo/issues",
"query": {"state": "open", "labels": ["a", "b"]},
"body": {"title": "hello"},
"sudo": "alice",
},
)
assert not result.is_error
payload = payload_of(result)
assert payload["status"] == 201 and payload["data"] == {"number": 7}
assert payload["identity"]["sudo"] == "alice"
sent = httpx_mock.get_requests(method="POST")[0]
assert json.loads(sent.content) == {"title": "hello"}
assert sent.headers["Sudo"] == "alice"
entry = audit_entries()[-1]
assert entry["tool_name"] == RAW_TOOL_NAME
assert entry["method"] == "POST" and entry["path"] == "/repos/octo/demo/issues"
assert entry["sudo"] == "alice" and entry["domain"] == "misc"
async def test_raw_tool_without_body_sends_none(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={"version": "1"})
async with connected() as client:
result = await client.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert not result.is_error
assert httpx_mock.get_requests(url=f"{API}/version")[0].content == b""
# Audit
async def test_audit_event_per_call_never_contains_argument_values(
httpx_mock: HTTPXMock,
) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues", method="POST", status_code=201, json={"number": 1}
)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", status_code=404, json={"message": "x"})
secret = "BODY-SECRET-VALUE-9f8e7d6c5b4a"
async with connected() as client:
await client.call_tool(
"gitea_issue_create_issue",
{**REPO_ARGS, "body": {"title": secret, "body": secret}, "sudo": "alice"},
)
await client.call_tool("gitea_repo_get", REPO_ARGS)
await client.call_tool("gitea_repo_get", {"owner": secret + "!", "repo": "x"})
with pytest.raises(MCPError):
await client.call_tool("gitea_unknown", {"value": secret})
entries = audit_entries()
assert [e["outcome"] for e in entries] == [
"success",
"not_found",
"invalid_argument",
"unknown_tool",
]
first = entries[0]
assert first["tool_name"] == "gitea_issue_create_issue"
assert first["domain"] == "issue" and first["method"] == "POST"
assert first["path"] == "/repos/{owner}/{repo}/issues"
assert first["identity_login"] == "admin" and first["identity_mode"] == "admin-token"
assert first["sudo"] == "alice" and first["result_status"] == "success"
assert first["params"] == {
"argument_names": ["body", "owner", "repo", "sudo"],
"http_status": 201,
}
assert first["duration_ms"] >= 0 and first["correlation_id"]
assert len({e["correlation_id"] for e in entries}) == 4
from aegis_gitea_mcp.audit import get_audit_logger
log_path = get_audit_logger().log_path
assert secret not in log_path.read_text(encoding="utf-8")
assert validate_audit_log_integrity(log_path) == (True, [])
async def test_audit_write_failure_after_execution_does_not_break_the_call(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
class BrokenAudit:
def log_tool_invocation(self, *args: Any, **kwargs: Any) -> str:
raise OSError("disk full: " + GITEA_TOKEN)
async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type]
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
# The call already ran (its side effect cannot be undone), so the result still goes out.
assert not result.is_error
assert "OSError" in caplog.text
assert GITEA_TOKEN not in caplog.text
async def test_unavailable_audit_log_refuses_the_call_before_gitea_is_contacted(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def broken() -> None:
raise OSError("permission denied: " + GITEA_TOKEN)
monkeypatch.setattr("aegis_gitea_mcp.server.get_audit_logger", broken)
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
written = await client.call_tool("gitea_issue_create_issue", {**REPO_ARGS, "body": {}})
assert result.is_error and written.is_error
error = payload_of(result)["error"]
assert error["kind"] == "audit_unavailable"
assert GITEA_TOKEN not in json.dumps(payload_of(result)) + caplog.text
assert httpx_mock.get_requests() == []
async def test_audit_correlation_id_is_the_request_id(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
async with connected() as client:
await client.call_tool("gitea_repo_get", REPO_ARGS)
from aegis_gitea_mcp.audit import get_audit_logger
line = get_audit_logger().log_path.read_text(encoding="utf-8").splitlines()[-1]
entry = json.loads(line)
assert entry["payload"]["correlation_id"] == entry["request_id"] != "-"
async def test_rejected_raw_call_audits_no_unvalidated_path_or_sudo() -> None:
secret = "SECRET-IN-PATH-9f8e7d6c5b"
async with connected() as client:
bad_path = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": secret, "sudo": secret}
)
bad_sudo = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": "/user", "sudo": "bad name!"}
)
assert bad_path.is_error and bad_sudo.is_error
first, second = audit_entries()
assert first["outcome"] == second["outcome"] == "invalid_argument"
for entry in (first, second):
assert "path" not in entry and "sudo" not in entry
from aegis_gitea_mcp.audit import get_audit_logger
assert secret not in get_audit_logger().log_path.read_text(encoding="utf-8")
async def test_raw_call_audits_the_validated_method_and_path(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={})
async with connected() as client:
await client.call_tool(
"gitea_api_request", {"method": "GET", "path": "/version", "sudo": "alice"}
)
entry = audit_entries()[-1]
assert (entry["method"], entry["path"], entry["sudo"]) == ("GET", "/version", "alice")
# Internal errors
class ExplodingExecutor(GiteaExecutor):
"""Executor whose calls raise an exception carrying a secret."""
async def execute(self, *args: Any, **kwargs: Any) -> ToolOutcome:
raise RuntimeError(f"boom with {GITEA_TOKEN} and a stack")
async def test_internal_error_is_generic_and_audited(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
mock_user(httpx_mock)
settings = server_settings()
async with connected(settings, executor=ExplodingExecutor(settings)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "internal_error"
assert "boom" not in result.content[0].text # type: ignore[union-attr]
assert "RuntimeError" not in error["message"]
assert GITEA_TOKEN not in caplog.text and "Traceback" not in caplog.text
assert audit_entries()[-1]["outcome"] == "internal_error"
async def test_internal_error_shows_class_name_when_details_are_exposed(
httpx_mock: HTTPXMock,
) -> None:
mock_user(httpx_mock)
settings = server_settings(expose_error_details=True)
async with connected(settings, executor=ExplodingExecutor(settings)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
message = payload_of(result)["error"]["message"]
assert "RuntimeError" in message and "boom" not in message
# Identity
class FixedResolver:
"""Resolver returning a per-user identity (what the OAuth mode will plug in)."""
def __init__(self) -> None:
self.seen: list[AccessToken | None] = []
async def resolve(self, access: AccessToken | None) -> Identity:
self.seen.append(access)
return Identity(login="alice", mode="oauth", token="user-token-abcdefgh")
async def test_custom_resolver_identity_reaches_gitea_and_audit(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
resolver = FixedResolver()
async with connected(resolver=resolver) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert payload_of(result)["identity"] == {"login": "alice", "mode": "oauth", "sudo": None}
assert httpx_mock.get_requests()[0].headers["Authorization"] == "Bearer user-token-abcdefgh"
assert resolver.seen == [None] # in-memory transport has no access token
entry = audit_entries()[-1]
assert entry["identity_login"] == "alice" and entry["identity_mode"] == "oauth"
async def test_identity_resolution_failure_is_an_error_result(httpx_mock: HTTPXMock) -> None:
class Refusing:
async def resolve(self, access: AccessToken | None) -> Identity:
raise ToolError("unknown identity", status_code=401)
async with connected(resolver=Refusing()) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
assert payload_of(result)["error"] == {"kind": "identity_error", "message": "unknown identity"}
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "identity_error"
async def test_missing_gitea_token_is_an_identity_error(httpx_mock: HTTPXMock) -> None:
async with connected(server_settings(gitea_token=None)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
assert "GITEA_TOKEN" in payload_of(result)["error"]["message"]
assert httpx_mock.get_requests() == []
# Uploads
def attachment_args(content: bytes) -> dict[str, Any]:
return {
**REPO_ARGS,
"index": 1,
"attachment": {
"filename": "a.bin",
"content_base64": base64.b64encode(content).decode("ascii"),
},
}
@pytest.mark.parametrize("size", [10, 9, 8])
async def test_upload_within_cap_is_sent(httpx_mock: HTTPXMock, size: int) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues/1/assets", method="POST", status_code=201, json={"id": 1}
)
async with connected(server_settings(max_binary_bytes=10)) as client:
result = await client.call_tool(
"gitea_issue_create_issue_attachment", attachment_args(b"x" * size)
)
assert not result.is_error
@pytest.mark.parametrize("size", [11, 12, 13, 1000])
async def test_upload_over_cap_is_rejected_before_any_request(
httpx_mock: HTTPXMock, size: int
) -> None:
async with connected(server_settings(max_binary_bytes=10)) as client:
result = await client.call_tool(
"gitea_issue_create_issue_attachment", attachment_args(b"x" * size)
)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "invalid_argument"
assert error["path"] == "$.attachment.content_base64"
assert "MAX_BINARY_BYTES" in error["message"]
assert httpx_mock.get_requests() == []
def test_max_request_body_size_covers_the_upload_cap() -> None:
default = max_request_body_size(server_settings(max_binary_bytes=1))
big = max_request_body_size(server_settings(max_binary_bytes=5_242_880))
assert default == 4 * 1024 * 1024
assert big >= 5_242_880 * 4 // 3 + 1024 * 1024
assert big > default
# Lifecycle
async def test_server_lifespan_closes_the_executor() -> None:
settings = server_settings()
executor = GiteaExecutor(settings)
server = build_server(settings, executor=executor)
async with Client(server) as client:
await client.list_tools()
assert executor._client.is_closed is False
assert executor._client.is_closed is True
def test_get_tool_input_schema_hook() -> None:
server = build_server(server_settings(disabled_tools=["gitea_repo_get"]))
assert server.get_tool_input_schema is not None
assert server.get_tool_input_schema("gitea_repo_delete")["type"] == "object" # type: ignore[index]
assert server.get_tool_input_schema("gitea_repo_get") is None
assert server.tool_count == 537
def test_upload_cap_ignores_absent_and_malformed_file_arguments() -> None:
from aegis_gitea_mcp.server import _upload_cap_error
operation = CATALOG.get("gitea_issue_create_issue_attachment")
assert operation is not None
assert _upload_cap_error(operation, REPO_ARGS, 10) is None
assert _upload_cap_error(operation, {"attachment": {"content_base64": 5}}, 10) is None
assert _upload_cap_error(operation, {"attachment": "not-an-object"}, 10) is None