4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
956 lines
33 KiB
Python
956 lines
33 KiB
Python
"""Tests for GitHub MCP validation (connectivity, auth, repo-access probe)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
import integrations.github.mcp as github_mcp_module
|
|
|
|
|
|
def test_run_async_closes_coroutine_when_runner_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _noop() -> None:
|
|
return None
|
|
|
|
def _fail_before_awaiting(_coro: Any) -> None:
|
|
raise RuntimeError("asyncio.run cannot be called from a running event loop")
|
|
|
|
monkeypatch.setattr(github_mcp_module.asyncio, "run", _fail_before_awaiting)
|
|
|
|
coro = _noop()
|
|
with pytest.raises(RuntimeError):
|
|
github_mcp_module._run_async(coro)
|
|
|
|
assert coro.cr_frame is None
|
|
|
|
|
|
def _minimal_toolset_for_validation() -> list[dict[str, Any]]:
|
|
"""Tool names required by validate_github_mcp_config plus list_repositories."""
|
|
|
|
names = (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
"list_repositories",
|
|
)
|
|
return [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in names
|
|
]
|
|
|
|
|
|
def test_validate_github_mcp_config_success_includes_repo_samples(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _minimal_toolset_for_validation()
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "alice"}, "text": ""}
|
|
if name == "list_repositories":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": [
|
|
{"full_name": "org/one", "private": False, "fork": False},
|
|
{"full_name": "org/two", "private": True, "fork": True},
|
|
],
|
|
"text": "",
|
|
}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
"toolsets": ["repos"],
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.authenticated_user == "alice"
|
|
assert result.repo_access_count == 2
|
|
assert result.repo_access_scope_owners == ("org",)
|
|
assert result.repo_access_samples == ("org/one", "org/two")
|
|
assert result.repo_access_probe_tool == "list_repositories"
|
|
assert result.repo_access_probe_limit_applied >= 5
|
|
assert len(result.repo_access_probe_rows) == 2
|
|
assert result.repo_access_probe_rows[0].full_name == "org/one"
|
|
assert result.repo_access_probe_rows[0].private is False
|
|
assert result.repo_access_probe_rows[1].private is True
|
|
assert result.repo_access_probe_rows[1].fork is True
|
|
assert "OK @alice" in result.detail
|
|
report = github_mcp_module.format_github_mcp_validation_cli_report(result)
|
|
assert "Configuration validation: succeeded" in report
|
|
assert "GitHub identity: @alice" in report
|
|
assert "Repositories returned (probe): 2" in report
|
|
assert "Repository access source:" in report
|
|
assert "org" in report
|
|
assert "org/one" in report
|
|
|
|
|
|
def test_is_github_copilot_host_detects_hosted_endpoint() -> None:
|
|
assert github_mcp_module._is_github_copilot_host("https://api.githubcopilot.com/mcp/")
|
|
assert github_mcp_module._is_github_copilot_host("https://api.githubcopilot.com:443/mcp")
|
|
assert not github_mcp_module._is_github_copilot_host("https://mcp.internal.example.com/mcp")
|
|
assert not github_mcp_module._is_github_copilot_host("")
|
|
|
|
|
|
def test_validate_github_mcp_config_credential_less_hosted_is_not_configured(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A stale store entry with empty credentials must short-circuit to
|
|
not_configured without hitting the network or emitting a 401 failure."""
|
|
|
|
def _must_not_connect(_config: Any) -> list[dict[str, Any]]:
|
|
raise AssertionError("network must not be probed for credential-less config")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", _must_not_connect)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config({})
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "not_configured"
|
|
assert "without an auth token" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_custom_url_without_token_still_probes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Self-hosted MCP servers may not require a token, so a custom URL without
|
|
a token must NOT be short-circuited — it should still attempt the probe."""
|
|
|
|
probed: dict[str, bool] = {"called": False}
|
|
|
|
def _fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
probed["called"] = True
|
|
return []
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", _fake_list_tools)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{"url": "https://mcp.internal.example.com/mcp", "mode": "streamable-http"}
|
|
)
|
|
github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert probed["called"] is True
|
|
|
|
|
|
def test_verify_github_reports_credential_less_as_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from integrations.github.verifier import verify_github as _verify_github
|
|
|
|
def _must_not_connect(_config: Any) -> list[dict[str, Any]]:
|
|
raise AssertionError("network must not be probed for credential-less config")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", _must_not_connect)
|
|
|
|
verdict = _verify_github("local store", {})
|
|
|
|
assert verdict["service"] == "github"
|
|
assert verdict["status"] == "missing"
|
|
assert "without an auth token" in verdict["detail"]
|
|
|
|
|
|
def test_validate_github_mcp_config_fails_when_repo_list_returns_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _minimal_toolset_for_validation()
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "bob"}, "text": ""}
|
|
if name == "list_repositories":
|
|
return {"is_error": True, "text": "403 Forbidden", "structured_content": None}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "repository_access"
|
|
assert "bob" in result.detail
|
|
assert "403 Forbidden" in result.detail
|
|
assert "repository access check failed" in result.detail
|
|
fail_report = github_mcp_module.format_github_mcp_validation_cli_report(result)
|
|
assert "Configuration validation: failed" in fail_report
|
|
assert "Failure type:" in fail_report
|
|
|
|
|
|
def test_validate_github_mcp_config_fails_when_no_repo_list_tool(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
)
|
|
]
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "carol"}, "text": ""}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "repository_access"
|
|
assert "carol" in result.detail
|
|
assert "no repository listing or search tool was usable" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_reports_actual_attempts_for_starred_view(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
)
|
|
]
|
|
tools.extend(
|
|
[
|
|
{
|
|
"name": "list_starred_repositories",
|
|
"description": "",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"page": {"type": "integer"}},
|
|
"required": ["page"],
|
|
},
|
|
},
|
|
{
|
|
"name": "search_repositories",
|
|
"description": "",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
]
|
|
)
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "carol"}, "text": ""}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg, repo_view="starred")
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "repository_access"
|
|
assert "tried: list_starred_repositories" in result.detail
|
|
assert "list_repositories" not in result.detail
|
|
assert "list_user_repositories" not in result.detail
|
|
assert "search_repositories" not in result.detail
|
|
|
|
|
|
def test_auto_probe_prefers_user_search_over_starred() -> None:
|
|
"""`auto` must not surface starred repos — prefer the user's own repos via search."""
|
|
tools = [
|
|
{
|
|
"name": "list_starred_repositories",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
},
|
|
{
|
|
"name": "search_repositories",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
]
|
|
|
|
plan = github_mcp_module._plan_repo_access_probe(tools, "octocat", view="auto")
|
|
assert plan is not None
|
|
name, args = plan
|
|
assert name == "search_repositories"
|
|
assert args == {"query": "user:octocat"}
|
|
|
|
attempts = github_mcp_module._repo_probe_attempts(tools, "octocat", view="auto")
|
|
assert "list_starred_repositories" not in attempts
|
|
assert attempts[-1] == "search_repositories"
|
|
|
|
|
|
def test_validate_github_mcp_config_uses_search_repositories_when_no_list_tool(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Hosted MCP exposes search_repositories (query required) but not list_repositories."""
|
|
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
)
|
|
]
|
|
tools.append(
|
|
{
|
|
"name": "search_repositories",
|
|
"description": "",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
)
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(
|
|
_config: Any,
|
|
name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "dana"}, "text": ""}
|
|
if name == "search_repositories":
|
|
assert args == {"query": "user:dana"}
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": {"items": [{"full_name": "dana/a"}]},
|
|
"text": "",
|
|
}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.repo_access_samples == ("dana/a",)
|
|
assert result.repo_access_probe_tool == "search_repositories"
|
|
|
|
|
|
def _hosted_tools_with_search() -> list[dict[str, Any]]:
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
)
|
|
]
|
|
tools.append(
|
|
{
|
|
"name": "search_repositories",
|
|
"description": "",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
)
|
|
return tools
|
|
|
|
|
|
def test_validate_github_mcp_config_falls_back_to_get_me_when_user_search_422(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Org-centric users often get 422 on ``user:<login>`` even with valid org access."""
|
|
|
|
tools = _hosted_tools_with_search()
|
|
search_422 = (
|
|
"422 Validation Failed "
|
|
"[{Resource:Search Field:q Code:invalid Message:The listed users and repositories "
|
|
"cannot be searched either because the resources do not exist or you do not have "
|
|
"permission to view them.}]"
|
|
)
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(
|
|
_config: Any,
|
|
name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": {
|
|
"login": "larsspinetta12",
|
|
"details": {"public_repos": 0, "total_private_repos": 0},
|
|
},
|
|
"text": "",
|
|
}
|
|
if name == "search_repositories":
|
|
assert args == {"query": "user:larsspinetta12"}
|
|
return {"is_error": True, "text": search_422, "structured_content": None}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.authenticated_user == "larsspinetta12"
|
|
assert result.repo_access_count == 0
|
|
assert "get_me profile" in result.detail
|
|
assert "search_repositories" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_tries_org_search_after_user_search_422(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _hosted_tools_with_search()
|
|
search_422 = "422 Validation Failed"
|
|
calls: list[tuple[str, dict[str, Any] | None]] = []
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(
|
|
_config: Any,
|
|
name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
calls.append((name, args))
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "dev1"}, "text": ""}
|
|
if name == "search_repositories":
|
|
query = str((args or {}).get("query") or "")
|
|
if query == "user:dev1":
|
|
return {"is_error": True, "text": search_422, "structured_content": None}
|
|
if query == "org:Tracer-Cloud":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": {
|
|
"items": [{"full_name": "Tracer-Cloud/opensre", "private": True}]
|
|
},
|
|
"text": "",
|
|
}
|
|
raise AssertionError(f"unexpected search query {query!r}")
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
monkeypatch.setenv("OPENSRE_GITHUB_MCP_VERIFY_ORGS", "Tracer-Cloud")
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.repo_access_samples == ("Tracer-Cloud/opensre",)
|
|
assert result.repo_access_probe_tool == "search_repositories"
|
|
assert calls.count(("search_repositories", {"query": "user:dev1"})) == 1
|
|
assert calls.count(("search_repositories", {"query": "org:Tracer-Cloud"})) == 1
|
|
|
|
|
|
def test_validate_github_mcp_config_auth_only_when_search_fails_without_profile_counts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _hosted_tools_with_search()
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(
|
|
_config: Any,
|
|
name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "dev2"}, "text": ""}
|
|
if name == "search_repositories":
|
|
return {"is_error": True, "text": "422 Validation Failed", "structured_content": None}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.authenticated_user == "dev2"
|
|
assert result.repo_access_count is None
|
|
assert "authenticated; repo probes inconclusive" in result.detail
|
|
|
|
|
|
def test_is_recoverable_repo_probe_error_only_matches_search_validation_failures() -> None:
|
|
assert github_mcp_module._is_recoverable_repo_probe_error(
|
|
"search_repositories",
|
|
{"query": "user:octocat"},
|
|
"422 Validation Failed",
|
|
)
|
|
assert github_mcp_module._is_recoverable_repo_probe_error(
|
|
"search_repositories",
|
|
{"query": "org:Tracer-Cloud"},
|
|
"The listed users and repositories cannot be searched",
|
|
)
|
|
assert not github_mcp_module._is_recoverable_repo_probe_error(
|
|
"search_repositories",
|
|
{"query": "user:octocat"},
|
|
"403 Forbidden",
|
|
)
|
|
assert not github_mcp_module._is_recoverable_repo_probe_error(
|
|
"list_repositories",
|
|
{},
|
|
"403 Forbidden",
|
|
)
|
|
|
|
|
|
def test_validate_github_mcp_config_fails_when_user_search_returns_403(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _hosted_tools_with_search()
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(
|
|
_config: Any,
|
|
name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {"is_error": False, "structured_content": {"login": "dev3"}, "text": ""}
|
|
if name == "search_repositories":
|
|
assert args == {"query": "user:dev3"}
|
|
return {"is_error": True, "text": "403 Forbidden", "structured_content": None}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "repository_access"
|
|
assert "403 Forbidden" in result.detail
|
|
assert "repository access check failed" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_succeeds_from_get_me_profile_without_list_tools(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_me",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
)
|
|
]
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": {
|
|
"login": "erin",
|
|
"details": {"public_repos": 2, "total_private_repos": 3},
|
|
},
|
|
"text": "",
|
|
}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.repo_access_count == 5
|
|
assert result.repo_access_samples == ()
|
|
assert "get_me profile" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_fails_when_get_me_tool_is_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = [
|
|
{
|
|
"name": n,
|
|
"description": "",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
for n in (
|
|
"get_file_contents",
|
|
"get_repository_tree",
|
|
"list_commits",
|
|
"search_code",
|
|
"list_repositories",
|
|
)
|
|
]
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr(
|
|
"integrations.github.mcp.call_github_mcp_tool",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("get_me should not run")),
|
|
)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is False
|
|
assert result.failure_category == "insufficient_tools"
|
|
assert "required identity tool 'get_me'" in result.detail
|
|
|
|
|
|
def test_validate_github_mcp_config_handles_truthy_non_dict_get_me_structured_content(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
tools = _minimal_toolset_for_validation()
|
|
|
|
def fake_list_tools(_config: Any) -> list[dict[str, Any]]:
|
|
return tools
|
|
|
|
def fake_call(_config: Any, name: str, _args: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if name == "get_me":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": [{"login": "alice"}],
|
|
"text": '{"login": "alice"}',
|
|
}
|
|
if name == "list_repositories":
|
|
return {
|
|
"is_error": False,
|
|
"structured_content": [{"full_name": "org/one", "private": False, "fork": False}],
|
|
"text": "",
|
|
}
|
|
raise AssertionError(f"unexpected tool {name}")
|
|
|
|
monkeypatch.setattr("integrations.github.mcp.list_github_mcp_tools", fake_list_tools)
|
|
monkeypatch.setattr("integrations.github.mcp.call_github_mcp_tool", fake_call)
|
|
|
|
cfg = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"url": "https://api.githubcopilot.com/mcp/",
|
|
"mode": "streamable-http",
|
|
"auth_token": "ghp_test",
|
|
}
|
|
)
|
|
result = github_mcp_module.validate_github_mcp_config(cfg)
|
|
|
|
assert result.ok is True
|
|
assert result.authenticated_user == "alice"
|
|
assert result.repo_access_samples == ("org/one",)
|
|
|
|
|
|
def test_repo_probe_capture_limit_respects_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT", raising=False)
|
|
assert (
|
|
github_mcp_module._repo_probe_capture_limit() == github_mcp_module._DEFAULT_REPO_PROBE_LIMIT
|
|
)
|
|
monkeypatch.setenv("OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT", "120")
|
|
assert github_mcp_module._repo_probe_capture_limit() == 120
|
|
monkeypatch.setenv("OPENSRE_GITHUB_MCP_REPO_PROBE_LIMIT", "9999")
|
|
assert github_mcp_module._repo_probe_capture_limit() == 500
|
|
|
|
|
|
def test_connectivity_failure_detail_unwraps_taskgroup_exception_group() -> None:
|
|
"""TaskGroup often wraps the real error; users should see the inner exception."""
|
|
inner = ConnectionError("Connection refused")
|
|
group = ExceptionGroup("unhandled errors in a TaskGroup (1 sub-exception)", [inner])
|
|
msg = github_mcp_module._connectivity_failure_detail(group)
|
|
assert "ConnectionError" in msg
|
|
assert "Connection refused" in msg
|
|
assert "Check: outbound HTTPS" in msg
|
|
|
|
|
|
def test_format_github_mcp_validation_cli_report_auth_failure() -> None:
|
|
r = github_mcp_module.GitHubMCPValidationResult(
|
|
ok=False,
|
|
detail="token rejected",
|
|
failure_category="authentication",
|
|
)
|
|
text = github_mcp_module.format_github_mcp_validation_cli_report(r)
|
|
assert "Configuration validation: failed" in text
|
|
assert "authentication" in text.lower()
|
|
assert "token rejected" in text
|
|
|
|
|
|
def test_print_github_mcp_validation_report_success_and_failure() -> None:
|
|
ok_console = Console(record=True, width=88, highlight=False)
|
|
ok_result = github_mcp_module.GitHubMCPValidationResult(
|
|
ok=True,
|
|
detail="OK @alice; repos=2; owners=org; examples=org/a,org/b; mcp_tools=9",
|
|
authenticated_user="alice",
|
|
repo_access_count=2,
|
|
repo_access_scope_owners=("org",),
|
|
repo_access_samples=("org/a", "org/b"),
|
|
repo_access_probe_tool="list_starred_repositories",
|
|
repo_access_probe_rows=(
|
|
github_mcp_module.GitHubMCPRepoProbeRow("org/a", False, False),
|
|
github_mcp_module.GitHubMCPRepoProbeRow("org/b", True, False),
|
|
),
|
|
repo_access_probe_limit_applied=50,
|
|
)
|
|
github_mcp_module.print_github_mcp_validation_report(
|
|
ok_result, console=ok_console, detail_level="full"
|
|
)
|
|
ok_text = ok_console.export_text()
|
|
assert "Configuration validation: succeeded" in ok_text
|
|
assert "alice" in ok_text
|
|
assert "Starred repositories" in ok_text
|
|
assert "public" in ok_text
|
|
assert "private" in ok_text
|
|
|
|
fail_console = Console(record=True, width=88, highlight=False)
|
|
fail_result = github_mcp_module.GitHubMCPValidationResult(
|
|
ok=False,
|
|
detail="connection reset",
|
|
failure_category="connectivity",
|
|
)
|
|
github_mcp_module.print_github_mcp_validation_report(
|
|
fail_result, console=fail_console, detail_level="standard"
|
|
)
|
|
fail_text = fail_console.export_text()
|
|
assert "validation failed" in fail_text.lower()
|
|
assert "connection reset" in fail_text
|
|
|
|
|
|
def test_github_mcp_is_usably_configured_requires_token_for_hosted_copilot() -> None:
|
|
config = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
"auth_token": "",
|
|
}
|
|
)
|
|
assert github_mcp_module.github_mcp_is_usably_configured(config) is False
|
|
|
|
|
|
def test_github_mcp_is_usably_configured_accepts_hosted_copilot_with_token() -> None:
|
|
config = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
"auth_token": "gho_test",
|
|
}
|
|
)
|
|
assert github_mcp_module.github_mcp_is_usably_configured(config) is True
|
|
|
|
|
|
def test_build_github_mcp_config_strips_persisted_username_metadata() -> None:
|
|
config = github_mcp_module.build_github_mcp_config(
|
|
{
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
"auth_token": "gho_test",
|
|
"username": "octocat",
|
|
}
|
|
)
|
|
assert config.auth_token == "gho_test"
|
|
assert "username" not in config.model_fields_set
|
|
|
|
|
|
def test_github_integration_is_configured_true_when_store_has_token_and_username(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"integrations.store.get_integration",
|
|
lambda service: (
|
|
{
|
|
"credentials": {
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
"auth_token": "gho_test",
|
|
"username": "octocat",
|
|
}
|
|
}
|
|
if service == "github"
|
|
else None
|
|
),
|
|
)
|
|
monkeypatch.setattr(github_mcp_module, "github_mcp_config_from_env", lambda: None)
|
|
|
|
assert github_mcp_module.github_integration_is_configured() is True
|
|
|
|
|
|
def test_github_integration_is_configured_ignores_stale_store_record(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"integrations.store.get_integration",
|
|
lambda service: (
|
|
{
|
|
"credentials": {
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
}
|
|
}
|
|
if service == "github"
|
|
else None
|
|
),
|
|
)
|
|
monkeypatch.setattr(github_mcp_module, "github_mcp_config_from_env", lambda: None)
|
|
|
|
assert github_mcp_module.github_integration_is_configured() is False
|
|
|
|
|
|
def test_github_integration_is_configured_true_when_store_has_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"integrations.store.get_integration",
|
|
lambda service: (
|
|
{
|
|
"credentials": {
|
|
"mode": "streamable-http",
|
|
"url": github_mcp_module.DEFAULT_GITHUB_MCP_URL,
|
|
"auth_token": "gho_test",
|
|
}
|
|
}
|
|
if service == "github"
|
|
else None
|
|
),
|
|
)
|
|
monkeypatch.setattr(github_mcp_module, "github_mcp_config_from_env", lambda: None)
|
|
|
|
assert github_mcp_module.github_integration_is_configured() is True
|