d76c16d9a7
The upstream MCP container requires a SonarQube user token in the Authorization header. Without one, every call returns 401. - proxy: read SONARQUBE_TOKEN via sonarqube_token() at session-open time; raise TokenMissingError when unset/blank. upstream_session() attaches the token as "Authorization: Bearer <token>" via streamablehttp_client(headers=...). - cli: fail fast in serve and check with a clear stderr message and exit 1 when the token is missing, before any network attempt. All exception text written to stderr passes through _redact() so an accidentally-leaked token from a third-party exception is replaced with [REDACTED] before display. - The token is never stored on any object, never logged, and the TokenMissingError message contains no token material (it only describes how to generate one in SonarQube). - Tests: header forwarding via mocked streamablehttp_client, missing- token exit code, redaction in CLI error paths, whitespace stripping on the token. Total: 25 tests. - Docs: README/CLAUDE updated with the new env-var, Claude Desktop config snippet, and the security guarantees. CHANGELOG added. Bumps version to 0.2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
"""Tests for the Click CLI: serve startup errors, check success/failure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
from mcp import types
|
|
|
|
from mcp_sonarqube_proxy import cli
|
|
|
|
|
|
def test_check_success_lists_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake_tools = [
|
|
types.Tool(
|
|
name="search",
|
|
description="Search issues.",
|
|
inputSchema={"type": "object"},
|
|
),
|
|
types.Tool(
|
|
name="get_metric",
|
|
title="Project Metric",
|
|
description=None,
|
|
inputSchema={"type": "object"},
|
|
),
|
|
]
|
|
|
|
async def fake_run_check() -> list[types.Tool]:
|
|
return fake_tools
|
|
|
|
monkeypatch.setattr(cli, "_run_check", fake_run_check)
|
|
|
|
result = CliRunner().invoke(cli.main, ["check"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "2 Tools" in result.output
|
|
assert "search" in result.output
|
|
assert "Project Metric" in result.output
|
|
|
|
|
|
def test_check_failure_exits_one_with_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_run_check() -> list[types.Tool]:
|
|
raise ConnectionError("connection refused")
|
|
|
|
monkeypatch.setattr(cli, "_run_check", fake_run_check)
|
|
|
|
result = CliRunner().invoke(cli.main, ["check"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Upstream nicht erreichbar" in result.stderr
|
|
assert "connection refused" in result.stderr
|
|
|
|
|
|
def test_serve_startup_error_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_run_serve() -> None:
|
|
raise RuntimeError("upstream initialize failed")
|
|
|
|
monkeypatch.setattr(cli, "_run_serve", fake_run_serve)
|
|
|
|
result = CliRunner().invoke(cli.main, ["serve"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "fatal" in result.stderr
|
|
assert "upstream initialize failed" in result.stderr
|
|
|
|
|
|
def test_version_flag() -> None:
|
|
from mcp_sonarqube_proxy import __version__
|
|
|
|
result = CliRunner().invoke(cli.main, ["--version"])
|
|
assert result.exit_code == 0
|
|
assert __version__ in result.output
|
|
|
|
|
|
# --- SONARQUBE_TOKEN handling ------------------------------------------------
|
|
|
|
|
|
def test_check_missing_token_exits_one_with_clean_message(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("SONARQUBE_TOKEN", raising=False)
|
|
|
|
result = CliRunner().invoke(cli.main, ["check"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "SONARQUBE_TOKEN" in result.stderr
|
|
assert "Security" in result.stderr
|
|
|
|
|
|
def test_serve_missing_token_exits_one_with_clean_message(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("SONARQUBE_TOKEN", raising=False)
|
|
|
|
result = CliRunner().invoke(cli.main, ["serve"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "SONARQUBE_TOKEN" in result.stderr
|
|
|
|
|
|
def test_check_redacts_token_when_error_includes_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
secret = "secret-token-do-not-leak"
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", secret)
|
|
|
|
async def fake_run_check() -> list[types.Tool]:
|
|
# Simulate a worst-case dependency that leaks the token in its message.
|
|
raise RuntimeError(f"401 Unauthorized; sent header Bearer {secret}")
|
|
|
|
monkeypatch.setattr(cli, "_run_check", fake_run_check)
|
|
|
|
result = CliRunner().invoke(cli.main, ["check"])
|
|
|
|
assert result.exit_code == 1
|
|
assert secret not in result.stderr
|
|
assert "[REDACTED]" in result.stderr
|
|
|
|
|
|
def test_serve_redacts_token_when_error_includes_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
secret = "another-secret-token-leak"
|
|
monkeypatch.setenv("SONARQUBE_TOKEN", secret)
|
|
|
|
async def fake_run_serve() -> None:
|
|
raise RuntimeError(f"upstream said Bearer {secret} is invalid")
|
|
|
|
monkeypatch.setattr(cli, "_run_serve", fake_run_serve)
|
|
|
|
result = CliRunner().invoke(cli.main, ["serve"])
|
|
|
|
assert result.exit_code == 1
|
|
assert secret not in result.stderr
|
|
assert "[REDACTED]" in result.stderr
|