feat: add the aegis-gitea-mcp setup command
Verifies the Gitea instance, token and scopes, registers or reuses the aegis-gitea-mcp OAuth app, generates missing secrets and writes a 0600 env file from the template. Prints client configs and deployment snippets without any secret value.
This commit is contained in:
1 parent
465a365d89
commit
1d223e6799
7 files changed
+1798
-3
No files matched your search
+1
-1
@@ -15,7 +15,7 @@ def test_version_prints_package_version(capsys: pytest.CaptureFixture[str]) -> N
|
||||
assert capsys.readouterr().out.strip() == __version__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv", [[], ["serve"], ["serve", "--transport", "http"], ["setup"]])
|
||||
@pytest.mark.parametrize("argv", [[], ["serve"], ["serve", "--transport", "http"]])
|
||||
def test_unbuilt_commands_exit_2(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(argv) == 2
|
||||
captured = capsys.readouterr()
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""Tests for ``aegis-gitea-mcp setup`` (HTTP mocked with pytest-httpx, files in tmp_path)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from aegis_gitea_mcp import setup_cmd
|
||||
from aegis_gitea_mcp.cli import main
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
from aegis_gitea_mcp.setup_cmd import (
|
||||
EnvEntry,
|
||||
missing_scopes,
|
||||
parse_env_text,
|
||||
render_env,
|
||||
write_env_atomic,
|
||||
)
|
||||
from aegis_gitea_mcp.setup_template import ENV_TEMPLATE
|
||||
|
||||
GITEA = "https://git.test"
|
||||
API = f"{GITEA}/api/v1"
|
||||
PUBLIC = "https://mcp.example.com"
|
||||
TOKEN = "tok_SECRET_0123456789abcdef"
|
||||
CLIENT_SECRET = "gto_CLIENTSECRET_9876543210"
|
||||
OLD_SECRET = "gto_OLDSECRET_1111111111"
|
||||
ALL_WRITE = [f"write:{c}" for c in setup_cmd.GITEA_SCOPE_CATEGORIES]
|
||||
LIST_URL = re.compile(re.escape(f"{API}/user/applications/oauth2") + r"\?page=\d+&limit=\d+")
|
||||
|
||||
|
||||
def app_json(
|
||||
app_id: int = 7,
|
||||
client_id: str = "cid-7",
|
||||
redirects: list[str] | None = None,
|
||||
secret: str = "",
|
||||
confidential: bool = True,
|
||||
name: str = "aegis-gitea-mcp",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": app_id,
|
||||
"name": name,
|
||||
"client_id": client_id,
|
||||
"client_secret": secret,
|
||||
"redirect_uris": redirects if redirects is not None else [f"{PUBLIC}/oauth/callback"],
|
||||
"confidential_client": confidential,
|
||||
"skip_secondary_authorization": False,
|
||||
}
|
||||
|
||||
|
||||
def mock_instance(
|
||||
httpx_mock: HTTPXMock,
|
||||
*,
|
||||
scopes: list[str] | None = None,
|
||||
apps: list[dict[str, Any]] | None = None,
|
||||
token_status: int = 200,
|
||||
) -> None:
|
||||
"""Register the version, user (admin), token and app-list endpoints."""
|
||||
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.99.0"})
|
||||
httpx_mock.add_response(url=f"{API}/user", json={"login": "root", "is_admin": True})
|
||||
if token_status == 200:
|
||||
httpx_mock.add_response(
|
||||
url=f"{API}/token",
|
||||
json={"id": 1, "name": "setup", "scopes": ["all"] if scopes is None else scopes},
|
||||
)
|
||||
else:
|
||||
httpx_mock.add_response(url=f"{API}/token", status_code=token_status, json={})
|
||||
if apps is not None:
|
||||
httpx_mock.add_response(url=LIST_URL, json=apps, headers={"x-total-count": str(len(apps))})
|
||||
|
||||
|
||||
def mock_create(httpx_mock: HTTPXMock, **overrides: Any) -> None:
|
||||
httpx_mock.add_response(
|
||||
method="POST",
|
||||
url=f"{API}/user/applications/oauth2",
|
||||
status_code=201,
|
||||
json=app_json(secret=CLIENT_SECRET, **overrides),
|
||||
)
|
||||
|
||||
|
||||
def run_setup(*extra: str, public: bool = True, oauth: bool = True) -> int:
|
||||
argv = ["setup", "--gitea-url", GITEA, "--yes", *extra]
|
||||
if public:
|
||||
argv += ["--public-base-url", PUBLIC]
|
||||
if not oauth:
|
||||
argv.append("--no-oauth")
|
||||
return main(argv)
|
||||
|
||||
|
||||
def read_env(path: Path) -> dict[str, str]:
|
||||
return {k: e.value for k, e in parse_env_text(path.read_text()).items()}
|
||||
|
||||
|
||||
def write_existing_env(path: Path, **values: str) -> None:
|
||||
path.write_text("".join(f"{k}={v}\n" for k, v in values.items()))
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def token_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_TOKEN", TOKEN)
|
||||
|
||||
|
||||
def assert_no_secrets(out: str, *extra: str) -> None:
|
||||
for secret in (TOKEN, CLIENT_SECRET, OLD_SECRET, *extra):
|
||||
assert secret not in out
|
||||
|
||||
|
||||
# --- happy path --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_happy_path_creates_app_and_env(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, apps=[])
|
||||
mock_create(httpx_mock)
|
||||
assert run_setup() == 0
|
||||
|
||||
post = next(r for r in httpx_mock.get_requests() if r.method == "POST")
|
||||
assert json.loads(post.content) == {
|
||||
"name": "aegis-gitea-mcp",
|
||||
"redirect_uris": [f"{PUBLIC}/oauth/callback"],
|
||||
"confidential_client": True,
|
||||
"skip_secondary_authorization": False,
|
||||
}
|
||||
assert all(r.headers["authorization"] == f"token {TOKEN}" for r in httpx_mock.get_requests())
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600
|
||||
env = read_env(env_path)
|
||||
assert env["GITEA_URL"] == GITEA
|
||||
assert env["GITEA_TOKEN"] == TOKEN
|
||||
assert env["GITEA_OAUTH_CLIENT_ID"] == "cid-7"
|
||||
assert env["GITEA_OAUTH_CLIENT_SECRET"] == CLIENT_SECRET
|
||||
assert env["OAUTH_ENABLED"] == "true"
|
||||
assert env["PUBLIC_BASE_URL"] == PUBLIC
|
||||
assert len(env["MCP_BEARER_TOKEN"]) >= 32
|
||||
assert len(env["TOKEN_ENCRYPTION_KEY"]) >= 32
|
||||
assert env["MCP_BEARER_TOKEN"] != env["TOKEN_ENCRYPTION_KEY"]
|
||||
text = env_path.read_text()
|
||||
assert text.startswith("# AegisGitea MCP configuration.")
|
||||
assert "# --- Gitea" in text
|
||||
assert text.index("GITEA_URL=") < text.index("MCP_TRANSPORT=") < text.index("OAUTH_ENABLED=")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
for stream in (captured.out, captured.err):
|
||||
assert_no_secrets(stream, env["MCP_BEARER_TOKEN"], env["TOKEN_ENCRYPTION_KEY"])
|
||||
assert "Gitea reachable: version 1.99.0" in captured.out
|
||||
assert "root (site administrator)" in captured.out
|
||||
assert "cid-7" in captured.out
|
||||
assert "grep '^MCP_BEARER_TOKEN=' " in captured.out
|
||||
|
||||
|
||||
def test_written_env_is_accepted_by_settings(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
mock_instance(httpx_mock, apps=[])
|
||||
mock_create(httpx_mock)
|
||||
assert run_setup() == 0
|
||||
settings = Settings(_env_file=str(tmp_path / ".env")) # type: ignore[call-arg]
|
||||
assert settings.oauth_enabled
|
||||
assert settings.oauth_config_errors() == []
|
||||
assert settings.http_auth_configured()
|
||||
assert settings.gitea_url == GITEA
|
||||
|
||||
|
||||
def test_printed_configs(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
mock_instance(httpx_mock, apps=[])
|
||||
mock_create(httpx_mock)
|
||||
assert run_setup() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert f"--env GITEA_URL={GITEA} --env GITEA_TOKEN=<your-gitea-token>" in out
|
||||
assert "uvx aegis-gitea-mcp" in out
|
||||
assert '"mcpServers"' in out
|
||||
assert (
|
||||
f"claude mcp add --transport http aegis-gitea {PUBLIC}/mcp "
|
||||
'--header "Authorization: Bearer <MCP_BEARER_TOKEN>"'
|
||||
) in out
|
||||
assert "claude.ai custom connector" in out
|
||||
assert "Mistral Le Chat" in out
|
||||
assert "git.hiddenden.cafe/hiddenden/aegisgitea-mcp:dev" in out
|
||||
assert "MCP_HOST: 0.0.0.0" in out
|
||||
assert '"127.0.0.1:8080:8080"' in out
|
||||
assert "no-new-privileges:true" in out
|
||||
assert "mcp.example.com {" in out
|
||||
assert "reverse_proxy 127.0.0.1:8080" in out
|
||||
assert "proxy_buffering off;" in out
|
||||
assert f"curl {PUBLIC}/health" in out
|
||||
assert "aegis-gitea-mcp serve" in out
|
||||
assert "MCP_TRANSPORT=http aegis-gitea-mcp serve" in out
|
||||
|
||||
|
||||
# --- verification failures -----------------------------------------------------
|
||||
|
||||
|
||||
def test_non_admin_token_fails(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.99.0"})
|
||||
httpx_mock.add_response(url=f"{API}/user", json={"login": "bob", "is_admin": False})
|
||||
assert run_setup() == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "not a site administrator" in err
|
||||
assert "Settings > Applications > Generate New Token" in err
|
||||
assert not (tmp_path / ".env").exists()
|
||||
assert_no_secrets(err)
|
||||
|
||||
|
||||
def test_rejected_token_fails(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.99.0"})
|
||||
httpx_mock.add_response(url=f"{API}/user", status_code=401, json={"message": "bad token"})
|
||||
assert run_setup() == 1
|
||||
assert "token rejected" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_scopes_listed(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, scopes=["write:repository", "read:user", "write:issue"])
|
||||
assert run_setup() == 1
|
||||
err = capsys.readouterr().err
|
||||
listed = err.split("lacks required scopes: ")[1].split(". ")[0].split(", ")
|
||||
assert "write:admin" in listed
|
||||
assert "write:user" in listed
|
||||
assert "write:repository" not in listed
|
||||
assert "write:issue" not in listed
|
||||
assert not (tmp_path / ".env").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scopes", [["all"], ["ALL", "read:user"], ALL_WRITE])
|
||||
def test_full_scopes_accepted(httpx_mock: HTTPXMock, scopes: list[str]) -> None:
|
||||
mock_instance(httpx_mock, scopes=scopes)
|
||||
assert run_setup(oauth=False) == 0
|
||||
|
||||
|
||||
def test_missing_scopes_helper() -> None:
|
||||
assert missing_scopes(["all"]) == []
|
||||
assert missing_scopes(ALL_WRITE) == []
|
||||
assert missing_scopes(["read:user"]) == list(setup_cmd.REQUIRED_SCOPES)
|
||||
|
||||
|
||||
def test_token_endpoint_missing_warns_and_continues(
|
||||
httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, token_status=404)
|
||||
assert run_setup(oauth=False) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Warning: could not read the token scopes" in out
|
||||
assert "not verified" in out
|
||||
|
||||
|
||||
def test_empty_scope_list_warns(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
mock_instance(httpx_mock, scopes=[])
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert "could not be verified" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_public_only_scope_warns(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
mock_instance(httpx_mock, scopes=["all", "public-only"])
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert "public-only" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_unreachable_instance(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
httpx_mock.add_exception(httpx.ConnectError("boom"))
|
||||
assert run_setup() == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "Gitea is not reachable" in err
|
||||
assert "git.test" in err
|
||||
assert not (tmp_path / ".env").exists()
|
||||
|
||||
|
||||
def test_timeout_message(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
httpx_mock.add_exception(httpx.ReadTimeout("slow"))
|
||||
assert run_setup() == 1
|
||||
assert "did not answer within 15 seconds" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_not_gitea_response(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", text="<html>login</html>")
|
||||
assert run_setup() == 1
|
||||
assert "did not return Gitea API JSON" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_redirect_is_not_followed(
|
||||
httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url=f"{API}/version", status_code=301, headers={"location": "https://other.test/x"}
|
||||
)
|
||||
assert run_setup() == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "redirect to https://other.test/x" in err
|
||||
assert len(httpx_mock.get_requests()) == 1
|
||||
|
||||
|
||||
def test_gitea_error_text_is_redacted(
|
||||
httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.99.0"})
|
||||
httpx_mock.add_response(
|
||||
url=f"{API}/user", status_code=500, json={"message": f"boom {TOKEN} boom"}
|
||||
)
|
||||
assert run_setup() == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "boom *** boom" in err
|
||||
assert_no_secrets(err)
|
||||
|
||||
|
||||
# --- OAuth app -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_oauth_app_reused_keeps_secret(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_existing_env(
|
||||
tmp_path / ".env",
|
||||
GITEA_OAUTH_CLIENT_ID="cid-7",
|
||||
GITEA_OAUTH_CLIENT_SECRET=OLD_SECRET,
|
||||
MCP_BEARER_TOKEN="b" * 40,
|
||||
TOKEN_ENCRYPTION_KEY="k" * 40,
|
||||
)
|
||||
mock_instance(httpx_mock, apps=[app_json(), app_json(app_id=8, client_id="x", name="other")])
|
||||
assert run_setup() == 0
|
||||
assert all(r.method == "GET" for r in httpx_mock.get_requests())
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["GITEA_OAUTH_CLIENT_SECRET"] == OLD_SECRET
|
||||
assert env["GITEA_OAUTH_CLIENT_ID"] == "cid-7"
|
||||
assert env["OAUTH_ENABLED"] == "true"
|
||||
out = capsys.readouterr().out
|
||||
assert "reused" in out
|
||||
assert_no_secrets(out)
|
||||
|
||||
|
||||
def test_oauth_app_redirect_differs_is_patched(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_existing_env(
|
||||
tmp_path / ".env", GITEA_OAUTH_CLIENT_ID="cid-7", GITEA_OAUTH_CLIENT_SECRET=OLD_SECRET
|
||||
)
|
||||
mock_instance(httpx_mock, apps=[app_json(redirects=["https://old.example.com/oauth/callback"])])
|
||||
httpx_mock.add_response(
|
||||
method="PATCH",
|
||||
url=f"{API}/user/applications/oauth2/7",
|
||||
json=app_json(secret=CLIENT_SECRET),
|
||||
)
|
||||
assert run_setup() == 0
|
||||
patch = next(r for r in httpx_mock.get_requests() if r.method == "PATCH")
|
||||
assert json.loads(patch.content)["redirect_uris"] == [f"{PUBLIC}/oauth/callback"]
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["GITEA_OAUTH_CLIENT_SECRET"] == CLIENT_SECRET
|
||||
out = capsys.readouterr().out
|
||||
assert "secret regenerated" in out
|
||||
assert "removed 1 other redirect URI" in out
|
||||
assert_no_secrets(out)
|
||||
|
||||
|
||||
def test_oauth_app_unknown_secret_is_rotated(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, apps=[app_json()])
|
||||
httpx_mock.add_response(
|
||||
method="PATCH",
|
||||
url=f"{API}/user/applications/oauth2/7",
|
||||
json=app_json(secret=CLIENT_SECRET),
|
||||
)
|
||||
assert run_setup() == 0
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["GITEA_OAUTH_CLIENT_SECRET"] == CLIENT_SECRET
|
||||
assert env["GITEA_OAUTH_CLIENT_ID"] == "cid-7"
|
||||
assert "secret rotated" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_rotation_requires_yes_when_not_interactive(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, apps=[app_json()])
|
||||
assert main(["setup", "--gitea-url", GITEA, "--public-base-url", PUBLIC]) == 1
|
||||
assert "Re-run with --yes" in capsys.readouterr().err
|
||||
assert not (tmp_path / ".env").exists()
|
||||
|
||||
|
||||
def test_stale_client_id_creates_new_app_when_missing(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path
|
||||
) -> None:
|
||||
write_existing_env(
|
||||
tmp_path / ".env", GITEA_OAUTH_CLIENT_ID="gone", GITEA_OAUTH_CLIENT_SECRET=OLD_SECRET
|
||||
)
|
||||
mock_instance(httpx_mock, apps=[])
|
||||
mock_create(httpx_mock, client_id="fresh")
|
||||
assert run_setup() == 0
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["GITEA_OAUTH_CLIENT_ID"] == "fresh"
|
||||
assert env["GITEA_OAUTH_CLIENT_SECRET"] == CLIENT_SECRET
|
||||
|
||||
|
||||
def test_oauth_apps_are_paginated(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
mock_instance(httpx_mock)
|
||||
httpx_mock.add_response(
|
||||
url=f"{API}/user/applications/oauth2?page=1&limit=50",
|
||||
json=[app_json(app_id=1, client_id="a", name="x")],
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=f"{API}/user/applications/oauth2?page=2&limit=50",
|
||||
json=[app_json(app_id=2, client_id="b", name="y")],
|
||||
)
|
||||
httpx_mock.add_response(url=f"{API}/user/applications/oauth2?page=3&limit=50", json=[])
|
||||
mock_create(httpx_mock, app_id=3, client_id="c")
|
||||
assert run_setup() == 0
|
||||
assert read_env(tmp_path / ".env")["GITEA_OAUTH_CLIENT_ID"] == "c"
|
||||
|
||||
|
||||
def test_oauth_create_forbidden_explains_scope(
|
||||
httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock, apps=[])
|
||||
httpx_mock.add_response(
|
||||
method="POST", url=f"{API}/user/applications/oauth2", status_code=403, json={}
|
||||
)
|
||||
assert run_setup() == 1
|
||||
assert "write:user" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_no_oauth_flag(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert not any("applications" in str(r.url) for r in httpx_mock.get_requests())
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["OAUTH_ENABLED"] == "false"
|
||||
assert env["PUBLIC_BASE_URL"] == PUBLIC
|
||||
assert env["GITEA_OAUTH_CLIENT_ID"] == ""
|
||||
|
||||
|
||||
def test_no_public_url_skips_oauth(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(public=False) == 0
|
||||
assert read_env(tmp_path / ".env")["OAUTH_ENABLED"] == "false"
|
||||
out = capsys.readouterr().out
|
||||
assert "no public base URL given" in out
|
||||
assert "http://127.0.0.1:8080/mcp" in out
|
||||
assert "Caddyfile" not in out
|
||||
|
||||
|
||||
# --- env file handling ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_existing_values_preserved(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
bearer, key = "b" * 40, "k" * 40
|
||||
(tmp_path / ".env").write_text(
|
||||
"MAX_TOOL_RESPONSE_ITEMS=5\n"
|
||||
f"MCP_BEARER_TOKEN={bearer}\n"
|
||||
f"TOKEN_ENCRYPTION_KEY={key}\n"
|
||||
'MY_CUSTOM_VAR="hello world"\n'
|
||||
"READ_ONLY=true # keep it safe\n"
|
||||
)
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
text = (tmp_path / ".env").read_text()
|
||||
env = read_env(tmp_path / ".env")
|
||||
assert env["MAX_TOOL_RESPONSE_ITEMS"] == "5"
|
||||
assert env["MCP_BEARER_TOKEN"] == bearer
|
||||
assert env["TOKEN_ENCRYPTION_KEY"] == key
|
||||
assert env["MY_CUSTOM_VAR"] == "hello world"
|
||||
assert 'MY_CUSTOM_VAR="hello world"' in text
|
||||
assert "READ_ONLY=true # keep it safe" in text
|
||||
assert text.count("MAX_TOOL_RESPONSE_ITEMS=") == 1
|
||||
|
||||
|
||||
def test_short_existing_secret_warns(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_existing_env(tmp_path / ".env", MCP_BEARER_TOKEN="short")
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert read_env(tmp_path / ".env")["MCP_BEARER_TOKEN"] == "short"
|
||||
assert "MCP_BEARER_TOKEN in the env file is shorter" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_url_and_token_taken_from_env_file(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITEA_TOKEN")
|
||||
write_existing_env(tmp_path / ".env", GITEA_URL=GITEA, GITEA_TOKEN=TOKEN)
|
||||
mock_instance(httpx_mock)
|
||||
assert main(["setup", "--yes", "--no-oauth"]) == 0
|
||||
assert read_env(tmp_path / ".env")["GITEA_TOKEN"] == TOKEN
|
||||
|
||||
|
||||
def test_symlink_env_file_refused(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
target = tmp_path / "real.env"
|
||||
target.write_text("KEEP=1\n")
|
||||
(tmp_path / ".env").symlink_to(target)
|
||||
assert run_setup() == 1
|
||||
assert "symlink" in capsys.readouterr().err
|
||||
assert target.read_text() == "KEEP=1\n"
|
||||
assert httpx_mock.get_requests() == []
|
||||
|
||||
|
||||
def test_loose_permissions_are_tightened(
|
||||
httpx_mock: HTTPXMock, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("READ_ONLY=true\n")
|
||||
env_path.chmod(0o644)
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600
|
||||
assert "permissions are now 0600" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_custom_env_file_and_template(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
template = tmp_path / "tpl"
|
||||
template.write_text("# my template\nGITEA_URL=\nOAUTH_ENABLED=false\nOTHER=default\n")
|
||||
mock_instance(httpx_mock)
|
||||
target = tmp_path / "out.env"
|
||||
assert run_setup("--env-file", str(target), "--template", str(template), oauth=False) == 0
|
||||
text = target.read_text()
|
||||
assert text.startswith("# my template\n")
|
||||
assert "OTHER=default" in text
|
||||
assert f"GITEA_URL={GITEA}" in text
|
||||
|
||||
|
||||
def test_missing_explicit_template_fails(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert run_setup("--template", "/nonexistent/tpl") == 1
|
||||
assert "cannot read template" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_falls_back_to_embedded_template(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
assert not (tmp_path / ".env.example").exists()
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert (tmp_path / ".env").read_text().startswith(ENV_TEMPLATE.splitlines()[0])
|
||||
|
||||
|
||||
def test_uses_local_env_example_when_present(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
|
||||
(tmp_path / ".env.example").write_text("# local template\nGITEA_URL=\n")
|
||||
mock_instance(httpx_mock)
|
||||
assert run_setup(oauth=False) == 0
|
||||
assert (tmp_path / ".env").read_text().startswith("# local template\n")
|
||||
|
||||
|
||||
def test_missing_env_dir_fails(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert run_setup("--env-file", "/nonexistent-dir-xyz/.env") == 1
|
||||
assert "does not exist" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- inputs ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "needle"),
|
||||
[
|
||||
("ftp://git.test", "http:// or https://"),
|
||||
("https://user:hunter2@git.test", "credentials"),
|
||||
("https://git.test?x=1", "query string"),
|
||||
("git.test", "http:// or https://"),
|
||||
],
|
||||
)
|
||||
def test_invalid_gitea_url(url: str, needle: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["setup", "--yes", "--gitea-url", url]) == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "invalid Gitea URL" in err
|
||||
assert needle in err
|
||||
assert "hunter2" not in err
|
||||
|
||||
|
||||
def test_invalid_public_url(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["setup", "--yes", "--gitea-url", GITEA, "--public-base-url", "http://mcp.x"]) == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "invalid public base URL" in err
|
||||
assert "https" in err
|
||||
|
||||
|
||||
def test_gitea_url_api_suffix_normalised(httpx_mock: HTTPXMock) -> None:
|
||||
mock_instance(httpx_mock)
|
||||
assert main(["setup", "--yes", "--no-oauth", "--gitea-url", f"{GITEA}/api/v1/"]) == 0
|
||||
|
||||
|
||||
def test_token_prompt_uses_getpass(
|
||||
httpx_mock: HTTPXMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITEA_TOKEN")
|
||||
prompts: list[str] = []
|
||||
|
||||
def fake_getpass(prompt: str = "") -> str:
|
||||
prompts.append(prompt)
|
||||
return f" {TOKEN} "
|
||||
|
||||
monkeypatch.setattr(setup_cmd.getpass, "getpass", fake_getpass)
|
||||
mock_instance(httpx_mock)
|
||||
assert main(["setup", "--gitea-url", GITEA, "--no-oauth"]) == 0
|
||||
assert len(prompts) == 1
|
||||
assert read_env(tmp_path / ".env")["GITEA_TOKEN"] == TOKEN
|
||||
assert_no_secrets(capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_url_prompt(httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": GITEA)
|
||||
mock_instance(httpx_mock)
|
||||
assert main(["setup", "--no-oauth"]) == 0
|
||||
assert read_env(tmp_path / ".env")["GITEA_URL"] == GITEA
|
||||
|
||||
|
||||
def test_missing_token_with_yes_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITEA_TOKEN")
|
||||
assert main(["setup", "--yes", "--gitea-url", GITEA]) == 1
|
||||
assert "no Gitea token" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_url_with_yes_fails(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert main(["setup", "--yes"]) == 1
|
||||
assert "no Gitea URL" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_unsafe_token_rejected_without_echo(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("GITEA_TOKEN", "abc def#ghi")
|
||||
assert main(["setup", "--yes", "--gitea-url", GITEA]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "abc" not in captured.err + captured.out
|
||||
|
||||
|
||||
def test_placeholder_url_in_env_file_is_ignored(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_existing_env(tmp_path / ".env", GITEA_URL="https://gitea.example.com")
|
||||
assert main(["setup", "--yes"]) == 1
|
||||
assert "no Gitea URL" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- env helpers --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_env_text_handles_quotes_comments_and_case() -> None:
|
||||
text = "# c\nA=1\nb = \"two words\"\nC='x'\nD=val # note\nexport E=5\nnot a line\n"
|
||||
parsed = parse_env_text(text)
|
||||
assert {k: e.value for k, e in parsed.items()} == {
|
||||
"A": "1",
|
||||
"B": "two words",
|
||||
"C": "x",
|
||||
"D": "val",
|
||||
"E": "5",
|
||||
}
|
||||
|
||||
|
||||
def test_render_env_precedence_and_extras() -> None:
|
||||
template = "# head\nA=tpl\nB=tpl\n# C=commented\nD=tpl\n"
|
||||
existing = {
|
||||
"B": EnvEntry("B", '"keep me"', "keep me"),
|
||||
"X": EnvEntry("X", "9", "9"),
|
||||
"A": EnvEntry("A", "old", "old"),
|
||||
}
|
||||
out = render_env(template, {"A": "new", "Z": "1"}, existing)
|
||||
lines = out.splitlines()
|
||||
assert lines[:5] == ["# head", "A=new", 'B="keep me"', "# C=commented", "D=tpl"]
|
||||
assert "Z=1" in lines
|
||||
assert "X=9" in lines
|
||||
assert out.endswith("\n")
|
||||
|
||||
|
||||
def test_write_env_atomic_mode_and_cleanup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
path = tmp_path / "x.env"
|
||||
write_env_atomic(path, "A=1\n")
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert path.read_text() == "A=1\n"
|
||||
|
||||
def boom(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(os, "replace", boom)
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
write_env_atomic(path, "A=2\n")
|
||||
assert path.read_text() == "A=1\n"
|
||||
assert [p.name for p in tmp_path.iterdir() if p.name != "x.env"] == []
|
||||
|
||||
|
||||
def test_write_env_failure_reports_rerun_hint(
|
||||
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_instance(httpx_mock)
|
||||
|
||||
def boom(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise OSError("nope")
|
||||
|
||||
monkeypatch.setattr(os, "replace", boom)
|
||||
assert run_setup(oauth=False) == 1
|
||||
assert "run setup again" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_http_gitea_url_warns(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
httpx_mock.add_response(url="http://git.test/api/v1/version", json={"version": "1"})
|
||||
httpx_mock.add_response(
|
||||
url="http://git.test/api/v1/user", json={"login": "root", "is_admin": True}
|
||||
)
|
||||
httpx_mock.add_response(url="http://git.test/api/v1/token", json={"scopes": ["all"]})
|
||||
assert main(["setup", "--yes", "--no-oauth", "--gitea-url", "http://git.test"]) == 0
|
||||
assert "plain http" in capsys.readouterr().out
|
||||
@@ -0,0 +1,12 @@
|
||||
"""The embedded env template must stay identical to ``.env.example``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aegis_gitea_mcp.setup_template import ENV_TEMPLATE
|
||||
|
||||
|
||||
def test_embedded_template_matches_env_example() -> None:
|
||||
example = Path(__file__).parent.parent / ".env.example"
|
||||
assert example.read_text(encoding="utf-8") == ENV_TEMPLATE
|
||||
Reference in new issue
Block a user