feat: harden gateway with policy engine, secure tools, and governance docs

This commit is contained in:
2026-02-14 16:05:56 +01:00
parent e17d34e6d7
commit 5969892af3
55 changed files with 4711 additions and 1587 deletions

View File

@@ -1,13 +1,16 @@
"""Pytest configuration and fixtures."""
from collections.abc import Generator
from pathlib import Path
from typing import Generator
import pytest
from aegis_gitea_mcp.audit import reset_audit_logger
from aegis_gitea_mcp.auth import reset_validator
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.observability import reset_metrics_registry
from aegis_gitea_mcp.policy import reset_policy_engine
from aegis_gitea_mcp.rate_limit import reset_rate_limiter
@pytest.fixture(autouse=True)
@@ -17,6 +20,9 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
reset_settings()
reset_audit_logger()
reset_validator()
reset_policy_engine()
reset_rate_limiter()
reset_metrics_registry()
# Use temporary directory for audit logs in tests
audit_log_path = tmp_path / "audit.log"
@@ -28,6 +34,9 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
reset_settings()
reset_audit_logger()
reset_validator()
reset_policy_engine()
reset_rate_limiter()
reset_metrics_registry()
@pytest.fixture
@@ -35,6 +44,9 @@ def mock_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set up mock environment variables for testing."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
monkeypatch.setenv("MCP_HOST", "0.0.0.0")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
monkeypatch.setenv("MCP_PORT", "8080")
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")

50
tests/test_audit.py Normal file
View File

@@ -0,0 +1,50 @@
"""Tests for tamper-evident audit logging."""
import json
from pathlib import Path
import pytest
from aegis_gitea_mcp.audit import AuditLogger, validate_audit_log_integrity
def test_audit_log_integrity_valid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Fresh audit log should validate with intact hash chain."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "token-123")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
logger.log_tool_invocation("list_repositories", result_status="pending")
logger.log_tool_invocation("list_repositories", result_status="success")
logger.close()
valid, errors = validate_audit_log_integrity(log_path)
assert valid
assert errors == []
def test_audit_log_integrity_detects_tamper(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Integrity validation should fail when entries are modified."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "token-123")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
log_path = tmp_path / "audit.log"
logger = AuditLogger(log_path=log_path)
logger.log_tool_invocation("list_repositories", result_status="pending")
logger.log_tool_invocation("list_repositories", result_status="success")
logger.close()
lines = log_path.read_text(encoding="utf-8").splitlines()
first_entry = json.loads(lines[0])
first_entry["payload"]["tool_name"] = "tampered"
lines[0] = json.dumps(first_entry)
log_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
valid, errors = validate_audit_log_integrity(log_path)
assert not valid
assert errors

View File

@@ -61,7 +61,7 @@ def test_hash_api_key():
assert hashed == hash_api_key(key) # Deterministic
def test_validator_singleton():
def test_validator_singleton(mock_env_with_key):
"""Test that get_validator returns same instance."""
validator1 = get_validator()
validator2 = get_validator()

134
tests/test_automation.py Normal file
View File

@@ -0,0 +1,134 @@
"""Tests for automation endpoints and controls."""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
def _set_base_env(
monkeypatch: pytest.MonkeyPatch, automation_enabled: bool, policy_path: Path
) -> None:
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
monkeypatch.setenv("AUTOMATION_ENABLED", "true" if automation_enabled else "false")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path))
def test_automation_job_denied_when_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Automation endpoints should deny requests when automation mode is disabled."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("defaults:\n read: allow\n write: deny\n", encoding="utf-8")
_set_base_env(monkeypatch, automation_enabled=False, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
)
assert response.status_code == 403
assert "disabled" in response.json()["detail"]
def test_automation_job_executes_when_enabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Dependency scan job should execute when automation is enabled and policy allows it."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
"""
defaults:
read: allow
write: deny
tools:
allow:
- automation_dependency_hygiene_scan
- automation_webhook_ingest
""".strip() + "\n",
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["result"]["job"] == "dependency_hygiene_scan"
def test_automation_webhook_policy_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Webhook ingestion must respect policy deny rules."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
"""
defaults:
read: allow
write: deny
tools:
deny:
- automation_webhook_ingest
""".strip() + "\n",
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/webhook",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"event_type": "scan.completed", "payload": {"status": "ok"}},
)
assert response.status_code == 403
assert "policy denied" in response.json()["detail"].lower()
def test_auto_issue_creation_denied_without_write_mode(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Auto issue creation job should be denied unless write mode is enabled."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
"""
defaults:
read: allow
write: allow
tools:
allow:
- automation_auto_issue_creation
""".strip() + "\n",
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "auto_issue_creation", "owner": "acme", "repo": "app"},
)
assert response.status_code == 403
assert "write mode is disabled" in response.json()["detail"].lower()

