224 lines
7.2 KiB
Python
224 lines
7.2 KiB
Python
"""The server refuses to start unless it is safe to: authentication off loopback, valid settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from aegis_gitea_mcp import cli, transports
|
|
from aegis_gitea_mcp.cli import main
|
|
from aegis_gitea_mcp.transports import run_http, run_stdio
|
|
from tests.test_oauth_helpers import oauth_settings
|
|
from tests.test_server_helpers import BEARER_TOKEN, GITEA_TOKEN, server_settings
|
|
|
|
WILDCARD_V4 = "0.0.0.0" # noqa: S104 (a bind address used as test input, never bound)
|
|
WILDCARD_V6 = "::"
|
|
NON_LOOPBACK = [
|
|
WILDCARD_V4,
|
|
WILDCARD_V6,
|
|
"[::]",
|
|
"192.168.1.10",
|
|
"10.0.0.5",
|
|
"203.0.113.5",
|
|
"2001:db8::1",
|
|
"mcp.example.com",
|
|
"localhost.evil.example",
|
|
"gitea.internal",
|
|
"127.1", # not a canonical loopback address: refused rather than guessed
|
|
"0",
|
|
"128.0.0.1",
|
|
]
|
|
LOOPBACK = ["127.0.0.1", "127.0.0.2", "127.255.255.254", "::1", "[::1]", "localhost", "LOCALHOST"]
|
|
|
|
|
|
class Served:
|
|
"""Replaces ``uvicorn.run`` and records whether anything was started."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def __call__(self, app: Any, **kwargs: Any) -> None:
|
|
self.calls.append(kwargs)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def served(mock_env: None, monkeypatch: pytest.MonkeyPatch) -> Served:
|
|
spy = Served()
|
|
monkeypatch.setattr(transports.uvicorn, "run", spy)
|
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
|
return spy
|
|
|
|
|
|
@pytest.mark.parametrize("host", NON_LOOPBACK)
|
|
def test_a_non_loopback_bind_without_authentication_exits_2(
|
|
host: str, served: Served, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(server_settings(mcp_host=host)) == 2
|
|
|
|
captured = capsys.readouterr()
|
|
assert served.calls == []
|
|
assert "without HTTP authentication" in captured.err
|
|
assert captured.out == ""
|
|
|
|
|
|
@pytest.mark.parametrize("host", NON_LOOPBACK)
|
|
def test_the_serve_command_refuses_the_same_binds(
|
|
host: str, served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
|
|
|
assert main(["serve", "--transport", "http", "--host", host]) == 2
|
|
|
|
assert served.calls == []
|
|
assert "without HTTP authentication" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.parametrize("host", NON_LOOPBACK[:4])
|
|
def test_a_bearer_is_enough_to_bind_publicly(host: str, served: Served) -> None:
|
|
assert run_http(server_settings(mcp_host=host, mcp_bearer_token=BEARER_TOKEN)) == 0
|
|
|
|
assert len(served.calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("host", NON_LOOPBACK[:4])
|
|
def test_complete_oauth_is_enough_to_bind_publicly(host: str, served: Served) -> None:
|
|
settings = oauth_settings(mcp_host=host, mcp_bearer_token=None, gitea_token=None)
|
|
|
|
assert run_http(settings) == 0
|
|
assert len(served.calls) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("host", LOOPBACK)
|
|
def test_loopback_binds_may_run_without_authentication(host: str, served: Served) -> None:
|
|
assert run_http(server_settings(mcp_host=host)) == 0
|
|
|
|
assert len(served.calls) == 1
|
|
|
|
|
|
# Half-configured authentication
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"missing",
|
|
[
|
|
"gitea_oauth_client_id",
|
|
"gitea_oauth_client_secret",
|
|
"token_encryption_key",
|
|
"public_base_url",
|
|
],
|
|
)
|
|
def test_half_configured_oauth_exits_2_and_never_falls_back_to_no_auth(
|
|
missing: str, served: Served, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
settings = oauth_settings(mcp_host=WILDCARD_V4, mcp_bearer_token=None, **{missing: None})
|
|
|
|
assert run_http(settings) == 2
|
|
|
|
assert served.calls == []
|
|
assert missing.upper() in capsys.readouterr().err
|
|
|
|
|
|
def test_oauth_with_a_bearer_but_no_admin_token_exits_2(
|
|
served: Served, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(oauth_settings(gitea_token=None)) == 2
|
|
|
|
assert served.calls == []
|
|
assert "GITEA_TOKEN" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.parametrize("bearer", ["", "short", "x" * 31])
|
|
def test_a_bearer_shorter_than_32_characters_is_rejected_at_configuration(
|
|
bearer: str, served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
|
monkeypatch.setenv("MCP_BEARER_TOKEN", bearer)
|
|
monkeypatch.setenv("MCP_HOST", WILDCARD_V4)
|
|
|
|
code = main(["serve", "--transport", "http"])
|
|
|
|
if bearer == "":
|
|
# A blank value counts as unset, so the unauthenticated public bind is refused instead.
|
|
assert code == 2 and "without HTTP authentication" in capsys.readouterr().err
|
|
else:
|
|
assert code == 2
|
|
assert "invalid configuration" in capsys.readouterr().err
|
|
assert served.calls == []
|
|
|
|
|
|
def test_an_http_public_base_url_on_a_public_host_is_rejected(
|
|
served: Served, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
|
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
|
monkeypatch.setenv("PUBLIC_BASE_URL", "http://mcp.example.com")
|
|
|
|
assert main(["serve", "--transport", "http"]) == 2
|
|
|
|
assert served.calls == []
|
|
assert "PUBLIC_BASE_URL" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_missing_gitea_token_exits_2_for_admin_token_mode_and_stdio(
|
|
served: Served, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(server_settings(gitea_token=None, mcp_bearer_token=BEARER_TOKEN)) == 2
|
|
assert run_http(server_settings(gitea_token=None)) == 2
|
|
assert run_stdio(server_settings(gitea_token=None)) == 2
|
|
|
|
captured = capsys.readouterr()
|
|
assert served.calls == []
|
|
assert "GITEA_TOKEN" in captured.err
|
|
assert captured.out == "" # stdout is reserved for JSON-RPC
|
|
|
|
|
|
def test_an_unwritable_audit_log_exits_2_before_serving(
|
|
tmp_path: Path,
|
|
served: Served,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
blocker = tmp_path / "blocker"
|
|
blocker.write_text("a file where a directory is needed")
|
|
path = blocker / "audit.log"
|
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(path))
|
|
|
|
assert run_http(server_settings(audit_log_path=path)) == 2
|
|
assert run_stdio(server_settings(audit_log_path=path)) == 2
|
|
|
|
assert served.calls == []
|
|
assert "audit log" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_symlinked_audit_path_exits_2(
|
|
tmp_path: Path,
|
|
served: Served,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
target = tmp_path / "elsewhere.log"
|
|
target.write_text("")
|
|
link = tmp_path / "audit.log"
|
|
link.symlink_to(target)
|
|
monkeypatch.setenv("AUDIT_LOG_PATH", str(link))
|
|
|
|
assert run_http(server_settings(audit_log_path=link)) == 2
|
|
|
|
assert served.calls == []
|
|
assert "symbolic link" in capsys.readouterr().err
|
|
assert target.read_text() == ""
|
|
|
|
|
|
def test_unknown_disabled_tools_stop_startup(
|
|
served: Served, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert run_http(server_settings(disabled_tools=["gitea_no_such_tool"])) == 2
|
|
assert run_stdio(server_settings(disabled_tools=["gitea_no_such_tool"])) == 2
|
|
|
|
assert served.calls == []
|
|
assert "gitea_no_such_tool" in capsys.readouterr().err
|