237 lines
9.1 KiB
Python
237 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
PATH = ROOT / "ops/gitea-deploy-broker/hub_candidate_transport.py"
|
|
SPEC = importlib.util.spec_from_file_location("hub_candidate_transport_test", PATH)
|
|
transport = importlib.util.module_from_spec(SPEC)
|
|
assert SPEC.loader is not None
|
|
sys.modules[SPEC.name] = transport
|
|
SPEC.loader.exec_module(transport)
|
|
|
|
|
|
def _request(url: str, *, mode="json", maximum=None, output_fd=None):
|
|
return {
|
|
"url": url,
|
|
"headers": {
|
|
"Accept": "application/json" if mode == "json" else "application/zip",
|
|
"Authorization": "token " + "x" * 40,
|
|
"User-Agent": transport.USER_AGENT,
|
|
},
|
|
"maxBytes": maximum if maximum is not None else (
|
|
transport.MAX_METADATA_BYTES if mode == "json" else transport.MAX_ARTIFACT_BYTES
|
|
),
|
|
"outputFd": output_fd,
|
|
}
|
|
|
|
|
|
class Response:
|
|
status = 200
|
|
|
|
def __init__(self, chunks):
|
|
self.chunks = list(chunks)
|
|
self.reads = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def read1(self, limit):
|
|
self.reads.append(limit)
|
|
if not self.chunks:
|
|
return b""
|
|
chunk = self.chunks.pop(0)
|
|
return chunk[:limit]
|
|
|
|
def read(self, _limit):
|
|
pytest.fail("transport must use deadline-aware read1")
|
|
|
|
|
|
class Opener:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
self.requests = []
|
|
self.timeouts = []
|
|
|
|
def open(self, request, timeout):
|
|
self.requests.append(request)
|
|
self.timeouts.append(timeout)
|
|
return self.response
|
|
|
|
|
|
@pytest.mark.parametrize("url", [
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main",
|
|
"https://gitea.example.com:443/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/git/commits/" + "a" * 40,
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=push&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123/jobs?page=2&limit=100",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123/artifacts?name=hub-release-candidate-" + "a" * 40,
|
|
])
|
|
def test_fixed_json_routes_are_allowed(url):
|
|
assert transport._url_is_allowed(url, "json")
|
|
|
|
|
|
@pytest.mark.parametrize("url", [
|
|
"https://other.invalid/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
|
"http://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
|
"https://gitea.example.com:444/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
|
"https://user@gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=push&event=push&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=pull_request&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/99999999999999999999/jobs?page=1&limit=100",
|
|
"https://gitea.example.com/api/v1/repos/jens/other/actions/workflows",
|
|
])
|
|
def test_nonfixed_json_routes_are_rejected(url):
|
|
assert not transport._url_is_allowed(url, "json")
|
|
|
|
|
|
def test_zip_route_is_fixed_and_has_bounded_artifact_id():
|
|
assert transport._url_is_allowed(
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip", "zip"
|
|
)
|
|
assert not transport._url_is_allowed(
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/9223372036854775808/zip", "zip"
|
|
)
|
|
assert not transport._url_is_allowed(
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip?other=x", "zip"
|
|
)
|
|
|
|
|
|
def test_ipc_requires_exact_fields_unique_json_and_16kib_bound():
|
|
valid = json.dumps(_request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")).encode()
|
|
assert transport._read_request(io.BytesIO(valid))["maxBytes"] == transport.MAX_METADATA_BYTES
|
|
for invalid in (
|
|
b'{"url":"x","url":"y"}',
|
|
valid[:-1] + b',"unexpected":true}',
|
|
b'{"a":NaN}',
|
|
b"x" * (transport.MAX_INPUT_BYTES + 1),
|
|
):
|
|
with pytest.raises(Exception):
|
|
transport._read_request(io.BytesIO(invalid))
|
|
|
|
|
|
@pytest.mark.parametrize("patch", [
|
|
{"headers": {"Accept": "application/json", "Authorization": "token x\r\nInjected: yes", "User-Agent": transport.USER_AGENT}},
|
|
{"headers": {"Accept": "application/json", "Authorization": "token " + "x" * 40, "User-Agent": transport.USER_AGENT, "Cookie": "x"}},
|
|
{"maxBytes": True},
|
|
{"outputFd": 3},
|
|
])
|
|
def test_json_request_rejects_header_injection_extra_header_and_bad_limits(patch):
|
|
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")
|
|
value.update(patch)
|
|
with pytest.raises(transport.TransportError):
|
|
transport._validate_request(value, "json")
|
|
|
|
|
|
def test_json_mode_emits_only_raw_bounded_response_bytes():
|
|
body = b'{"workflows":[]}'
|
|
opener = Opener(Response([body]))
|
|
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows")
|
|
result = transport.execute(value, "json", opener_factory=lambda mode: opener)
|
|
assert result == body
|
|
assert opener.timeouts == [10]
|
|
headers = {key.lower(): val for key, val in opener.requests[0].header_items()}
|
|
assert headers == {
|
|
"accept": "application/json",
|
|
"authorization": "token " + "x" * 40,
|
|
"user-agent": transport.USER_AGENT,
|
|
}
|
|
|
|
|
|
def test_zip_mode_streams_only_to_inherited_fd_and_reports_byte_count():
|
|
read_fd, write_fd = os.pipe()
|
|
try:
|
|
response = Response([b"zip-", b"bytes"])
|
|
opener = Opener(response)
|
|
value = _request(
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip",
|
|
mode="zip", output_fd=write_fd,
|
|
)
|
|
assert transport.execute(value, "zip", opener_factory=lambda mode: opener) == 9
|
|
os.close(write_fd)
|
|
write_fd = -1
|
|
assert os.read(read_fd, 32) == b"zip-bytes"
|
|
assert opener.timeouts == [60]
|
|
finally:
|
|
if write_fd >= 0:
|
|
os.close(write_fd)
|
|
os.close(read_fd)
|
|
|
|
|
|
def test_redirect_strips_authorization_and_rejects_other_hosts():
|
|
request = transport.Request(
|
|
"https://gitea.example.com/api/v1/artifact",
|
|
headers={"Accept": "application/zip", "Authorization": "token secret", "User-Agent": transport.USER_AGENT},
|
|
)
|
|
handler = transport._SameHostArtifactRedirect()
|
|
redirected = handler.redirect_request(
|
|
request, None, 302, "", {}, "https://gitea.example.com/attachments/opaque?signature=opaque"
|
|
)
|
|
assert redirected is not None
|
|
assert {key.lower(): value for key, value in redirected.header_items()} == {
|
|
"accept": "application/zip", "user-agent": transport.USER_AGENT,
|
|
}
|
|
for url in (
|
|
"http://gitea.example.com/attachments/file",
|
|
"https://other.invalid/attachments/file",
|
|
"https://gitea.example.com:444/attachments/file",
|
|
"https://user@gitea.example.com/attachments/file",
|
|
"https://gitea.example.com/attachments/file#fragment",
|
|
):
|
|
assert handler.redirect_request(request, None, 302, "", {}, url) is None
|
|
|
|
|
|
def test_deadline_stops_a_continuous_read1_trickle():
|
|
clock = [0.0]
|
|
|
|
class Trickle(Response):
|
|
def read1(self, _limit):
|
|
clock[0] += 2
|
|
return b" "
|
|
|
|
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")
|
|
with pytest.raises(transport.TransportError, match="deadline"):
|
|
transport.execute(value, "json", opener_factory=lambda _mode: Opener(Trickle([])),
|
|
monotonic=lambda: clock[0])
|
|
|
|
|
|
def test_cli_errors_are_generic_and_zip_marker_is_compact():
|
|
errors = io.BytesIO()
|
|
stdout = io.BytesIO()
|
|
assert transport.main(["wrong"], stdin=io.BytesIO(b""), stdout=stdout, stderr=errors) == 1
|
|
assert errors.getvalue() == transport._GENERIC_ERROR
|
|
assert b"wrong" not in errors.getvalue()
|
|
|
|
read_fd, write_fd = os.pipe()
|
|
try:
|
|
value = _request(
|
|
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip",
|
|
mode="zip", output_fd=write_fd,
|
|
)
|
|
response = Response([b"zip"])
|
|
payload = json.dumps(value, separators=(",", ":")).encode()
|
|
stdout = io.BytesIO()
|
|
errors = io.BytesIO()
|
|
result = transport.main(["zip"], stdin=io.BytesIO(payload), stdout=stdout,
|
|
stderr=errors, opener_factory=lambda _mode: Opener(response))
|
|
assert result == 0
|
|
assert stdout.getvalue() == b'{"bytes":3}\n'
|
|
assert errors.getvalue() == b""
|
|
os.close(write_fd)
|
|
write_fd = -1
|
|
assert os.read(read_fd, 32) == b"zip"
|
|
finally:
|
|
if write_fd >= 0:
|
|
os.close(write_fd)
|
|
os.close(read_fd)
|