View File

@@ -3,7 +3,7 @@
import pytest
from pydantic import ValidationError
from aegis_gitea_mcp.config import Settings, get_settings, reset_settings
from aegis_gitea_mcp.config import get_settings, reset_settings
def test_settings_from_env(mock_env: None) -> None:
@@ -12,7 +12,7 @@ def test_settings_from_env(mock_env: None) -> None:
assert settings.gitea_base_url == "https://gitea.example.com"
assert settings.gitea_token == "test-token-12345"
assert settings.mcp_host == "0.0.0.0"
assert settings.mcp_host == "127.0.0.1"
assert settings.mcp_port == 8080
assert settings.log_level == "DEBUG"
@@ -21,10 +21,11 @@ def test_settings_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test default values when not specified."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
settings = get_settings()
assert settings.mcp_host == "0.0.0.0"
assert settings.mcp_host == "127.0.0.1"
assert settings.mcp_port == 8080
assert settings.log_level == "INFO"
assert settings.max_file_size_bytes == 1_048_576
@@ -33,7 +34,6 @@ def test_settings_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
def test_settings_validation_missing_required(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Test that missing required fields raise validation errors."""
import os
monkeypatch.delenv("GITEA_URL", raising=False)
monkeypatch.delenv("GITEA_TOKEN", raising=False)
@@ -51,6 +51,7 @@ def test_settings_invalid_log_level(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that invalid log levels are rejected."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("LOG_LEVEL", "INVALID")
reset_settings()
@@ -63,6 +64,7 @@ def test_settings_empty_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that empty tokens are rejected."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", " ")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
reset_settings()
@@ -70,7 +72,7 @@ def test_settings_empty_token(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings()
def test_settings_singleton() -> None:
def test_settings_singleton(mock_env: None) -> None:
"""Test that get_settings returns same instance."""
settings1 = get_settings()
settings2 = get_settings()

View File

@@ -22,13 +22,15 @@ def full_env(monkeypatch):
"""Set up complete test environment."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("MCP_API_KEYS", f"{'a' * 64},{'b' * 64}")
monkeypatch.setenv("MCP_HOST", "0.0.0.0")
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
monkeypatch.setenv("MCP_PORT", "8080")
monkeypatch.setenv("LOG_LEVEL", "INFO")
monkeypatch.setenv("MAX_AUTH_FAILURES", "5")
monkeypatch.setenv("AUTH_FAILURE_WINDOW", "300")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
@pytest.fixture
@@ -153,6 +155,23 @@ def test_all_mcp_tools_discoverable(client):
"get_repository_info",
"get_file_tree",
"get_file_contents",
"search_code",
"list_commits",
"get_commit_diff",
"compare_refs",
"list_issues",
"get_issue",
"list_pull_requests",
"get_pull_request",
"list_labels",
"list_tags",
"list_releases",
"create_issue",
"update_issue",
"create_issue_comment",
"create_pr_comment",
"add_labels",
"assign_issue",
]
tool_names = [tool["name"] for tool in tools]

127
tests/test_policy.py Normal file
View File

