From 825c3c93e2176590bfda2455264f922ed8570cd9 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 16:58:57 +0200 Subject: [PATCH] test: issue, pull request, release, secret, variable and webhook scenarios --- tests/integration/test_scenario_issue_pull.py | 224 +++++++++++++++ .../test_scenario_release_hooks.py | 265 ++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 tests/integration/test_scenario_issue_pull.py create mode 100644 tests/integration/test_scenario_release_hooks.py diff --git a/tests/integration/test_scenario_issue_pull.py b/tests/integration/test_scenario_issue_pull.py new file mode 100644 index 0000000..6414eba --- /dev/null +++ b/tests/integration/test_scenario_issue_pull.py @@ -0,0 +1,224 @@ +"""Scenarios: issues (labels, milestones, time tracking, reactions) and pull requests.""" + +from __future__ import annotations + +import time + +from tests.integration.conftest import McpSession, RegularUser, RepoRef, b64 +from tests.integration.harness import GiteaInstance + + +async def test_issue_lifecycle( + mcp: McpSession, regular_user: RegularUser, fresh_repo: RepoRef +) -> None: + repo = fresh_repo.args + watcher = regular_user.login + + issue = await mcp.ok( + "gitea_issue_create_issue", + status=201, + body={"title": "Broken widget", "body": "It does not widget."}, + **repo, + ) + index = issue.data["number"] + assert issue.data["state"] == "open" + + # Comments. + comment = await mcp.ok( + "gitea_issue_create_comment", status=201, index=index, body={"body": "first"}, **repo + ) + comment_id = comment.data["id"] + edited = await mcp.ok( + "gitea_issue_edit_comment", id=comment_id, body={"body": "first, edited"}, **repo + ) + assert edited.data["body"] == "first, edited" + comments = await mcp.ok("gitea_issue_get_comments", index=index, **repo) + assert [item["body"] for item in comments.data] == ["first, edited"] + + # Labels: create, add, remove. + label = await mcp.ok( + "gitea_issue_create_label", + status=201, + body={"name": "bug", "color": "#d73a4a", "description": "something is broken"}, + **repo, + ) + label_id = label.data["id"] + added = await mcp.ok("gitea_issue_add_label", index=index, body={"labels": [label_id]}, **repo) + assert [item["name"] for item in added.data] == ["bug"] + assert (await mcp.ok("gitea_issue_get_labels", index=index, **repo)).data + await mcp.ok("gitea_issue_remove_label", index=index, id=label_id, **repo) + assert (await mcp.ok("gitea_issue_get_labels", index=index, **repo)).data == [] + await mcp.ok("gitea_issue_replace_labels", index=index, body={"labels": [label_id]}, **repo) + await mcp.ok("gitea_issue_clear_labels", index=index, **repo) + + # Milestone. + milestone = await mcp.ok( + "gitea_issue_create_milestone", status=201, body={"title": "v1"}, **repo + ) + await mcp.ok( + "gitea_issue_edit_issue", index=index, body={"milestone": milestone.data["id"]}, **repo + ) + assert (await mcp.ok("gitea_issue_get_issue", index=index, **repo)).data["milestone"][ + "title" + ] == "v1" + + # Pin and unpin. + await mcp.ok("gitea_issue_pin_issue", index=index, **repo) + pinned = await mcp.ok("gitea_issue_list_pinned_issues", **repo) + assert [item["number"] for item in pinned.data] == [index] + await mcp.ok("gitea_issue_unpin_issue", index=index, **repo) + assert (await mcp.ok("gitea_issue_list_pinned_issues", **repo)).data == [] + + # Subscriptions. + await mcp.ok( + "gitea_issue_add_subscription", status=(200, 201), index=index, user=watcher, **repo + ) + subscribers = await mcp.ok("gitea_issue_subscriptions", index=index, **repo) + assert watcher in [item["login"] for item in subscribers.data] + await mcp.ok("gitea_issue_delete_subscription", index=index, user=watcher, **repo) + assert (await mcp.ok("gitea_issue_subscriptions", index=index, **repo)).data == [] + + # Time tracking and the stopwatch. + await mcp.ok("gitea_issue_add_time", index=index, body={"time": 3600}, **repo) + times = await mcp.ok("gitea_issue_tracked_times", index=index, **repo) + assert [item["time"] for item in times.data] == [3600] + await mcp.ok("gitea_issue_delete_time", index=index, id=times.data[0]["id"], **repo) + assert (await mcp.ok("gitea_issue_tracked_times", index=index, **repo)).data == [] + await mcp.ok("gitea_issue_start_stopwatch", status=201, index=index, **repo) + running = await mcp.ok("gitea_user_get_stopwatches") + assert index in [item["issue_index"] for item in running.data] + await mcp.ok("gitea_issue_stop_stopwatch", status=201, index=index, **repo) + assert (await mcp.ok("gitea_issue_tracked_times", index=index, **repo)).data + + # Reactions. + await mcp.ok( + "gitea_issue_post_issue_reaction", + status=(200, 201), + index=index, + body={"content": "+1"}, + **repo, + ) + reactions = await mcp.ok("gitea_issue_get_issue_reactions", index=index, **repo) + assert [item["content"] for item in reactions.data] == ["+1"] + await mcp.ok("gitea_issue_delete_issue_reaction", index=index, body={"content": "+1"}, **repo) + + # Search finds the issue (indexing is asynchronous, so poll briefly). + deadline = time.monotonic() + 20 + hits: list[int] = [] + while time.monotonic() < deadline and index not in hits: + found = await mcp.ok("gitea_issue_list_issues", q="widget", **repo) + hits = [item["number"] for item in found.data] + if index not in hits: + time.sleep(0.5) + assert index in hits + assert (await mcp.ok("gitea_issue_search_issues", q="Broken widget")).data is not None + + # Close. + closed = await mcp.ok("gitea_issue_edit_issue", index=index, body={"state": "closed"}, **repo) + assert closed.data["state"] == "closed" + assert (await mcp.ok("gitea_issue_get_issue", index=index, **repo)).data["state"] == "closed" + await mcp.ok("gitea_issue_delete_comment", id=comment_id, **repo) + await mcp.ok("gitea_issue_delete_label", id=label_id, **repo) + await mcp.ok("gitea_issue_delete_milestone", id=str(milestone.data["id"]), **repo) + + +async def test_pull_request_review_and_merge( + mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser, fresh_repo: RepoRef +) -> None: + repo = fresh_repo.args + reviewer = regular_user.login + await mcp.ok( + "gitea_repo_add_collaborator", + collaborator=reviewer, + body={"permission": "write"}, + **repo, + ) + try: + # A branch with a change, pushed through the contents API. + await mcp.ok( + "gitea_repo_create_branch", + status=201, + body={"new_branch_name": "feat/greeting", "old_branch_name": "main"}, + **repo, + ) + await mcp.ok( + "gitea_repo_create_file", + status=201, + filepath="greeting.txt", + body={ + "content": b64("hello pull request\n"), + "message": "add greeting", + "branch": "feat/greeting", + }, + **repo, + ) + pull = await mcp.ok( + "gitea_pull_create_pull_request", + status=201, + body={"title": "Add greeting", "base": "main", "head": "feat/greeting"}, + **repo, + ) + index = pull.data["number"] + assert pull.data["state"] == "open" + assert not pull.data["merged"] + + files = await mcp.ok("gitea_pull_get_pull_request_files", index=index, **repo) + assert [item["filename"] for item in files.data] == ["greeting.txt"] + commits = await mcp.ok("gitea_pull_get_pull_request_commits", index=index, **repo) + assert len(commits.data) == 1 + + diff = await mcp.ok( + "gitea_pull_download_pull_diff_or_patch", index=index, diffType="diff", **repo + ) + assert isinstance(diff.data, str) + assert "+hello pull request" in diff.data + + # Requested reviewer, then a pending review with an inline comment (as the + # reviewer, through sudo), submitted as an approval. + await mcp.ok( + "gitea_pull_create_pull_review_requests", + status=201, + index=index, + body={"reviewers": [reviewer]}, + **repo, + ) + pending = await mcp.ok( + "gitea_pull_create_pull_review", + sudo=reviewer, + index=index, + body={ + "body": "looks fine", + "comments": [{"path": "greeting.txt", "new_position": 1, "body": "nice"}], + }, + **repo, + ) + assert pending.identity["sudo"] == reviewer + review_id = pending.data["id"] + assert pending.data["state"] == "PENDING" + submitted = await mcp.ok( + "gitea_pull_submit_pull_review", + sudo=reviewer, + index=index, + id=review_id, + body={"event": "APPROVED", "body": "approved"}, + **repo, + ) + assert submitted.data["state"] == "APPROVED" + reviews = await mcp.ok("gitea_pull_list_pull_reviews", index=index, **repo) + assert "APPROVED" in [item["state"] for item in reviews.data] + comments = await mcp.ok( + "gitea_pull_get_pull_review_comments", index=index, id=review_id, **repo + ) + assert [item["body"] for item in comments.data] == ["nice"] + + # Not merged yet, then merged. + await mcp.fail("gitea_pull_pull_request_is_merged", 404, index=index, **repo) + await mcp.ok("gitea_pull_merge_pull_request", index=index, body={"do": "merge"}, **repo) + await mcp.ok("gitea_pull_pull_request_is_merged", index=index, **repo) + merged = await mcp.ok("gitea_pull_get_pull_request", index=index, **repo) + assert merged.data["merged"] is True + assert merged.data["state"] == "closed" + readme = await mcp.ok("gitea_repo_get_contents", filepath="greeting.txt", **repo) + assert readme.data["path"] == "greeting.txt" + finally: + gitea.api("DELETE", f"/repos/{fresh_repo.owner}/{fresh_repo.name}/collaborators/{reviewer}") diff --git a/tests/integration/test_scenario_release_hooks.py b/tests/integration/test_scenario_release_hooks.py new file mode 100644 index 0000000..49943c7 --- /dev/null +++ b/tests/integration/test_scenario_release_hooks.py @@ -0,0 +1,265 @@ +"""Scenarios: releases and attachments, Action secrets and variables, webhooks.""" + +from __future__ import annotations + +import secrets +from typing import Any + +import pytest + +from tests.integration.conftest import McpSession, RepoRef, b64, drop_org +from tests.integration.harness import GiteaInstance + +HOOK_CONFIG = {"url": "http://127.0.0.1:9/hook", "content_type": "json"} + + +async def test_release_with_attachment( + mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef +) -> None: + repo = fresh_repo.args + release = await mcp.ok( + "gitea_release_create_release", + status=201, + body={ + "tag_name": "v1.0.0", + "target_commitish": "main", + "name": "One point oh", + "body": "first release", + }, + **repo, + ) + release_id = release.data["id"] + assert release.data["tag_name"] == "v1.0.0" + assert (await mcp.ok("gitea_release_get_latest_release", **repo)).data["id"] == release_id + assert (await mcp.ok("gitea_release_get_release_by_tag", tag="v1.0.0", **repo)).data["id"] == ( + release_id + ) + + # Upload an attachment through the file argument (base64), then list and download it. + payload = b"release asset " + secrets.token_bytes(24) + asset = await mcp.ok( + "gitea_release_create_release_attachment", + status=201, + id=release_id, + name="notes.bin", + attachment={ + "filename": "notes.bin", + "content_base64": b64(payload), + "content_type": "application/octet-stream", + }, + **repo, + ) + attachment_id = asset.data["id"] + assert asset.data["name"] == "notes.bin" + assert asset.data["size"] == len(payload) + listed = await mcp.ok("gitea_release_list_release_attachments", id=release_id, **repo) + assert [item["name"] for item in listed.data] == ["notes.bin"] + meta = await mcp.ok( + "gitea_release_get_release_attachment", id=release_id, attachment_id=attachment_id, **repo + ) + download = gitea.request("GET", meta.data["browser_download_url"].removeprefix(gitea.base_url)) + assert download.status_code == 200 + assert download.content == payload + + renamed = await mcp.ok( + "gitea_release_edit_release_attachment", + status=(200, 201), + id=release_id, + attachment_id=attachment_id, + body={"name": "renamed.bin"}, + **repo, + ) + assert renamed.data["name"] == "renamed.bin" + edited = await mcp.ok( + "gitea_release_edit_release", + id=release_id, + body={"name": "Renamed", "draft": False}, + **repo, + ) + assert edited.data["name"] == "Renamed" + assert [r["id"] for r in (await mcp.ok("gitea_release_list_releases", **repo)).data] == [ + release_id + ] + + await mcp.ok( + "gitea_release_delete_release_attachment", + id=release_id, + attachment_id=attachment_id, + **repo, + ) + assert ( + await mcp.ok("gitea_release_list_release_attachments", id=release_id, **repo) + ).data == [] + await mcp.ok("gitea_release_delete_release", id=release_id, **repo) + await mcp.fail("gitea_release_get_release", 404, id=release_id, **repo) + + # A second release is removed through its tag. + await mcp.ok( + "gitea_release_create_release", + status=201, + body={"tag_name": "v1.1.0", "target_commitish": "main"}, + **repo, + ) + await mcp.ok("gitea_release_delete_release_by_tag", tag="v1.1.0", **repo) + + +def _names(result: Any) -> list[str]: + return [item["name"] for item in result.data] + + +async def test_secrets_and_variables_never_leak_values( + mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef +) -> None: + repo = fresh_repo.args + org = gitea.unique("it-secrets") + values = [f"S3cr3t-{secrets.token_hex(12)}" for _ in range(4)] + repo_secret, org_secret, user_secret, probe = values + try: + await mcp.ok("gitea_org_create", status=201, body={"username": org}) + + # Repository, organization and user-level secrets: set, list (names only), delete. + await mcp.ok( + "gitea_actions_update_repo_secret", + secretname="REPO_KEY", + body={"data": repo_secret, "description": "repo key"}, + **repo, + ) + await mcp.ok( + "gitea_actions_update_repo_secret", + secretname="REPO_KEY", + body={"data": repo_secret + "x"}, + **repo, + ) + listed = await mcp.ok("gitea_actions_repo_list_secrets", **repo) + assert _names(listed) == ["REPO_KEY"] + assert set(listed.data[0]) <= {"name", "description", "created_at"} + await mcp.ok( + "gitea_actions_update_org_secret", + org=org, + secretname="ORG_KEY", + body={"data": org_secret}, + ) + org_listed = await mcp.ok("gitea_actions_org_list_secrets", org=org) + assert _names(org_listed) == ["ORG_KEY"] + assert set(org_listed.data[0]) <= {"name", "description", "created_at"} + await mcp.ok( + "gitea_actions_update_user_secret", secretname="USER_KEY", body={"data": user_secret} + ) + + # A failing call carrying a secret must not echo it either. + denied = await mcp.fail( + "gitea_actions_update_repo_secret", + 404, + owner=gitea.admin_login, + repo="does-not-exist-" + secrets.token_hex(3), + secretname="NOPE", + body={"data": probe}, + ) + assert probe not in denied.text + + await mcp.ok("gitea_actions_delete_repo_secret", secretname="REPO_KEY", **repo) + await mcp.ok("gitea_actions_delete_org_secret", org=org, secretname="ORG_KEY") + await mcp.ok("gitea_actions_delete_user_secret", secretname="USER_KEY") + assert (await mcp.ok("gitea_actions_repo_list_secrets", **repo)).data == [] + + # Variables are not secret: create, get, update, list, delete at every level. + scopes: list[tuple[str, dict[str, str]]] = [ + ("repo", repo), + ("org", {"org": org}), + ("user", {}), + ] + for level, scope in scopes: + create = f"gitea_actions_create_{level}_variable" + await mcp.ok( + create, + status=(200, 201, 204), + variablename="GREETING", + body={"value": "one"}, + **scope, + ) + got = await mcp.ok( + f"gitea_actions_get_{level}_variable", variablename="GREETING", **scope + ) + assert got.data["data"] == "one" + await mcp.ok( + f"gitea_actions_update_{level}_variable", + variablename="GREETING", + body={"value": "two"}, + **scope, + ) + changed = await mcp.ok( + f"gitea_actions_get_{level}_variable", variablename="GREETING", **scope + ) + assert changed.data["data"] == "two" + names = await mcp.ok(f"gitea_actions_get_{level}_variables_list", **scope) + assert "GREETING" in _names(names) + await mcp.ok(f"gitea_actions_delete_{level}_variable", variablename="GREETING", **scope) + await mcp.fail( + f"gitea_actions_get_{level}_variable", 404, variablename="GREETING", **scope + ) + finally: + drop_org(gitea, org) + gitea.api("DELETE", "/user/actions/secrets/USER_KEY") + + # No secret value in any tool result, and none in the audit log. + for value in values: + assert all(value not in text for text in mcp.texts) + assert value not in mcp.audit_text() + audited = [entry["tool_name"] for entry in mcp.audit_entries()] + assert "gitea_actions_update_repo_secret" in audited + + +@pytest.mark.parametrize("level", ["repo", "org", "user", "admin"]) +async def test_webhook_lifecycle( + mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef, level: str +) -> None: + org = gitea.unique("it-hooks") + scope: dict[str, str] = {} + prefix = f"gitea_hook_{level}" + try: + if level == "repo": + scope = fresh_repo.args + elif level == "org": + await mcp.ok("gitea_org_create", status=201, body={"username": org}) + scope = {"org": org} + if level == "admin": + create, get, listing, edit, delete = ( + "gitea_admin_create_hook", + "gitea_admin_get_hook", + "gitea_admin_list_hooks", + "gitea_admin_edit_hook", + "gitea_admin_delete_hook", + ) + else: + create, get, listing, edit, delete = ( + f"{prefix}_create_hook", + f"{prefix}_get_hook", + f"{prefix}_list_hooks", + f"{prefix}_edit_hook", + f"{prefix}_delete_hook", + ) + hook = await mcp.ok( + create, + status=201, + body={"type": "gitea", "config": HOOK_CONFIG, "events": ["push"], "active": True}, + **scope, + ) + hook_id = hook.data["id"] + assert hook.data["type"] == "gitea" + # Hooks created through /admin/hooks count as "default" hooks, so list both kinds. + query = {"type": "all"} if level == "admin" else {} + hooks = await mcp.ok(listing, **scope, **query) + assert hook_id in [item["id"] for item in hooks.data] + assert (await mcp.ok(get, id=hook_id, **scope)).data["config"]["url"] == HOOK_CONFIG["url"] + edited = await mcp.ok( + edit, id=hook_id, body={"active": False, "events": ["push", "issues"]}, **scope + ) + assert edited.data["active"] is False + assert "issues" in edited.data["events"] + if level == "repo": + await mcp.ok("gitea_hook_repo_test_hook", id=hook_id, **scope) + await mcp.ok(delete, id=hook_id, **scope) + await mcp.fail(get, 404, id=hook_id, **scope) + finally: + if level == "org": + drop_org(gitea, org)