feat: add Gitea request executor with result envelope, pagination and redaction

This commit is contained in:
Latte committed 2026-10-14 15:55:56 +02:00
1 parent 041b3ea7ff
commit 9e50859133
8 files changed
+3021

No files matched your search

File diff suppressed because it is too large. Load diff
+200
View File
@@ -0,0 +1,200 @@
"""Shared builders for the executor tests (hand-made operations mirroring the swagger)."""
from __future__ import annotations
from typing import Any
from aegis_gitea_mcp.catalog.model import Operation, Parameter
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.identity import Identity
GITEA_TOKEN = "gitea-service-token-0123456789abcdef"
BEARER_TOKEN = "mcp-bearer-token-0123456789abcdefghijkl"
OAUTH_CLIENT_SECRET = "oauth-client-secret-0123456789abcdef"
USER_TOKEN = "user-oauth-token-0123456789abcdef"
API = "https://gitea.example.com/api/v1"
def make_settings(**overrides: Any) -> Settings:
"""Settings with every secret source populated."""
values: dict[str, Any] = {
"gitea_url": "https://gitea.example.com",
"gitea_token": GITEA_TOKEN,
"mcp_bearer_token": BEARER_TOKEN,
"gitea_oauth_client_secret": OAUTH_CLIENT_SECRET,
}
values.update(overrides)
return Settings(**values)
def admin_identity() -> Identity:
return Identity(login="admin", mode="admin-token", token=GITEA_TOKEN)
def oauth_identity() -> Identity:
return Identity(login="alice", mode="oauth", token=USER_TOKEN)
def param(
name: str,
location: str,
schema: dict[str, Any] | None = None,
*,
required: bool = False,
is_file: bool = False,
) -> Parameter:
return Parameter(
name=name,
arg_name="body" if location == "body" else name,
location=location, # type: ignore[arg-type]
required=required,
schema=schema or {"type": "string"},
is_file=is_file,
)
def make_op(
operation_id: str,
method: str,
path: str,
parameters: list[Parameter],
*,
consumes: tuple[str, ...] = ("application/json",),
produces: tuple[str, ...] = ("application/json",),
paginated: bool = False,
) -> Operation:
return Operation(
tool_name=f"gitea_test_{operation_id.lower()}",
domain="repo",
operation_id=operation_id,
method=method,
path=path,
summary="",
description="",
parameters=tuple(parameters),
input_schema={},
consumes=consumes,
produces=produces,
read_only=method == "GET",
destructive=method == "DELETE",
idempotent=method in {"GET", "PUT", "DELETE"},
paginated=paginated,
)
OWNER = param("owner", "path", required=True)
REPO = param("repo", "path", required=True)
PAGE = param("page", "query", {"type": "integer"})
LIMIT = param("limit", "query", {"type": "integer"})
LIST_BRANCHES = make_op(
"repoListBranches",
"GET",
"/repos/{owner}/{repo}/branches",
[OWNER, REPO, PAGE, LIMIT],
paginated=True,
)
CREATE_ISSUE = make_op(
"issueCreateIssue",
"POST",
"/repos/{owner}/{repo}/issues",
[OWNER, REPO, param("body", "body", {"type": "object"})],
)
GET_RAW_FILE = make_op(
"repoGetRawFile",
"GET",
"/repos/{owner}/{repo}/raw/{filepath}",
[OWNER, REPO, param("filepath", "path", required=True), param("ref", "query")],
produces=("application/octet-stream",),
)
GET_BRANCH = make_op(
"repoGetBranch",
"GET",
"/repos/{owner}/{repo}/branches/{branch}",
[OWNER, REPO, param("branch", "path", required=True)],
)
DELETE_BRANCH = make_op(
"repoDeleteBranch",
"DELETE",
"/repos/{owner}/{repo}/branches/{branch}",
[OWNER, REPO, param("branch", "path", required=True)],
)
UPLOAD_ATTACHMENT = make_op(
"repoCreateReleaseAttachment",
"POST",
"/repos/{owner}/{repo}/releases/{id}/assets",
[
OWNER,
REPO,
param("id", "path", {"type": "integer"}, required=True),
param("name", "query"),
param("attachment", "formData", {"type": "object"}, is_file=True),
],
consumes=("multipart/form-data", "application/octet-stream"),
)
PULL_DIFF = make_op(
"repoDownloadPullDiffOrPatch",
"GET",
"/repos/{owner}/{repo}/pulls/{index}.{diffType}",
[
OWNER,
REPO,
param("index", "path", {"type": "integer"}, required=True),
param("diffType", "path", required=True),
],
produces=("text/plain",),
)
CREATE_USER = make_op(
"adminCreateUser",
"POST",
"/admin/users",
[param("body", "body", {"type": "object"})],
)
PUT_SECRET = make_op(
"updateRepoSecret",
"PUT",
"/repos/{owner}/{repo}/actions/secrets/{secretname}",
[OWNER, REPO, param("secretname", "path", required=True), param("body", "body")],
)
RENDER_RAW = make_op(
"renderMarkdownRaw",
"POST",
"/markdown/raw",
[param("body", "body", {"type": "string"})],
consumes=("text/plain",),
produces=("text/html",),
)
LIST_PULLS = make_op(
"repoListPullRequests",
"GET",
"/repos/{owner}/{repo}/pulls",
[
OWNER,
REPO,
param("labels", "query", {"type": "array", "items": {"type": "integer"}}),
param("state", "query"),
param("draft", "query", {"type": "boolean"}),
param("csvlist", "query", {"type": "array", "collectionFormat": "csv"}),
PAGE,
LIMIT,
],
paginated=True,
)
CREATE_TOKEN = make_op(
"userCreateToken",
"POST",
"/users/{username}/tokens",
[param("username", "path", required=True), param("body", "body", {"type": "object"})],
)
REGISTRATION_TOKEN = make_op(
"repoCreateRunnerRegistrationToken",
"POST",
"/repos/{owner}/{repo}/actions/runners/registration-token",
[OWNER, REPO],
)
GET_VARIABLE_NOBODY = make_op(
"repoListLabels",
"GET",
"/repos/{owner}/{repo}/labels",
[OWNER, REPO],
)
+157
View File
@@ -0,0 +1,157 @@
"""Unit tests for the executor's pure helpers (encoding, pagination, redaction)."""
from __future__ import annotations
import pytest
from aegis_gitea_mcp.gitea.encoding import (
SLASH_PATH_PARAMS,
ArgumentError,
check_segments,
render_path,
render_raw_path,
sensitive_body_values,
validate_sudo,
)
from aegis_gitea_mcp.gitea.pagination import build_pagination, parse_link_header
from aegis_gitea_mcp.gitea.redaction import normalize_secrets, redact_exact, redact_text
from tests.test_executor_helpers import GET_BRANCH, GET_RAW_FILE, PULL_DIFF
# Encoding
def test_slash_parameter_set_is_documented_and_minimal() -> None:
assert {"filepath", "branch", "ref", "basehead", "head", "tag"} == SLASH_PATH_PARAMS
def test_render_path_returns_encoded_and_display_forms() -> None:
encoded, display = render_path(
GET_RAW_FILE, {"owner": "o", "repo": "r", "filepath": "a b/c#"[:-1]}
)
assert encoded == "/repos/o/r/raw/a%20b/c"
assert display == "/repos/o/r/raw/a b/c"
def test_render_path_repeated_use_of_integers() -> None:
encoded, _ = render_path(PULL_DIFF, {"owner": "o", "repo": "r", "index": 3, "diffType": "diff"})
assert encoded == "/repos/o/r/pulls/3.diff"
def test_branch_with_slashes_is_not_quoted_but_other_chars_are() -> None:
encoded, _ = render_path(GET_BRANCH, {"owner": "o", "repo": "r", "branch": "fix/a+b c"})
assert encoded.endswith("/branches/fix/a%2Bb%20c")
@pytest.mark.parametrize("path", ["/user", "/repos/o/r/issues", "/a/b.c/d-e_f"])
def test_raw_path_accepts_plain_paths(path: str) -> None:
assert render_raw_path(path) == (path, path)
def test_raw_path_encodes_non_ascii_but_displays_it_raw() -> None:
assert render_raw_path("/repos/ö/r") == ("/repos/%C3%B6/r", "/repos/ö/r")
@pytest.mark.parametrize("value", ["a", "a b", "ünï", "a.b", "a..b", ".hidden", "-x"])
def test_check_segments_accepts(value: str) -> None:
check_segments(value, "v")
@pytest.mark.parametrize("value", ["", ".", "..", "a/./b", "a/../b", "/a", "a/", "a\\b", "a%b"])
def test_check_segments_rejects(value: str) -> None:
with pytest.raises(ArgumentError):
check_segments(value, "v")
def test_validate_sudo() -> None:
assert validate_sudo(None) is None
assert validate_sudo("Bob_1.x-y") == "Bob_1.x-y"
with pytest.raises(ArgumentError):
validate_sudo("bob@example.com")
def test_sensitive_body_values_variants() -> None:
assert sensitive_body_values({"data": 5}, secret_data=True) == []
assert sensitive_body_values({"data": ""}, secret_data=True) == []
assert sensitive_body_values("text", secret_data=True) == []
assert sensitive_body_values({"data": "x"}, secret_data=False) == []
assert sensitive_body_values({"data": "xy"}, secret_data=True) == ["xy"]
assert sensitive_body_values({"PassWord": "abcd", "n": 1}, secret_data=False) == ["abcd"]
assert sensitive_body_values({"password": "abc"}, secret_data=False) == []
assert sensitive_body_values({"password": 12345}, secret_data=False) == []
# Pagination
def test_parse_link_header() -> None:
header = (
'<https://g/api?page=2&limit=5>; rel="next", <https://g/api?page=9>; rel=last, '
'<https://g/api?page=x&limit=0>; rel="prev first", <https://g/api?page=3>; title="t"'
)
links = parse_link_header(header)
assert links["next"] == {"page": 2, "limit": 5}
assert links["last"] == {"page": 9, "limit": None}
assert links["prev"] == {"page": None, "limit": None}
assert links["first"] == links["prev"]
assert len(links) == 4
assert parse_link_header(None) == {}
assert parse_link_header("garbage") == {}
def test_build_pagination_nothing_to_report() -> None:
assert build_pagination({}, requested_page=None, requested_limit=None, paginated=False) is None
def test_build_pagination_limit_from_links_and_garbage_total() -> None:
result = build_pagination(
{"link": '<https://g/?page=2&limit=7>; rel="last"', "x-total-count": "many"},
requested_page="x",
requested_limit=None,
paginated=True,
)
assert result == {
"page": 1,
"limit": 7,
"total_count": None,
"next_page": None,
"last_page": 2,
}
def test_build_pagination_page_derived_from_next_of_two() -> None:
result = build_pagination(
{"link": '<https://g/?page=1>; rel="next"'},
requested_page=None,
requested_limit=None,
paginated=True,
)
assert result is not None
assert result["page"] == 1
# Redaction
def test_normalize_secrets_orders_longest_first_and_drops_empty() -> None:
assert normalize_secrets(["ab", None, "", "abcd", "ab"]) == ("abcd", "ab")
def test_redact_text_prefers_longer_secret() -> None:
assert redact_text("xx abcd ab", ("abcd", "ab")) == "xx [REDACTED] [REDACTED]"
def test_redact_exact_covers_all_container_types() -> None:
value = {"k-sec": ("sec", ["sec", 1, None, True]), "n": 2.5}
assert redact_exact(value, ("sec",)) == {
"k-[REDACTED]": ["[REDACTED]", ["[REDACTED]", 1, None, True]],
"n": 2.5,
}
assert redact_exact(value, ()) is value