Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

222 lines
8.8 KiB
Python

"""Unit tests for the PostHog MCP integration."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from integrations.catalog import classify_integrations as _classify_integrations
from integrations.posthog_mcp import (
DEFAULT_POSTHOG_MCP_URL,
PostHogMCPConfig,
build_posthog_mcp_config,
describe_posthog_mcp_error,
posthog_mcp_config_from_env,
posthog_mcp_runtime_unavailable_reason,
validate_posthog_mcp_config,
)
# ---------------------------------------------------------------------------
# PostHogMCPConfig
# ---------------------------------------------------------------------------
class TestPostHogMCPConfig:
def test_defaults_to_hosted_streamable_http(self) -> None:
config = PostHogMCPConfig()
assert config.mode == "streamable-http"
assert config.url == DEFAULT_POSTHOG_MCP_URL
assert config.is_configured is True
assert config.read_only is True
def test_streamable_http_requires_url(self) -> None:
with pytest.raises(ValidationError, match="requires a non-empty url"):
PostHogMCPConfig(mode="streamable-http", url="")
def test_stdio_requires_command(self) -> None:
with pytest.raises(ValidationError, match="requires a non-empty command"):
PostHogMCPConfig(mode="stdio", command="", url="")
def test_url_trailing_slash_stripped(self) -> None:
config = PostHogMCPConfig(url="https://mcp.posthog.com/mcp/")
assert config.url == "https://mcp.posthog.com/mcp"
def test_mode_mcp_alias_maps_to_streamable_http(self) -> None:
config = PostHogMCPConfig(mode="mcp")
assert config.mode == "streamable-http"
@pytest.mark.parametrize("alias", ["default", "http", "https", "streamable_http"])
def test_mode_generic_aliases_map_to_streamable_http(self, alias: str) -> None:
config = PostHogMCPConfig(mode=alias)
assert config.mode == "streamable-http"
def test_bearer_prefix_stripped_from_token(self) -> None:
config = PostHogMCPConfig(auth_token="Bearer phx_secret")
assert config.auth_token == "phx_secret"
def test_features_normalized_from_string(self) -> None:
config = PostHogMCPConfig(features="flags, error-tracking")
assert config.features == ("flags", "error-tracking")
def test_request_headers_include_auth_and_scoping(self) -> None:
config = PostHogMCPConfig(
auth_token="phx_secret",
organization_id="org-1",
project_id="proj-9",
)
headers = config.request_headers
assert headers["Authorization"] == "Bearer phx_secret"
assert headers["x-posthog-organization-id"] == "org-1"
assert headers["x-posthog-project-id"] == "proj-9"
assert headers["x-posthog-read-only"] == "true"
def test_read_only_header_omitted_when_disabled(self) -> None:
config = PostHogMCPConfig(auth_token="phx_secret", read_only=False)
assert "x-posthog-read-only" not in config.request_headers
def test_session_url_merges_features_query(self) -> None:
config = PostHogMCPConfig(features=["flags", "errors"])
assert config.session_url == f"{DEFAULT_POSTHOG_MCP_URL}?features=flags%2Cerrors"
def test_session_url_unchanged_without_features(self) -> None:
config = PostHogMCPConfig()
assert config.session_url == DEFAULT_POSTHOG_MCP_URL
# ---------------------------------------------------------------------------
# Env loading / runtime gating
# ---------------------------------------------------------------------------
class TestEnvLoading:
def test_returns_none_without_token_for_hosted(self, monkeypatch: pytest.MonkeyPatch) -> None:
for var in (
"POSTHOG_MCP_MODE",
"POSTHOG_MCP_URL",
"POSTHOG_MCP_COMMAND",
"POSTHOG_MCP_AUTH_TOKEN",
"POSTHOG_MCP_ARGS",
"POSTHOG_MCP_READ_ONLY",
):
monkeypatch.delenv(var, raising=False)
assert posthog_mcp_config_from_env() is None
def test_loads_hosted_config_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("POSTHOG_MCP_AUTH_TOKEN", "phx_secret")
monkeypatch.delenv("POSTHOG_MCP_URL", raising=False)
monkeypatch.delenv("POSTHOG_MCP_MODE", raising=False)
monkeypatch.delenv("POSTHOG_MCP_READ_ONLY", raising=False)
config = posthog_mcp_config_from_env()
assert config is not None
assert config.url == DEFAULT_POSTHOG_MCP_URL
assert config.auth_token == "phx_secret"
assert config.read_only is True
def test_read_only_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("POSTHOG_MCP_AUTH_TOKEN", "phx_secret")
monkeypatch.setenv("POSTHOG_MCP_READ_ONLY", "false")
config = posthog_mcp_config_from_env()
assert config is not None
assert config.read_only is False
def test_runtime_reason_requires_token_for_hosted(self) -> None:
config = build_posthog_mcp_config({"url": DEFAULT_POSTHOG_MCP_URL, "auth_token": ""})
reason = posthog_mcp_runtime_unavailable_reason(config)
assert reason is not None
assert "personal API key" in reason
def test_runtime_reason_ok_with_token(self) -> None:
config = build_posthog_mcp_config({"auth_token": "phx_secret"})
assert posthog_mcp_runtime_unavailable_reason(config) is None
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_validation_fails_without_token(self) -> None:
config = build_posthog_mcp_config({"auth_token": ""})
result = validate_posthog_mcp_config(config)
assert result.ok is False
assert "personal API key" in result.detail
def test_validation_passes_when_tools_listed(self) -> None:
config = build_posthog_mcp_config({"auth_token": "phx_secret"})
fake_tools = [
{"name": "query-run", "description": "Run HogQL", "input_schema": {}},
{"name": "feature-flag-list", "description": "List flags", "input_schema": {}},
]
with patch(
"integrations.posthog_mcp.list_posthog_mcp_tools",
return_value=fake_tools,
):
result = validate_posthog_mcp_config(config)
assert result.ok is True
assert result.tool_names == ("feature-flag-list", "query-run")
assert "discovered 2 tool(s)" in result.detail
def test_validation_fails_when_no_tools(self) -> None:
config = build_posthog_mcp_config({"auth_token": "phx_secret"})
with patch(
"integrations.posthog_mcp.list_posthog_mcp_tools",
return_value=[],
):
result = validate_posthog_mcp_config(config)
assert result.ok is False
assert "no tools" in result.detail
def test_validation_handles_exception(self) -> None:
config = build_posthog_mcp_config({"auth_token": "phx_secret"})
with patch(
"integrations.posthog_mcp.list_posthog_mcp_tools",
side_effect=RuntimeError("boom"),
):
result = validate_posthog_mcp_config(config)
assert result.ok is False
assert "validation failed" in result.detail
def test_describe_error_includes_auth_hint() -> None:
import httpx
config = build_posthog_mcp_config({"auth_token": "phx_secret"})
request = httpx.Request("GET", DEFAULT_POSTHOG_MCP_URL)
response = httpx.Response(401, request=request)
err = httpx.HTTPStatusError("unauthorized", request=request, response=response)
detail = describe_posthog_mcp_error(err, config)
assert "Authentication failed" in detail
# ---------------------------------------------------------------------------
# Catalog classification
# ---------------------------------------------------------------------------
def test_classify_posthog_mcp_credentials() -> None:
records = [
{
"id": "posthog-mcp-prod",
"service": "posthog_mcp",
"status": "active",
"credentials": {
"url": "https://mcp.posthog.com/mcp",
"mode": "streamable-http",
"auth_token": "phx_secret",
"project_id": "12345",
},
}
]
from tools.investigation.stages.gather_evidence.tools import availability_view
resolved = _classify_integrations(records)
assert "posthog_mcp" in resolved
assert resolved["posthog_mcp"].auth_token == "phx_secret"
assert resolved["posthog_mcp"].project_id == "12345"
# connection_verified is set at the tool-availability boundary
view = availability_view(resolved)
assert view["posthog_mcp"]["connection_verified"] is True