Files

1504 lines
51 KiB
Python

"""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="<p>x</p>", 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"] == "<p>x</p>"
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"<a/>", headers={"content-type": content_type})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert outcome.payload["data"] == "<a/>"
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": '<https://x/?page=2&limit=5>; rel="next"', "X-Total-Count": "9"}
)
httpx_mock.add_response(json=[], headers={"Link": '<https://x/?page=1&limit=5>; rel="prev"'})
first = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
last = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert first.payload["pagination"]["page"] == 1
assert first.payload["pagination"]["next_page"] == 2
assert last.payload["pagination"]["page"] == 2
assert last.payload["pagination"]["last_page"] == 2
async def test_paginated_operation_without_headers_reports_defaults(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(json=[])
outcome = await executor.execute(LIST_BRANCHES, {**BASE_ARGS, "limit": 50}, admin_identity())
assert outcome.payload["pagination"] == {
"page": 1,
"limit": 50,
"total_count": None,
"next_page": None,
"last_page": 1,
}
async def test_unpaginated_operation_with_total_count_header(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(json=[], headers={"X-Total-Count": "0"})
httpx_mock.add_response(json={})
with_header = await executor.execute(CREATE_ISSUE, BASE_ARGS, admin_identity())
without = await executor.execute(CREATE_ISSUE, BASE_ARGS, admin_identity())
assert with_header.payload["pagination"]["total_count"] == 0
assert "pagination" not in without.payload
# Errors
@pytest.mark.parametrize(
("status", "kind"),
[
(400, "bad_request"),
(401, "unauthorized"),
(403, "forbidden"),
(404, "not_found"),
(405, "method_not_allowed"),
(409, "conflict"),
(410, "gone"),
(412, "precondition_failed"),
(413, "payload_too_large"),
(422, "validation_failed"),
(423, "locked"),
(429, "rate_limited"),
(418, "client_error"),
(500, "server_error"),
(503, "server_error"),
],
)
async def test_http_error_kinds(
executor: GiteaExecutor, httpx_mock: HTTPXMock, status: int, kind: str
) -> None:
httpx_mock.add_response(
status_code=status, json={"message": "nope", "url": "https://x/swagger"}
)
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert outcome.is_error
assert outcome.status == status
assert outcome.payload == {
"error": {"status": status, "kind": kind, "message": "nope"},
"identity": {"login": "admin", "mode": "admin-token", "sudo": None},
"request": {
"method": "GET",
"path": "/repos/octo/demo/branches",
"operation": LIST_BRANCHES.tool_name,
},
}
async def test_error_message_joins_validation_errors(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(
status_code=422,
json={"message": "Validation failed", "errors": ["title is empty", 7]},
)
outcome = await executor.execute(CREATE_ISSUE, BASE_ARGS, admin_identity())
assert error_of(outcome)["message"] == "Validation failed; title is empty; 7"
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"text": "<html> Bad\n gateway </html>"}, "<html> Bad gateway </html>"),
({"content": b""}, "Gitea returned HTTP 502"),
({"json": {"url": "only"}}, "Gitea returned HTTP 502"),
({"json": {"message": "", "errors": "notalist"}}, "Gitea returned HTTP 502"),
({"json": ["a", "b"]}, '["a","b"]'),
({"content": b"\xff\xfe"}, "��"),
],
)
async def test_error_message_fallbacks(
executor: GiteaExecutor, httpx_mock: HTTPXMock, kwargs: dict[str, Any], expected: str
) -> None:
httpx_mock.add_response(status_code=502, **kwargs)
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert error_of(outcome)["message"] == expected
async def test_error_message_is_sanitized_and_truncated(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
message = f"Authorization: Bearer abcdefghijklmnop failed {GH_TOKEN} " + "z" * 600
httpx_mock.add_response(status_code=400, json={"message": message})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
shown = error_of(outcome)["message"]
assert "abcdefghijklmnop" not in shown
assert GH_TOKEN not in shown
assert len(shown) <= 500
assert shown.endswith("...")
async def test_error_body_read_is_capped(
executor: GiteaExecutor, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(executor_module, "_MAX_ERROR_BODY_BYTES", 5)
httpx_mock.add_response(status_code=500, text="abcdefghij")
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert error_of(outcome)["message"] == "abcde"
@pytest.mark.parametrize(
("exception", "kind"),
[
(httpx.ReadTimeout("slow https://gitea.example.com/x?access_token=SECRET"), "timeout"),
(httpx.ConnectTimeout("slow"), "timeout"),
(
httpx.ConnectError("refused https://gitea.example.com/x?access_token=SECRET"),
"upstream_unreachable",
),
(httpx.RemoteProtocolError("broken"), "upstream_unreachable"),
],
)
async def test_transport_errors(
executor: GiteaExecutor, httpx_mock: HTTPXMock, exception: Exception, kind: str
) -> None:
httpx_mock.add_exception(exception)
outcome = await executor.execute(LIST_BRANCHES, {**BASE_ARGS, "page": 1}, admin_identity())
error = error_of(outcome)
assert error["kind"] == kind
assert error["status"] is None
assert outcome.status is None
assert "exception" not in error
dumped = json.dumps(outcome.payload)
assert "SECRET" not in dumped
assert "page=1" not in dumped
assert "gitea.example.com" not in dumped
async def test_timeout_message_names_configured_seconds(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(request_timeout_seconds=7))
httpx_mock.add_exception(httpx.ReadTimeout("slow"))
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert "7s" in error_of(outcome)["message"]
await executor.aclose()
async def test_expose_error_details_adds_only_class_name(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(expose_error_details=True))
httpx_mock.add_exception(httpx.ConnectError("refused"))
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
error = error_of(outcome)
assert error["exception"] == "ConnectError"
assert "Traceback" not in json.dumps(outcome.payload)
await executor.aclose()
class _BrokenStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b"partial"
raise httpx.ReadError("connection reset")
async def test_failure_while_reading_the_body(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(headers={"content-type": "application/json"}, stream=_BrokenStream())
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert error_of(outcome)["kind"] == "upstream_unreachable"
# Redaction
@pytest.mark.parametrize(
("secret", "identity"),
[
(GITEA_TOKEN, admin_identity()),
(USER_TOKEN, oauth_identity()),
(BEARER_TOKEN, admin_identity()),
(OAUTH_CLIENT_SECRET, admin_identity()),
("extra-plain-secret-value", admin_identity()),
],
)
async def test_every_secret_source_is_redacted_everywhere(
httpx_mock: HTTPXMock, secret: str, identity: Identity
) -> None:
executor = GiteaExecutor(make_settings(), extra_secrets=["extra-plain-secret-value"])
echoed = {
"plain": f"x {secret} y",
"nested": [{"k": secret}],
f"key-{secret}": "v",
"n": 1,
}
httpx_mock.add_response(json=echoed)
httpx_mock.add_response(status_code=403, json={"message": f"bad token {secret}"})
ok = await executor.execute(LIST_BRANCHES, BASE_ARGS, identity)
failed = await executor.execute(LIST_BRANCHES, BASE_ARGS, identity)
assert secret not in json.dumps(ok.payload)
assert secret not in json.dumps(failed.payload)
assert ok.payload["data"]["plain"] == "x [REDACTED] y"
assert ok.payload["data"]["nested"] == [{"k": "[REDACTED]"}]
assert ok.payload["data"]["n"] == 1
assert "[REDACTED]" in failed.payload["error"]["message"]
await executor.aclose()
async def test_redaction_applies_even_when_secret_detection_is_off(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(secret_detection_mode="off"))
httpx_mock.add_response(json={"echo": GITEA_TOKEN, "pattern": GH_TOKEN})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert outcome.payload["data"] == {"echo": "[REDACTED]", "pattern": GH_TOKEN}
await executor.aclose()
async def test_exact_redaction_with_no_configured_secrets(httpx_mock: HTTPXMock) -> None:
settings = make_settings(
gitea_token=None, mcp_bearer_token=None, gitea_oauth_client_secret=None
)
executor = GiteaExecutor(settings)
httpx_mock.add_response(json={"echo": USER_TOKEN})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, oauth_identity())
assert outcome.payload["data"] == {"echo": "[REDACTED]"}
await executor.aclose()
async def test_mask_mode_masks_secret_like_strings(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(json={"note": f"see {GH_TOKEN}"})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert GH_TOKEN not in json.dumps(outcome.payload)
assert "ghp_...N4o5" in outcome.payload["data"]["note"]
assert outcome.is_error is False
async def test_credential_issuing_operations_skip_masking_but_not_exact_redaction(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
minted = {"sha1": GH_TOKEN, "leak": GITEA_TOKEN}
httpx_mock.add_response(status_code=201, json=minted)
outcome = await executor.execute(
CREATE_TOKEN, {"username": "alice", "body": {"name": "ci"}}, admin_identity()
)
assert CREATE_TOKEN.operation_id in CREDENTIAL_ISSUING_OPERATIONS
assert outcome.is_error is False
assert outcome.payload["data"] == {"sha1": GH_TOKEN, "leak": "[REDACTED]"}
async def test_credential_issuing_errors_are_still_masked(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(status_code=400, json={"message": f"x {GH_TOKEN}"})
outcome = await executor.execute(
CREATE_TOKEN, {"username": "alice", "body": {"name": "ci"}}, admin_identity()
)
assert GH_TOKEN not in json.dumps(outcome.payload)
async def test_registration_token_is_returned_from_the_response_header(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(headers={"token": GH_TOKEN})
httpx_mock.add_response()
issued = await executor.execute(REGISTRATION_TOKEN, BASE_ARGS, admin_identity())
absent = await executor.execute(REGISTRATION_TOKEN, BASE_ARGS, admin_identity())
assert issued.payload["data"] == {"token": GH_TOKEN}
assert absent.payload["data"] is None
async def test_header_data_is_ignored_for_other_operations(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(headers={"token": "not-for-you"})
outcome = await executor.execute(DELETE_BRANCH, {**BASE_ARGS, "branch": "b"}, admin_identity())
assert outcome.payload["data"] is None
async def test_block_mode_blocks_secret_like_results(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(secret_detection_mode="block"))
httpx_mock.add_response(json={"note": f"see {GH_TOKEN}"})
httpx_mock.add_response(json={"note": "clean"})
httpx_mock.add_response(status_code=400, json={"message": f"leak {GH_TOKEN}"})
blocked = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
clean = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
blocked_error = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert blocked.is_error
assert blocked.status == 200
assert error_of(blocked)["kind"] == "secret_blocked"
assert error_of(blocked)["status"] == 200
assert set(blocked.payload) == {"error", "identity", "request"}
assert GH_TOKEN not in json.dumps(blocked.payload)
assert clean.is_error is False
assert clean.payload["data"] == {"note": "clean"}
assert error_of(blocked_error)["kind"] == "bad_request"
assert GH_TOKEN not in json.dumps(blocked_error.payload)
assert blocked_error.status == 400
await executor.aclose()
async def test_block_mode_exempts_credential_issuing_results(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(secret_detection_mode="block"))
httpx_mock.add_response(status_code=201, json={"sha1": GH_TOKEN})
outcome = await executor.execute(
CREATE_TOKEN, {"username": "alice", "body": {"name": "ci"}}, admin_identity()
)
assert outcome.is_error is False
assert outcome.payload["data"] == {"sha1": GH_TOKEN}
await executor.aclose()
async def test_block_mode_does_not_echo_secret_like_request_path(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(secret_detection_mode="block"))
httpx_mock.add_response(json={"note": f"see {GH_TOKEN}"})
outcome = await executor.execute(
LIST_BRANCHES, {"owner": GH_TOKEN, "repo": "demo"}, admin_identity()
)
assert error_of(outcome)["kind"] == "secret_blocked"
assert GH_TOKEN not in json.dumps(outcome.payload)
await executor.aclose()
async def test_action_secret_value_is_never_echoed(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
value = "s3cr3t-deploy-value-42"
httpx_mock.add_response(status_code=422, json={"message": f"invalid secret {value}"})
httpx_mock.add_response(status_code=201, json={"echo": value, "nested": [value]})
arguments = {**BASE_ARGS, "secretname": "DEPLOY", "body": {"data": value, "description": "d"}}
failed = await executor.execute(PUT_SECRET, arguments, admin_identity())
echoed = await executor.execute(PUT_SECRET, arguments, admin_identity())
assert value not in json.dumps(failed.payload)
assert value not in json.dumps(echoed.payload)
assert value in httpx_mock.get_requests()[0].content.decode()
assert echoed.payload["data"]["echo"] == "[REDACTED]"
async def test_short_action_secret_value_is_redacted_too(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(status_code=422, json={"message": "bad ZQ value"})
outcome = await executor.execute(
PUT_SECRET,
{**BASE_ARGS, "secretname": "S", "body": {"data": "ZQ"}},
admin_identity(),
)
assert "ZQ" not in json.dumps(outcome.payload)
async def test_sensitive_body_fields_are_redacted_from_results(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(
status_code=422, json={"message": "password Hunter2-Hunter2 rejected, ab, hook-secret-1"}
)
body = {
"username": "bob",
"password": "Hunter2-Hunter2",
"short": {"password": "ab"},
"config": {"secret": "hook-secret-1"},
"list": [{"token": "list-token-1"}],
}
outcome = await executor.execute(CREATE_USER, {"body": body}, admin_identity())
dumped = json.dumps(outcome.payload)
assert "Hunter2" not in dumped
assert "hook-secret-1" not in dumped
assert "rejected, ab" in dumped
async def test_secret_data_value_not_special_for_other_operations(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(status_code=201, json={"title": "keep-me"})
outcome = await executor.execute(
CREATE_ISSUE, {**BASE_ARGS, "body": {"data": "keep-me"}}, admin_identity()
)
assert outcome.payload["data"] == {"title": "keep-me"}
async def test_body_deeper_than_limit_is_not_scanned(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
deep: Any = {"password": "deepdeepdeep"}
for _ in range(12):
deep = {"x": deep}
httpx_mock.add_response(status_code=201, json={})
outcome = await executor.execute(CREATE_USER, {"body": deep}, admin_identity())
assert outcome.is_error is False
# Raw request
async def test_raw_request_get(executor: GiteaExecutor, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"version": "1.2"})
outcome = await executor.request(
"get",
"/repos/octo/dem ö/issues",
oauth_identity(),
query={"state": "open", "labels": ["a", "b"], "draft": True, "skip": None},
sudo="bob",
read_only_hint=True,
)
request = only_request(httpx_mock)
assert request.method == "GET"
assert request.url.raw_path == (
b"/api/v1/repos/octo/dem%20%C3%B6/issues?state=open&labels=a&labels=b&draft=true"
)
assert request.headers["Authorization"] == f"Bearer {USER_TOKEN}"
assert request.headers["Sudo"] == "bob"
assert outcome.payload["request"] == {
"method": "GET",
"path": "/repos/octo/dem ö/issues",
"operation": RAW_TOOL_NAME,
}
assert outcome.payload["identity"]["sudo"] == "bob"
assert outcome.payload["data"] == {"version": "1.2"}
assert "pagination" not in outcome.payload
async def test_raw_request_with_body_and_pagination(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(json=[])
outcome = await executor.request(
"POST", "/repos/o/r/issues", admin_identity(), query={"page": 2}, body={"title": "t"}
)
request = only_request(httpx_mock)
assert json.loads(request.content) == {"title": "t"}
assert request.headers["Content-Type"] == "application/json"
assert outcome.payload["pagination"]["page"] == 2
async def test_raw_request_redacts_secret_writes(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
value = "raw-secret-value-77"
httpx_mock.add_response(status_code=422, json={"message": f"bad {value}"})
outcome = await executor.request(
"PUT", "/repos/o/r/actions/secrets/NAME", admin_identity(), body={"data": value}
)
assert value not in json.dumps(outcome.payload)
async def test_raw_request_data_field_is_not_secret_outside_secret_paths(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(json={"echo": "plainvalue"})
outcome = await executor.request(
"POST", "/repos/o/r/hooks", admin_identity(), body={"data": "plainvalue"}
)
assert outcome.payload["data"] == {"echo": "plainvalue"}
@pytest.mark.parametrize(
"path",
[
"repos/o/r",
"/",
"//x",
"/a/../b",
"/a/./b",
"/a//b",
"/a/",
"/a?b=1",
"/a#x",
"/a%2Fb",
"/a\\b",
"/a\x00",
],
)
async def test_raw_request_rejects_unsafe_paths(
executor: GiteaExecutor, httpx_mock: HTTPXMock, path: str
) -> None:
outcome = await executor.request("GET", path, admin_identity())
assert error_of(outcome)["kind"] == "invalid_argument"
assert outcome.payload["request"]["operation"] == RAW_TOOL_NAME
assert httpx_mock.get_requests() == []
async def test_raw_request_rejects_bad_method_sudo_and_query(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
results = [
await executor.request("TRACE", "/user", admin_identity()),
await executor.request(5, "/user", admin_identity()), # type: ignore[arg-type]
await executor.request("GET", "/user", admin_identity(), sudo="bad user"),
await executor.request("GET", "/user", admin_identity(), query={"access_token": "x"}),
await executor.request("GET", "/user", admin_identity(), query={"sudo": "root"}),
await executor.request("GET", "/user", admin_identity(), query={"a": object()}),
await executor.request("GET", 5, admin_identity()), # type: ignore[arg-type]
]
assert all(error_of(result)["kind"] == "invalid_argument" for result in results)
assert results[0].payload["request"]["method"] == "(invalid)"
assert results[-1].payload["request"]["path"] == "(invalid)"
assert httpx_mock.get_requests() == []
async def test_raw_request_echoes_only_a_bounded_invalid_path(
executor: GiteaExecutor,
) -> None:
outcome = await executor.request("GET", "x" * 500, admin_identity())
assert len(outcome.payload["request"]["path"]) == 200
# Client handling
async def test_cookies_are_not_persisted_between_calls(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings())
httpx_mock.add_response(json=[], headers={"Set-Cookie": "session=abc; Path=/"})
httpx_mock.add_response(json=[])
await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
await executor.execute(LIST_BRANCHES, BASE_ARGS, oauth_identity())
assert "Cookie" not in httpx_mock.get_requests()[1].headers
await executor.aclose()
async def test_aclose_closes_only_owned_clients() -> None:
shared = httpx.AsyncClient()
borrowed = GiteaExecutor(make_settings(), client=shared)
owned = GiteaExecutor(make_settings())
await borrowed.aclose()
assert shared.is_closed is False
inner = owned._client
await owned.aclose()
assert inner.is_closed is True
await shared.aclose()
async def test_injected_client_is_used_without_following_redirects(
httpx_mock: HTTPXMock,
) -> None:
client = httpx.AsyncClient(follow_redirects=True)
executor = GiteaExecutor(make_settings(), client=client)
httpx_mock.add_response(status_code=301, headers={"Location": "https://other.example.org/"})
outcome = await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert error_of(outcome)["kind"] == "redirect"
assert len(httpx_mock.get_requests()) == 1
await client.aclose()
async def test_timeout_comes_from_settings(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(request_timeout_seconds=12))
httpx_mock.add_response(json=[])
await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert only_request(httpx_mock).extensions["timeout"] == {
"connect": 12.0,
"read": 12.0,
"write": 12.0,
"pool": 12.0,
}
await executor.aclose()
async def test_api_url_comes_from_settings(httpx_mock: HTTPXMock) -> None:
executor = GiteaExecutor(make_settings(gitea_url="http://localhost:3000/gitea/api/v1/"))
httpx_mock.add_response(json=[])
await executor.execute(LIST_BRANCHES, BASE_ARGS, admin_identity())
assert str(only_request(httpx_mock).url).startswith("http://localhost:3000/gitea/api/v1/repos/")
await executor.aclose()
async def test_query_parameter_named_body_is_mapped_by_arg_name(
executor: GiteaExecutor, httpx_mock: HTTPXMock
) -> None:
operation = make_op(
"repoGetFileContents",
"GET",
"/repos/{owner}/{repo}/file-contents",
[
param("owner", "path"),
param("repo", "path"),
Parameter(
name="body",
arg_name="query_body",
location="query",
required=False,
schema={"type": "string"},
),
],
)
httpx_mock.add_response(json=[])
await executor.execute(
operation, {**BASE_ARGS, "query_body": "files", "body": {"ignored": 1}}, admin_identity()
)
request = only_request(httpx_mock)
assert request.url.query == b"body=files"
assert request.content == b""