"""Tests for the Gitea request executor (HTTP behaviour, envelope, redaction).""" from __future__ import annotations import base64 import json from collections.abc import AsyncIterator from typing import Any import httpx import pytest from pytest_httpx import HTTPXMock from aegis_gitea_mcp import __version__ from aegis_gitea_mcp.catalog.model import Parameter from aegis_gitea_mcp.gitea import ( CREDENTIAL_ISSUING_OPERATIONS, RAW_TOOL_NAME, UNTRUSTED_NOTICE, GiteaExecutor, ToolOutcome, ) from aegis_gitea_mcp.gitea import executor as executor_module from aegis_gitea_mcp.identity import Identity from tests.test_executor_helpers import ( API, BEARER_TOKEN, CREATE_ISSUE, CREATE_TOKEN, CREATE_USER, DELETE_BRANCH, GET_BRANCH, GET_RAW_FILE, GITEA_TOKEN, LIST_BRANCHES, LIST_PULLS, OAUTH_CLIENT_SECRET, PULL_DIFF, PUT_SECRET, REGISTRATION_TOKEN, RENDER_RAW, UPLOAD_ATTACHMENT, USER_TOKEN, admin_identity, make_op, make_settings, oauth_identity, param, ) BASE_ARGS = {"owner": "octo", "repo": "demo"} GH_TOKEN = "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5" @pytest.fixture async def executor() -> AsyncIterator[GiteaExecutor]: instance = GiteaExecutor(make_settings()) yield instance await instance.aclose() def only_request(httpx_mock: HTTPXMock) -> httpx.Request: requests = httpx_mock.get_requests() assert len(requests) == 1 return requests[0] def error_of(outcome: ToolOutcome) -> dict[str, Any]: assert outcome.is_error error: dict[str, Any] = outcome.payload["error"] return error # URL building, headers and query async def test_get_builds_url_headers_and_success_envelope( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[{"name": "main"}]) outcome = await executor.execute( LIST_BRANCHES, {**BASE_ARGS, "page": 2, "limit": 10}, admin_identity() ) request = only_request(httpx_mock) assert request.method == "GET" assert str(request.url) == f"{API}/repos/octo/demo/branches?page=2&limit=10" assert request.headers["Authorization"] == f"token {GITEA_TOKEN}" assert request.headers["Accept"] == "application/json" assert request.headers["User-Agent"] == f"aegis-gitea-mcp/{__version__}" assert "Sudo" not in request.headers assert request.content == b"" assert outcome.is_error is False assert outcome.status == 200 assert list(outcome.payload) == [ "identity", "request", "status", "pagination", "untrusted_content", "data", ] assert outcome.payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None} assert outcome.payload["request"] == { "method": "GET", "path": "/repos/octo/demo/branches", "operation": LIST_BRANCHES.tool_name, } assert outcome.payload["status"] == 200 assert outcome.payload["untrusted_content"] == UNTRUSTED_NOTICE assert outcome.payload["data"] == [{"name": "main"}] assert json.loads(outcome.to_text()) == outcome.payload async def test_oauth_identity_uses_bearer_and_sudo_header( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[]) outcome = await executor.execute( LIST_BRANCHES, {**BASE_ARGS, "sudo": "bob.smith"}, oauth_identity() ) request = only_request(httpx_mock) assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}" assert request.headers["Sudo"] == "bob.smith" assert outcome.payload["identity"] == {"login": "alice", "mode": "oauth", "sudo": "bob.smith"} async def test_identity_without_token_sends_no_authorization( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[]) identity = Identity(login=None, mode="oauth", token="") await executor.execute(LIST_BRANCHES, BASE_ARGS, identity) assert "Authorization" not in only_request(httpx_mock).headers @pytest.mark.parametrize("sudo", ["", "bob smith", "bob\r\nX-Evil: 1", 5, "a" * 256]) async def test_invalid_sudo_is_rejected_without_request( executor: GiteaExecutor, httpx_mock: HTTPXMock, sudo: Any ) -> None: outcome = await executor.execute(LIST_BRANCHES, {**BASE_ARGS, "sudo": sudo}, admin_identity()) assert error_of(outcome)["kind"] == "invalid_argument" assert outcome.status is None assert outcome.payload["identity"]["sudo"] is None assert httpx_mock.get_requests() == [] async def test_accept_header_lists_all_produced_types( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: operation = make_op( "repoCompareDiff", "GET", "/repos/{owner}/{repo}/labels", [param("owner", "path"), param("repo", "path")], produces=("application/json", "text/plain"), ) no_produces = make_op("x", "GET", "/ping", [], produces=()) httpx_mock.add_response(json={}) httpx_mock.add_response(json={}) await executor.execute(operation, BASE_ARGS, admin_identity()) await executor.execute(no_produces, {}, admin_identity()) first, second = httpx_mock.get_requests() assert first.headers["Accept"] == "application/json, text/plain" assert second.headers["Accept"] == "application/json" async def test_slashes_kept_in_filepath_and_unicode_encoded( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(content=b"data", headers={"content-type": "application/octet-stream"}) httpx_mock.add_response(json={"name": "x"}) await executor.execute( GET_RAW_FILE, {**BASE_ARGS, "filepath": "src/app/mein file.py", "ref": "feature/ünï"}, admin_identity(), ) await executor.execute(GET_BRANCH, {**BASE_ARGS, "branch": "feature/für/✓"}, admin_identity()) raw, branch = httpx_mock.get_requests() assert raw.url.raw_path == ( b"/api/v1/repos/octo/demo/raw/src/app/mein%20file.py?ref=feature%2F%C3%BCn%C3%AF" ) assert branch.url.raw_path == (b"/api/v1/repos/octo/demo/branches/feature/f%C3%BCr/%E2%9C%93") async def test_non_slash_parameters_encode_slashes( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[]) outcome = await executor.execute( LIST_BRANCHES, {"owner": "a/b", "repo": "r e:p"}, admin_identity() ) assert only_request(httpx_mock).url.raw_path == b"/api/v1/repos/a%2Fb/r%20e%3Ap/branches" assert outcome.payload["request"]["path"] == "/repos/a/b/r e:p/branches" async def test_integer_and_dotted_placeholders( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(text="diff --git", headers={"content-type": "text/plain"}) outcome = await executor.execute( PULL_DIFF, {**BASE_ARGS, "index": 7, "diffType": "diff"}, admin_identity() ) request = only_request(httpx_mock) assert request.url.path == "/api/v1/repos/octo/demo/pulls/7.diff" assert request.headers["Accept"] == "text/plain" assert outcome.payload["data"] == "diff --git" @pytest.mark.parametrize( "value", [ "", ".", "..", "a/../b", "../etc", "a//b", "a\\b", "a?b=1", "a#frag", "a%2Fb", "100%", "a\x00b", "a\nb", "a\u2028b", "a\ud800b", ], ) @pytest.mark.parametrize("name", ["owner", "filepath"]) async def test_unsafe_path_values_are_rejected_without_request( executor: GiteaExecutor, httpx_mock: HTTPXMock, name: str, value: str ) -> None: arguments = {**BASE_ARGS, "filepath": "ok/file.txt"} arguments[name] = value outcome = await executor.execute(GET_RAW_FILE, arguments, admin_identity()) error = error_of(outcome) assert error["kind"] == "invalid_argument" assert error["status"] is None assert outcome.status is None assert outcome.payload["request"]["path"] == GET_RAW_FILE.path assert httpx_mock.get_requests() == [] @pytest.mark.parametrize( ("arguments", "needle"), [ ({"repo": "demo"}, "missing path parameter 'owner'"), ({"owner": None, "repo": "demo"}, "missing path parameter 'owner'"), ({"owner": True, "repo": "demo"}, "must be a string or integer"), ({"owner": 1.5, "repo": "demo"}, "must be a string or integer"), ({"owner": ["a"], "repo": "demo"}, "must be a string or integer"), ], ) async def test_missing_or_mistyped_path_parameter( executor: GiteaExecutor, httpx_mock: HTTPXMock, arguments: dict[str, Any], needle: str ) -> None: outcome = await executor.execute(LIST_BRANCHES, arguments, admin_identity()) assert needle in error_of(outcome)["message"] assert httpx_mock.get_requests() == [] async def test_placeholder_without_parameter_fails_closed( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: operation = make_op("broken", "GET", "/repos/{owner}/{nothere}", [param("owner", "path")]) outcome = await executor.execute(operation, {"owner": "o"}, admin_identity()) assert "has no matching parameter" in error_of(outcome)["message"] assert httpx_mock.get_requests() == [] async def test_rejected_value_is_never_echoed( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: outcome = await executor.execute( LIST_BRANCHES, {"owner": f"../{USER_TOKEN}", "repo": "demo"}, admin_identity() ) assert USER_TOKEN not in json.dumps(outcome.payload) assert httpx_mock.get_requests() == [] async def test_query_arrays_booleans_and_skipped_values( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[]) await executor.execute( LIST_PULLS, { **BASE_ARGS, "labels": [3, 5], "state": None, "draft": False, "csvlist": ["a", "b"], "page": 1, "limit": 1.0, "unknown": "ignored", }, admin_identity(), ) query = only_request(httpx_mock).url.query.decode() assert query == "labels=3&labels=5&draft=false&csvlist=a%2Cb&page=1&limit=1.0" async def test_query_true_empty_array_and_scalar_to_array( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json=[]) await executor.execute( LIST_PULLS, {**BASE_ARGS, "draft": True, "labels": [], "csvlist": "single"}, admin_identity(), ) assert only_request(httpx_mock).url.query.decode() == "draft=true&csvlist=single" async def test_query_with_unsupported_type_is_rejected( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: outcome = await executor.execute( LIST_PULLS, {**BASE_ARGS, "state": {"nested": 1}}, admin_identity() ) assert "query parameter 'state'" in error_of(outcome)["message"] assert httpx_mock.get_requests() == [] async def test_reserved_query_names_are_rejected( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: operation = make_op("x", "GET", "/ping", [param("token", "query"), param("Sudo", "query")]) first = await executor.execute(operation, {"token": "abc"}, admin_identity()) second = await executor.execute(operation, {"Sudo": "root"}, admin_identity()) assert "reserved" in error_of(first)["message"] assert "reserved" in error_of(second)["message"] assert httpx_mock.get_requests() == [] # Bodies async def test_json_body_is_sent_and_not_returned( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(status_code=201, json={"number": 1}) body = {"title": "Bug", "body": "details ünï"} outcome = await executor.execute(CREATE_ISSUE, {**BASE_ARGS, "body": body}, admin_identity()) request = only_request(httpx_mock) assert request.method == "POST" assert json.loads(request.content) == body assert request.headers["Content-Type"] == "application/json" assert outcome.status == 201 assert "details" not in json.dumps(outcome.payload) async def test_missing_body_sends_no_content( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(status_code=201, json={}) await executor.execute(CREATE_ISSUE, BASE_ARGS, admin_identity()) assert only_request(httpx_mock).content == b"" async def test_text_plain_body(executor: GiteaExecutor, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(text="
x
", headers={"content-type": "text/html"}) outcome = await executor.execute(RENDER_RAW, {"body": "# héllo"}, admin_identity()) request = only_request(httpx_mock) assert request.content == "# héllo".encode() assert request.headers["Content-Type"] == "text/plain" assert outcome.payload["data"] == "x
" async def test_text_plain_body_must_be_a_string( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: outcome = await executor.execute(RENDER_RAW, {"body": {"a": 1}}, admin_identity()) assert "must be a string" in error_of(outcome)["message"] assert httpx_mock.get_requests() == [] async def test_multipart_upload_decodes_base64( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(status_code=201, json={"id": 9}) payload = bytes(range(256)) outcome = await executor.execute( UPLOAD_ATTACHMENT, { **BASE_ARGS, "id": 4, "name": "tool.bin", "attachment": { "filename": "tool.bin", "content_base64": base64.b64encode(payload).decode(), "content_type": "application/x-tool", }, }, admin_identity(), ) request = only_request(httpx_mock) assert request.url.query == b"name=tool.bin" assert request.headers["Content-Type"].startswith("multipart/form-data; boundary=") assert b'name="attachment"; filename="tool.bin"' in request.content assert b"Content-Type: application/x-tool" in request.content assert payload in request.content assert outcome.status == 201 async def test_multipart_default_content_type_and_plain_form_field( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: operation = make_op( "x", "POST", "/upload", [param("attachment", "formData", is_file=True), param("note", "formData")], consumes=("multipart/form-data",), ) httpx_mock.add_response(status_code=201, json={}) await executor.execute( operation, {"attachment": {"filename": "a.txt", "content_base64": "aGk="}, "note": 5}, admin_identity(), ) content = only_request(httpx_mock).content assert b"Content-Type: application/octet-stream" in content assert b'name="note"' in content assert b"\r\n\r\n5\r\n" in content @pytest.mark.parametrize( ("attachment", "needle"), [ ("not-an-object", "must be an object"), ({"filename": "a", "content_base64": "aGk=", "extra": 1}, "unexpected keys"), ({"filename": 5, "content_base64": "aGk="}, "needs string filename"), ({"filename": "a"}, "needs string filename"), ({"filename": "", "content_base64": "aGk="}, "non-empty single-line"), ({"filename": "a\nb", "content_base64": "aGk="}, "non-empty single-line"), ({"filename": "x" * 256, "content_base64": "aGk="}, "non-empty single-line"), ({"filename": 'a"b', "content_base64": "aGk="}, "double quote"), ({"filename": "a", "content_base64": "!!!notbase64"}, "not valid base64"), ({"filename": "a", "content_base64": "aGk"}, "not valid base64"), ({"filename": "a", "content_base64": "aG k="}, "not valid base64"), ({"filename": "a", "content_base64": "aGk=", "content_type": 5}, "must be a string"), ({"filename": "a", "content_base64": "aGk=", "content_type": "a\r\nb"}, "single-line"), ], ) async def test_invalid_file_arguments( executor: GiteaExecutor, httpx_mock: HTTPXMock, attachment: Any, needle: str ) -> None: outcome = await executor.execute( UPLOAD_ATTACHMENT, {**BASE_ARGS, "id": 1, "attachment": attachment}, admin_identity() ) error = error_of(outcome) assert error["kind"] == "invalid_argument" assert needle in error["message"] assert httpx_mock.get_requests() == [] async def test_upload_without_file_sends_no_body( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(status_code=201, json={}) await executor.execute(UPLOAD_ATTACHMENT, {**BASE_ARGS, "id": 1}, admin_identity()) assert only_request(httpx_mock).content == b"" async def test_delete_with_204_returns_null_data( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(status_code=204) outcome = await executor.execute( DELETE_BRANCH, {**BASE_ARGS, "branch": "old/branch"}, admin_identity() ) assert only_request(httpx_mock).method == "DELETE" assert outcome.is_error is False assert outcome.status == 204 assert outcome.payload["data"] is None # Responses async def test_redirects_are_never_followed(executor: GiteaExecutor, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response(status_code=302, headers={"Location": "https://evil.example.net/steal"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert len(httpx_mock.get_requests()) == 1 error = error_of(outcome) assert error["kind"] == "redirect" assert error["status"] == 302 assert outcome.status == 302 assert "evil.example.net" not in json.dumps(outcome.payload) async def test_text_response(executor: GiteaExecutor, httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( text="line1\nline2", headers={"content-type": "text/plain; charset=utf-8"} ) outcome = await executor.execute( PULL_DIFF, {**BASE_ARGS, "index": 1, "diffType": "patch"}, admin_identity() ) assert outcome.payload["data"] == "line1\nline2" assert "truncated" not in outcome.payload @pytest.mark.parametrize( "content_type", ["application/xml", "application/x-patch", "application/vnd.foo+xml", "text/x-diff"], ) async def test_text_media_types( executor: GiteaExecutor, httpx_mock: HTTPXMock, content_type: str ) -> None: httpx_mock.add_response(content=b"", headers={"content-type": content_type}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert outcome.payload["data"] == "" async def test_json_variants_and_fallback_to_text( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response( content=b'{"a": 1}', headers={"content-type": "application/vnd.api+json"} ) httpx_mock.add_response(content=b"not json {", headers={"content-type": "application/json"}) httpx_mock.add_response(content=b"\xff\xfe bad", headers={"content-type": "application/json"}) httpx_mock.add_response(content=b"[1, 2]", headers={}) outcomes = [ await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) for _ in range(4) ] assert outcomes[0].payload["data"] == {"a": 1} assert outcomes[1].payload["data"] == "not json {" assert outcomes[2].payload["data"] == "�� bad" assert outcomes[3].payload["data"] == [1, 2] async def test_binary_response_is_base64_with_size( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: blob = bytes(range(200)) httpx_mock.add_response(content=blob, headers={"content-type": "application/octet-stream"}) httpx_mock.add_response(content=b"PK", headers={"content-type": "application/zip"}) httpx_mock.add_response(content=b"zz", headers={"content-type": "image/png; foo=bar"}) first = await executor.execute( GET_RAW_FILE, {**BASE_ARGS, "filepath": "a.bin"}, admin_identity() ) second = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) third = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert first.payload["data"] == { "content_base64": base64.b64encode(blob).decode(), "content_type": "application/octet-stream", "size": 200, } assert second.payload["data"]["content_type"] == "application/zip" assert third.payload["data"]["content_type"] == "image/png; foo=bar" assert third.payload["data"]["size"] == 2 async def test_binary_over_limit_by_content_length(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_binary_bytes=10)) httpx_mock.add_response(content=b"x" * 11, headers={"content-type": "application/zip"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) error = error_of(outcome) assert error["kind"] == "payload_too_large" assert error["size"] == 11 assert error["limit"] == 10 assert "11 bytes" in error["message"] assert outcome.status == 200 await executor.aclose() async def test_binary_exactly_at_limit_is_allowed(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_binary_bytes=10)) httpx_mock.add_response(content=b"x" * 10, headers={"content-type": "application/zip"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert outcome.payload["data"]["size"] == 10 await executor.aclose() class _ChunkedStream(httpx.AsyncByteStream): async def __aiter__(self) -> AsyncIterator[bytes]: for _ in range(5): yield b"x" * 4 async def test_binary_over_limit_without_declared_size(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_binary_bytes=10)) httpx_mock.add_response(headers={"content-type": "application/zip"}, stream=_ChunkedStream()) httpx_mock.add_response( content=b"x" * 20, headers={"content-type": "application/zip", "content-length": "bogus"}, ) first = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) second = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) for outcome in (first, second): error = error_of(outcome) assert error["kind"] == "payload_too_large" assert error["size"] is None assert "larger than the limit of 10 bytes" in error["message"] await executor.aclose() def test_declared_size_ignores_encoded_and_negative_lengths() -> None: encoded = httpx.Response(200, headers={"content-encoding": "gzip", "content-length": "5"}) negative = httpx.Response(200, headers={"content-length": "-1"}) plain = httpx.Response(200, headers={"content-length": "5"}) assert executor_module._declared_size(encoded) is None assert executor_module._declared_size(negative) is None assert executor_module._declared_size(plain) == 5 async def test_json_body_over_hard_cap_is_an_error( executor: GiteaExecutor, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(executor_module, "_MAX_TEXT_BYTES", 20) httpx_mock.add_response(json={"k": "v" * 100}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert error_of(outcome)["kind"] == "payload_too_large" async def test_text_body_over_hard_cap_is_truncated( executor: GiteaExecutor, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(executor_module, "_MAX_TEXT_BYTES", 20) httpx_mock.add_response(text="é" * 50, headers={"content-type": "text/plain"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert outcome.is_error is False assert outcome.payload["truncated"] == {"returned": 10, "available": None, "unit": "chars"} assert outcome.payload["data"] == "é" * 10 async def test_exact_chunk_boundary_for_hard_cap( executor: GiteaExecutor, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(executor_module, "_MAX_TEXT_BYTES", 10) httpx_mock.add_response(text="a" * 10, headers={"content-type": "text/plain"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert "truncated" not in outcome.payload assert outcome.payload["data"] == "a" * 10 async def test_list_is_truncated_to_max_items(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_tool_response_items=3)) httpx_mock.add_response(json=[{"i": i} for i in range(5)]) httpx_mock.add_response(json=[{"i": i} for i in range(3)]) big = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) exact = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert big.payload["data"] == [{"i": 0}, {"i": 1}, {"i": 2}] assert big.payload["truncated"] == {"returned": 3, "available": 5, "unit": "items"} assert "truncated" not in exact.payload await executor.aclose() async def test_text_over_char_limit_is_truncated_to_fit_envelope(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_tool_response_chars=1000)) httpx_mock.add_response(text='é"' * 1000, headers={"content-type": "text/plain"}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert len(outcome.to_text()) <= 1000 truncated = outcome.payload["truncated"] assert truncated["available"] == 2000 assert truncated["unit"] == "chars" assert truncated["returned"] == len(outcome.payload["data"]) assert 0 < truncated["returned"] < 2000 assert list(outcome.payload)[-2:] == ["untrusted_content", "data"] await executor.aclose() async def test_huge_json_object_becomes_truncated_json_string(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_tool_response_chars=700)) httpx_mock.add_response(json={"blob": "x" * 5000}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert len(outcome.to_text()) <= 700 assert isinstance(outcome.payload["data"], str) assert outcome.payload["data"].startswith('{"blob": "xxx') assert outcome.payload["truncated"]["unit"] == "chars" assert outcome.payload["truncated"]["available"] == len(json.dumps({"blob": "x" * 5000})) await executor.aclose() async def test_item_truncation_then_size_truncation(httpx_mock: HTTPXMock) -> None: executor = GiteaExecutor(make_settings(max_tool_response_items=2, max_tool_response_chars=900)) httpx_mock.add_response(json=[{"v": "y" * 400}] * 3) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) truncated = outcome.payload["truncated"] assert truncated["items_returned"] == 2 assert truncated["items_available"] == 3 assert truncated["unit"] == "chars" assert len(outcome.to_text()) <= 900 await executor.aclose() async def test_envelope_overhead_larger_than_limit_leaves_empty_data( httpx_mock: HTTPXMock, ) -> None: executor = GiteaExecutor(make_settings(max_tool_response_chars=10)) httpx_mock.add_response(json={"a": 1}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert outcome.payload["data"] == "" assert outcome.payload["truncated"]["returned"] == 0 await executor.aclose() async def test_small_response_within_limit_is_untouched( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response(json={"a": 1}) outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity()) assert outcome.payload["data"] == {"a": 1} assert "truncated" not in outcome.payload # Pagination async def test_pagination_from_link_and_total_count( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: link = ( f'<{API}/repos/octo/demo/branches?page=3&limit=30>; rel="next", ' f'<{API}/repos/octo/demo/branches?page=4&limit=30>; rel="last", ' f'<{API}/repos/octo/demo/branches?page=1&limit=30>; rel="first", ' f'<{API}/repos/octo/demo/branches?page=1&limit=30>; rel="prev"' ) httpx_mock.add_response(json=[], headers={"Link": link, "X-Total-Count": "120"}) outcome = await executor.execute(LIST_BRANCHES, {**BASE_ARGS, "page": 2}, admin_identity()) assert outcome.payload["pagination"] == { "page": 2, "limit": 30, "total_count": 120, "next_page": 3, "last_page": 4, } async def test_pagination_infers_page_when_not_requested( executor: GiteaExecutor, httpx_mock: HTTPXMock ) -> None: httpx_mock.add_response( json=[], headers={"Link": '