blob: 17099682a210c2ed6eafa05f165e3c445c194519 [file] [log] [blame] [edit]
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import os
import pytest
from unittest.mock import patch, AsyncMock
from gerrit_mcp_server import main
# --- Fixtures ---
@pytest.fixture(autouse=True)
def mock_env():
"""Sets up the environment variables for all tests in this module."""
with patch.dict(os.environ, {"GERRIT_BASE_URL": "https://fuchsia-review.googlesource.com"}):
yield
@pytest.fixture
def mock_run_curl():
"""Provides a mocked run_curl."""
with patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) as m:
yield m
@pytest.fixture
def mock_exec():
"""Provides a mocked asyncio.create_subprocess_exec."""
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as m:
yield m
@pytest.fixture
def mock_load_config():
"""Provides a mocked load_gerrit_config."""
with patch("gerrit_mcp_server.main.load_gerrit_config") as m:
yield m
# --- Tests ---
@pytest.mark.asyncio
async def test_query_changes(mock_run_curl):
"""Tests querying changes from Gerrit."""
mock_run_curl.return_value = json.dumps([
{
"_number": 1,
"subject": "Test Change 1",
"work_in_progress": False,
"updated": "2025-07-02T12:00:00Z",
},
{
"_number": 2,
"subject": "Test Change 2",
"work_in_progress": True,
"updated": "2025-07-01T10:00:00Z",
},
])
result = await main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:open",
)
assert "Found 2 changes" in result[0]["text"]
assert "1: Test Change 1" in result[0]["text"]
assert "2: [WIP] Test Change 2" in result[0]["text"]
@pytest.mark.asyncio
async def test_query_changes_no_results(mock_run_curl):
"""Tests querying changes when no results are returned."""
mock_run_curl.return_value = json.dumps([])
result = await main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:open",
)
assert "No changes found" in result[0]["text"]
@pytest.mark.asyncio
async def test_get_change_details(mock_run_curl):
"""Tests retrieving details for a specific change."""
mock_run_curl.return_value = json.dumps({
"_number": 123,
"subject": "Test Subject",
"owner": {"email": "owner@example.com"},
"status": "NEW",
"reviewers": {
"REVIEWER": [{"email": "reviewer@example.com", "_account_id": 1}]
},
"labels": {"Code-Review": {"all": [{"value": 1, "_account_id": 1}]}},
"messages": [
{"_revision_number": 1, "message": "First message"},
{"_revision_number": 2, "message": "Second message"},
],
})
result = await main.get_change_details(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
text = result[0]["text"]
assert "Summary for CL 123" in text
assert "Subject: Test Subject" in text
assert "owner@example.com" in text
assert "reviewer@example.com (Code-Review: +1)" in text
assert "- (Patch Set 2) [No date] (Gerrit): Second message" in text
@pytest.mark.asyncio
async def test_get_change_details_missing_fields(mock_run_curl):
"""Tests retrieving change details when optional fields are missing."""
mock_run_curl.return_value = json.dumps({
"_number": 123,
"subject": "Test Subject",
"owner": {"email": "owner@example.com"},
"status": "NEW",
})
result = await main.get_change_details(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
text = result[0]["text"]
assert "Summary for CL 123" in text
assert "Reviewers:" not in text
assert "Recent Messages:" not in text
@pytest.mark.asyncio
async def test_list_change_files(mock_run_curl):
"""Tests listing files in a change."""
mock_run_curl.side_effect = [
json.dumps({
"/COMMIT_MSG": {},
"file1.txt": {"status": "ADDED", "lines_inserted": 10, "lines_deleted": 0},
"file2.txt": {"status": "MODIFIED", "lines_inserted": 5, "lines_deleted": 2},
}),
json.dumps({"current_revision_number": 3}),
]
result = await main.list_change_files(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
text = result[0]["text"]
assert "Files in CL 123 (Patch Set 3)" in text
assert "[A] file1.txt (+10, -0)" in text
assert "[M] file2.txt (+5, -2)" in text
assert "/COMMIT_MSG" not in text
@pytest.mark.asyncio
async def test_list_change_files_no_files(mock_run_curl):
"""Tests listing files when only COMMIT_MSG is present."""
mock_run_curl.side_effect = [
json.dumps({"/COMMIT_MSG": {}}),
json.dumps({"current_revision_number": 1}),
]
result = await main.list_change_files(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
assert "Files in CL 123 (Patch Set 1)" in result[0]["text"]
assert "[" not in result[0]["text"]
@pytest.mark.asyncio
async def test_get_file_diff(mock_run_curl):
"""Tests retrieving the diff of a file."""
diff_text = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-hello\n+world"
import base64
encoded_diff = base64.b64encode(diff_text.encode("utf-8")).decode("utf-8")
mock_run_curl.return_value = encoded_diff
result = await main.get_file_diff(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
file_path="file.txt",
)
assert result[0]["text"] == diff_text
@pytest.mark.asyncio
async def test_list_change_comments(mock_run_curl):
"""Tests listing comments on a change."""
mock_run_curl.return_value = json.dumps({
"file1.txt": [
{
"line": 10,
"author": {"name": "user1@example.com"},
"message": "Comment 1",
"unresolved": True,
"updated": "2025-07-15T11:00:00Z",
},
{
"line": 12,
"author": {"name": "user2@example.com"},
"message": "Comment 2",
"unresolved": False,
"updated": "2025-07-15T11:05:00Z",
},
],
"file2.txt": [
{
"line": 5,
"author": {"name": "user1@example.com"},
"message": "Comment 3",
"unresolved": True,
"updated": "2025-07-15T11:10:00Z",
},
],
})
result = await main.list_change_comments(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
text = result[0]["text"]
assert "Comments for CL 123" in text
assert "File: file1.txt" in text
assert "L10: [user1@example.com] (2025-07-15T11:00:00Z) - UNRESOLVED" in text
assert "Comment 1" in text
assert "L12: [user2@example.com] (2025-07-15T11:05:00Z) - RESOLVED" in text
assert "Comment 2" in text
assert "File: file2.txt" in text
assert "L5: [user1@example.com] (2025-07-15T11:10:00Z) - UNRESOLVED" in text
assert "Comment 3" in text
@pytest.mark.asyncio
async def test_list_change_comments_no_unresolved(mock_run_curl):
"""Tests listing comments when all are resolved."""
mock_run_curl.return_value = json.dumps({
"file1.txt": [
{
"line": 12,
"author": {"name": "user2@example.com"},
"message": "Comment 2",
"unresolved": False,
"updated": "2025-07-15T11:05:00Z",
},
]
})
result = await main.list_change_comments(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
text = result[0]["text"]
assert "Comments for CL 123" in text
assert "L12: [user2@example.com] (2025-07-15T11:05:00Z) - RESOLVED" in text
@pytest.mark.asyncio
async def test_list_change_comments_json_decode_error(mock_run_curl):
"""Tests handling of invalid JSON response when listing comments."""
mock_run_curl.return_value = "this is not json"
result = await main.list_change_comments(
gerrit_base_url="https://fuchsia-review.googlesource.com", change_id="123"
)
assert "Failed to parse JSON" in result[0]["text"]
@pytest.mark.asyncio
async def test_add_reviewer(mock_run_curl):
"""Tests adding a reviewer to a change."""
mock_run_curl.return_value = json.dumps({}) # Empty object for success
result = await main.add_reviewer(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
reviewer="reviewer@example.com",
)
assert "Successfully added reviewer@example.com as a REVIEWER to CL 123" in result[0]["text"]
@pytest.mark.asyncio
async def test_add_reviewer_failure(mock_run_curl):
"""Tests handling of failure when adding a reviewer."""
mock_run_curl.return_value = '{"error": "Reviewer not found"}'
result = await main.add_reviewer(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
reviewer="nonexistent@example.com",
)
assert "Failed to add" in result[0]["text"]
assert "Reviewer not found" in result[0]["text"]
@pytest.mark.asyncio
async def test_get_most_recent_cl(mock_run_curl):
"""Tests retrieving the most recent CL for a user."""
mock_run_curl.return_value = json.dumps([
{
"_number": 456,
"subject": "Most Recent",
"work_in_progress": False,
"updated": "2025-07-02T13:00:00Z",
},
])
result = await main.get_most_recent_cl(
gerrit_base_url="https://fuchsia-review.googlesource.com",
user="owner@example.com",
)
assert "Most recent CL for owner@example.com" in result[0]["text"]
assert "456: Most Recent" in result[0]["text"]
@pytest.mark.asyncio
async def test_add_reviewer_invalid_state(mock_run_curl):
"""Tests that adding a reviewer with an invalid state fails locally."""
result = await main.add_reviewer(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
reviewer="reviewer@example.com",
state="INVALID_STATE",
)
assert "Failed to add" in result[0]["text"]
assert "Invalid state" in result[0]["text"]
mock_run_curl.assert_not_called()
@pytest.mark.asyncio
async def test_get_most_recent_cl_no_results(mock_run_curl):
"""Tests retrieving the most recent CL when none exist."""
mock_run_curl.return_value = json.dumps([])
result = await main.get_most_recent_cl(
gerrit_base_url="https://fuchsia-review.googlesource.com",
user="owner@example.com",
)
assert "No changes found for user" in result[0]["text"]
@pytest.mark.asyncio
async def test_gerrit_base_url_override(mock_run_curl):
"""Tests that the environment variable overrides the default base URL."""
mock_run_curl.return_value = json.dumps([])
# We need to override the fixture's env var for this specific test
with patch.dict(os.environ, {"GERRIT_BASE_URL": "https://another-gerrit.com"}):
await main.query_changes(query="status:open")
mock_run_curl.assert_called_once()
assert "https://another-gerrit.com/changes" in mock_run_curl.call_args[0][0][0]
@pytest.mark.asyncio
async def test_run_curl_auth_error(mock_exec, mock_load_config):
"""Tests handling of authentication errors from curl."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"name": "Corporate",
"external_url": "https://gerrit.private.corp.corporation.com/",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.return_value = (
b"",
b"bad request: no valid session id provided",
)
mock_exec.return_value.returncode = 1
with pytest.raises(Exception, match="curl command failed with exit code 1"):
await main.run_curl(
["https://gerrit.private.corp.corporation.com/changes/123"],
"https://gerrit.private.corp.corporation.com",
)
@pytest.mark.asyncio
async def test_run_curl_generic_error(mock_exec, mock_load_config):
"""Tests handling of generic curl errors."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"name": "Fake",
"external_url": "https://fakegerrit.com/",
"authentication": {
"type": "git_cookies",
"gitcookies_path": "~/.gitcookies",
},
}
]
}
mock_exec.return_value.communicate.return_value = (
b"",
b"curl: (6) Could not resolve host: fakegerrit.com",
)
mock_exec.return_value.returncode = 6
with pytest.raises(Exception, match="curl command failed with exit code 6"):
await main.run_curl(["https://fakegerrit.com"], "https://fakegerrit.com")
@pytest.mark.asyncio
async def test_tool_functions_with_invalid_change_id(mock_run_curl):
"""Tests that tool functions handle invalid change IDs gracefully."""
mock_run_curl.side_effect = Exception(
"curl command failed with exit code 1.\nSTDERR:\nNot Found"
)
with pytest.raises(Exception, match="Not Found"):
await main.get_change_details(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="invalid",
)
with pytest.raises(Exception, match="Not Found"):
await main.list_change_files(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="invalid",
)
with pytest.raises(Exception, match="Not Found"):
await main.get_file_diff(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="invalid",
file_path="file.txt",
)
with pytest.raises(Exception, match="Not Found"):
await main.list_change_comments(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="invalid",
)
with pytest.raises(Exception, match="Not Found"):
await main.add_reviewer(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="invalid",
reviewer="reviewer@example.com",
)
@pytest.mark.asyncio
async def test_tool_functions_with_malformed_json(mock_run_curl):
"""Tests that tool functions handle malformed JSON responses."""
mock_run_curl.return_value = "this is not json"
result = await main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:open",
)
assert "Failed to parse JSON" in result[0]["text"]
@pytest.mark.asyncio
async def test_tool_functions_with_unexpected_json(mock_run_curl):
"""Tests that tool functions handle unexpected JSON structures."""
mock_run_curl.return_value = json.dumps(
{"unexpected_field": "unexpected_value"}
)
with pytest.raises(KeyError):
await main.get_change_details(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
)
@pytest.mark.asyncio
async def test_concurrent_requests(mock_run_curl):
"""Tests that multiple requests can be handled concurrently."""
mock_run_curl.return_value = json.dumps([])
tasks = [
main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:open",
),
main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:merged",
),
]
results = await asyncio.gather(*tasks)
assert len(results) == 2
assert mock_run_curl.call_count == 2
@pytest.mark.asyncio
async def test_command_injection(mock_exec):
"""Tests that the server is not vulnerable to command injection."""
mock_exec.return_value.communicate.return_value = (b"[]", b"")
mock_exec.return_value.returncode = 0
await main.query_changes(
gerrit_base_url="https://fuchsia-review.googlesource.com",
query="status:open; rm -rf /",
)
# Check that the malicious command was not executed
command_list = mock_exec.call_args[0][0]
command_str = " ".join(command_list)
assert ";" not in command_str
assert "rm" not in command_str
@pytest.mark.asyncio
async def test_post_review_comment_with_labels(mock_run_curl):
"""Tests posting a review comment with labels."""
mock_run_curl.return_value = ')]}\'\\n{"done": true}'
result = await main.post_review_comment(
gerrit_base_url="https://fuchsia-review.googlesource.com",
change_id="123",
file_path="/COMMIT_MSG",
line_number=1,
message="Setting Verified to +1",
labels={"Verified": 1}
)
assert "Successfully posted comment" in result[0]["text"]
# Verify the JSON payload sent to Gerrit
expected_payload = {
"comments": {
"/COMMIT_MSG": [{
"line": 1,
"message": "Setting Verified to +1",
"unresolved": True
}]
},
"labels": {"Verified": 1}
}
# The payload is passed as the argument after '--data'
curl_args = mock_run_curl.call_args[0][0]
data_index = curl_args.index("--data")
actual_payload_str = curl_args[data_index + 1]
actual_payload = json.loads(actual_payload_str)
assert actual_payload == expected_payload