feat: harden gateway with policy engine, secure tools, and governance docs
This commit is contained in:
@@ -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
50
tests/test_audit.py
Normal 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
|
||||
@@ -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
134
tests/test_automation.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
127
tests/test_policy.py
Normal 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
26
tests/test_security.py
Normal 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]"
|
||||
@@ -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")
|
||||
|
||||
177
tests/test_tools_extended.py
Normal file
177
tests/test_tools_extended.py
Normal 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
|
||||
Reference in New Issue
Block a user