@@ -0,0 +1,127 @@
"""Tests for YAML policy engine."""
from pathlib import Path
import pytest
from aegis_gitea_mcp.config import get_settings, reset_settings
from aegis_gitea_mcp.policy import PolicyEngine, PolicyError
def _set_base_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "token-12345")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
def test_default_policy_allows_read_and_denies_write(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Default policy should allow reads and deny writes when write mode is disabled."""
_set_base_env(monkeypatch)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(tmp_path / "does-not-exist.yaml")
read_decision = engine.authorize("list_repositories", is_write=False)
write_decision = engine.authorize("create_issue", is_write=True, repository="owner/repo")
assert read_decision.allowed
assert not write_decision.allowed
def test_policy_global_deny(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Global deny should reject matching tool names."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
"""
defaults:
read: allow
write: deny
tools:
deny:
- list_repositories
""".strip() + "\n",
encoding="utf-8",
)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
decision = engine.authorize("list_repositories", is_write=False)
assert not decision.allowed
assert "denied" in decision.reason
def test_repository_path_restriction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Repository path allow-list should block unknown paths."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
"""
repositories:
acme/app:
tools:
allow:
- get_file_contents
paths:
allow:
- src/*
""".strip() + "\n",
encoding="utf-8",
)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
allowed = engine.authorize(
"get_file_contents",
is_write=False,
repository="acme/app",
target_path="src/main.py",
)
denied = engine.authorize(
"get_file_contents",
is_write=False,
repository="acme/app",
target_path="docs/readme.md",
)
assert allowed.allowed
assert not denied.allowed
def test_invalid_policy_structure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Invalid policy YAML should raise PolicyError."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("repositories: []\n", encoding="utf-8")
reset_settings()
_ = get_settings()
with pytest.raises(PolicyError):
PolicyEngine.from_yaml_file(policy_path)
def test_write_mode_repository_whitelist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Write mode should require repo whitelist and honor configured repository entries."""
_set_base_env(monkeypatch)
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8")
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
allowed = engine.authorize("create_issue", is_write=True, repository="acme/app")
denied = engine.authorize("create_issue", is_write=True, repository="acme/other")
assert allowed.allowed
assert denied.allowed is False

26
tests/test_security.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for secret detection and sanitization helpers."""
from aegis_gitea_mcp.security import detect_secrets, sanitize_data
def test_detect_secrets_api_key_pattern() -> None:
"""Secret detector should identify common token formats."""
findings = detect_secrets("token=sk-test12345678901234567890")
assert findings
def test_sanitize_data_mask_mode() -> None:
"""Mask mode should preserve structure while redacting values."""
payload = {"content": "api_key=AKIA1234567890ABCDEF"}
sanitized = sanitize_data(payload, mode="mask")
assert sanitized["content"] != payload["content"]
assert "AKIA" in sanitized["content"]
def test_sanitize_data_block_mode() -> None:
"""Block mode should replace secret-bearing fields entirely."""
payload = {"nested": ["Bearer eyJhbGciOiJIUzI1NiJ9.abcd.efgh"]}
sanitized = sanitize_data(payload, mode="block")
assert sanitized["nested"][0] == "[REDACTED_SECRET]"

View File

@@ -22,8 +22,10 @@ def mock_env(monkeypatch):
"""Set up test environment."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
@pytest.fixture
@@ -31,8 +33,10 @@ def mock_env_auth_disabled(monkeypatch):
"""Set up test environment with auth disabled."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("MCP_API_KEYS", "")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
@pytest.fixture
@@ -72,6 +76,13 @@ def test_health_endpoint(client):
assert data["status"] == "healthy"
def test_metrics_endpoint(client):
"""Metrics endpoint should be available for observability."""
response = client.get("/metrics")
assert response.status_code == 200
assert "aegis_http_requests_total" in response.text
def test_health_endpoint_no_auth_required(client):
"""Test that health check doesn't require authentication."""
response = client.get("/health")
@@ -169,6 +180,22 @@ def test_call_nonexistent_tool(client):
assert "not found" in data["detail"].lower()
def test_write_tool_denied_by_default_policy(client):
"""Write tools must be denied when write mode is disabled."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={
"tool": "create_issue",
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
},
)
assert response.status_code == 403
data = response.json()
assert "policy denied" in data["detail"].lower()
def test_sse_endpoint_without_auth(client):
"""Test that SSE endpoint requires authentication."""
response = client.get("/mcp/sse")

View File

@@ -0,0 +1,177 @@
"""Tests for expanded read/write MCP tool handlers."""
import pytest
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.gitea_client import GiteaError
from aegis_gitea_mcp.tools.read_tools import (
compare_refs_tool,
get_commit_diff_tool,
get_issue_tool,
get_pull_request_tool,
list_commits_tool,
list_issues_tool,
list_labels_tool,
list_pull_requests_tool,
list_releases_tool,
list_tags_tool,
search_code_tool,
)
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_issue_comment_tool,
create_issue_tool,
create_pr_comment_tool,
update_issue_tool,
)
@pytest.fixture(autouse=True)
def tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide minimal settings environment for response limit helpers."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
class StubGitea:
"""Stubbed Gitea client for tool unit tests."""
async def search_code(self, owner, repo, query, *, ref, page, limit):
return {"hits": [{"path": "src/main.py", "snippet": "match text", "score": 1.0}]}
async def list_commits(self, owner, repo, *, ref, page, limit):
return [{"sha": "abc1234", "commit": {"message": "Fix bug", "author": {"date": "now"}}}]
async def get_commit_diff(self, owner, repo, sha):
return {
"commit": {"message": "Fix bug"},
"files": [{"filename": "a.py", "status": "modified"}],
}
async def compare_refs(self, owner, repo, base, head):
return {
"commits": [{"sha": "abc", "commit": {"message": "Msg"}}],
"files": [{"filename": "a.py", "status": "modified"}],
}
async def list_issues(self, owner, repo, *, state, page, limit, labels=None):
return [{"number": 1, "title": "Issue", "state": "open", "labels": []}]
async def get_issue(self, owner, repo, index):
return {"number": index, "title": "Issue", "body": "Body", "state": "open", "labels": []}
async def list_pull_requests(self, owner, repo, *, state, page, limit):
return [{"number": 1, "title": "PR", "state": "open"}]
async def get_pull_request(self, owner, repo, index):
return {"number": index, "title": "PR", "body": "Body", "state": "open"}
async def list_labels(self, owner, repo, *, page, limit):
return [{"id": 1, "name": "bug", "color": "ff0000", "description": "desc"}]
async def list_tags(self, owner, repo, *, page, limit):
return [{"name": "v1.0.0", "commit": {"sha": "abc"}}]
async def list_releases(self, owner, repo, *, page, limit):
return [{"id": 1, "tag_name": "v1.0.0", "name": "release"}]
async def create_issue(self, owner, repo, *, title, body, labels=None, assignees=None):
return {"number": 1, "title": title, "state": "open"}
async def update_issue(self, owner, repo, index, *, title=None, body=None, state=None):
return {"number": index, "title": title or "Issue", "state": state or "open"}
async def create_issue_comment(self, owner, repo, index, body):
return {"id": 1, "body": body}
async def create_pr_comment(self, owner, repo, index, body):
return {"id": 2, "body": body}
async def add_labels(self, owner, repo, index, labels):
return {"labels": [{"name": label} for label in labels]}
async def assign_issue(self, owner, repo, index, assignees):
return {"assignees": [{"login": user} for user in assignees]}
class ErrorGitea(StubGitea):
"""Stub that raises backend errors for failure-mode coverage."""
async def list_commits(self, owner, repo, *, ref, page, limit):
raise GiteaError("backend failure")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool,args,expected_key",
[
(search_code_tool, {"owner": "acme", "repo": "app", "query": "foo"}, "results"),
(list_commits_tool, {"owner": "acme", "repo": "app"}, "commits"),
(get_commit_diff_tool, {"owner": "acme", "repo": "app", "sha": "abc1234"}, "files"),
(
compare_refs_tool,
{"owner": "acme", "repo": "app", "base": "main", "head": "feature"},
"commits",
),
(list_issues_tool, {"owner": "acme", "repo": "app"}, "issues"),
(get_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1}, "title"),
(list_pull_requests_tool, {"owner": "acme", "repo": "app"}, "pull_requests"),
(get_pull_request_tool, {"owner": "acme", "repo": "app", "pull_number": 1}, "title"),
(list_labels_tool, {"owner": "acme", "repo": "app"}, "labels"),
(list_tags_tool, {"owner": "acme", "repo": "app"}, "tags"),
(list_releases_tool, {"owner": "acme", "repo": "app"}, "releases"),
],
)
async def test_extended_read_tools_success(tool, args, expected_key):
"""Each expanded read tool should return expected top-level keys."""
result = await tool(StubGitea(), args)
assert expected_key in result
@pytest.mark.asyncio
async def test_extended_read_tools_failure_mode() -> None:
"""Expanded read tools should wrap backend failures."""
with pytest.raises(RuntimeError):
await list_commits_tool(ErrorGitea(), {"owner": "acme", "repo": "app"})
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool,args,expected_key",
[
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}, "number"),
(
update_issue_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "title": "Updated"},
"number",
),
(
create_issue_comment_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "body": "comment"},
"id",
),
(
create_pr_comment_tool,
{"owner": "acme", "repo": "app", "pull_number": 1, "body": "comment"},
"id",
),
(
add_labels_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
"labels",
),
(
assign_issue_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]},
"assignees",
),
],
)
async def test_write_tools_success(tool, args, expected_key):
"""Write tools should normalize successful backend responses."""
result = await tool(StubGitea(), args)
assert expected_key in result