Files
nocobase-mcp/tests/test_agent_install.py
T

384 lines
16 KiB
Python

import json
import subprocess
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import agent_install # noqa: E402
def make_project(root: Path) -> None:
(root / "pyproject.toml").write_text('[project]\nname = "test"\n', encoding="utf-8")
(root / "uv.lock").write_text("version = 1\n", encoding="utf-8")
(root / "config.example.json").write_text(json.dumps({"env": "nb-mcp", "collections": {}}), encoding="utf-8")
def fake_which(name: str) -> str | None:
return {
"git": "/usr/bin/git",
"uv": "/opt/bin/uv",
"nb": "/opt/bin/nb",
}.get(name)
class AgentInstallTests(unittest.TestCase):
def test_normalize_api_base_url_requires_tls_for_remote_keys(self) -> None:
self.assertEqual(
agent_install.normalize_api_base_url("https://nb.example/nocobase/"),
"https://nb.example/nocobase/api",
)
self.assertEqual(agent_install.normalize_api_base_url("http://127.0.0.1:13000"), "http://127.0.0.1:13000/api")
with self.assertRaisesRegex(agent_install.InstallerError, "https"):
agent_install.normalize_api_base_url("http://nb.example")
with self.assertRaisesRegex(agent_install.InstallerError, "must not contain credentials"):
agent_install.normalize_api_base_url("https://user:secret@nb.example")
def test_inspect_reports_required_tools_and_config_as_optional_before_prepare(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
result = agent_install.inspect_project(root, which=fake_which, python_version=(3, 12, 4))
self.assertTrue(result["ok"])
checks = {check["name"]: check for check in result["checks"]}
self.assertTrue(checks["uv"]["ok"])
self.assertFalse(checks["config"]["ok"])
self.assertFalse(checks["config"]["required"])
def test_guided_install_composes_runtime_key_doctor_and_client_config(self) -> None:
calls = []
info_reads = 0
def runner(command, **kwargs):
nonlocal info_reads
calls.append((command, kwargs))
if command[1:4] == ["env", "info", "nb-mcp"]:
info_reads += 1
if info_reads == 1:
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": "token"}}}),
stderr="",
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
result = agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: True,
)
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
commands = [command for command, _kwargs in calls]
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "ready-for-client-registration")
self.assertEqual(result["mode"], "read-only")
self.assertEqual(result["prepared"]["dependencyProfile"], "locked-runtime-only")
self.assertEqual(result["authentication"]["keyExposure"], "private-tty-only")
self.assertEqual(configured["env"], "nb-mcp")
self.assertEqual(
result["clientConfig"]["mcpServers"]["nocobase-crm"]["command"],
str(root.resolve() / ".venv" / "bin" / "python"),
)
self.assertEqual(commands[0][1:5], ["sync", "--locked", "--no-dev", "--python"])
self.assertTrue(any("crm_health" in action for action in result["remainingActions"]))
self.assertNotIn("access-token", json.dumps(result))
def test_guided_install_refuses_non_tty_before_writing(self) -> None:
calls = []
def runner(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: False,
)
self.assertFalse((root / "config.json").exists())
self.assertEqual(calls, [])
def test_cli_exposes_one_guided_install_command(self) -> None:
args = agent_install.build_parser().parse_args(["install", "--api-base-url", "https://nb.example", "--json"])
self.assertEqual(args.command, "install")
self.assertEqual(args.env_name, "nb-mcp")
self.assertFalse(args.enable_writes)
self.assertTrue(args.as_json)
def test_prepare_is_idempotent_and_never_overwrites_existing_config(self) -> None:
calls = []
def runner(command, **kwargs):
calls.append((command, kwargs))
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
first = agent_install.prepare_project(root, runner=runner, which=fake_which, python_version=(3, 12, 4))
config_path = root / "config.json"
config_path.write_text(json.dumps({"env": "custom-env"}), encoding="utf-8")
second = agent_install.prepare_project(root, runner=runner, which=fake_which, python_version=(3, 12, 4))
preserved = json.loads(config_path.read_text(encoding="utf-8"))
self.assertTrue(first["configCreated"])
self.assertFalse(second["configCreated"])
self.assertEqual(preserved["env"], "custom-env")
self.assertEqual(calls[0][0], ["/opt/bin/uv", "sync", "--locked", "--no-dev", "--python", "3.12"])
self.assertEqual(calls[0][1]["env"]["UV_PROJECT_ENVIRONMENT"], str(root / ".venv"))
self.assertEqual(first["dependencies"], "locked-runtime-only")
self.assertEqual(first["environmentPath"], str(root / ".venv"))
self.assertEqual(len(calls), 2)
def test_set_env_backs_up_config_before_changing_it(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
original = {"env": "nb-mcp", "collections": {"customers": {"resource": "customers"}}}
(root / "config.json").write_text(json.dumps(original), encoding="utf-8")
result = agent_install.set_config_env(root, "nb-mcp-acme")
changed = json.loads((root / "config.json").read_text(encoding="utf-8"))
backup = json.loads((root / "config.json.pre-agent-install.bak").read_text(encoding="utf-8"))
self.assertTrue(result["changed"])
self.assertEqual(changed["env"], "nb-mcp-acme")
self.assertEqual(backup, original)
def test_connect_key_creates_env_through_private_tty_without_key_in_argv(self) -> None:
calls = []
info_reads = 0
def runner(command, **kwargs):
nonlocal info_reads
calls.append((command, kwargs))
if command[1:4] == ["env", "info", "nb-mcp"]:
info_reads += 1
if info_reads == 1:
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps(
{
"api": {
"apiBaseUrl": "https://nb.example/api",
"auth": {"type": "token", "accessToken": "-"},
}
}
),
stderr="",
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "old-env"}), encoding="utf-8")
result = agent_install.connect_with_personal_key(
root,
"https://nb.example",
runner=runner,
which=fake_which,
stdin_isatty=lambda: True,
)
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
commands = [command for command, _kwargs in calls]
flattened = [item for command in commands for item in command]
self.assertEqual(result["action"], "created")
self.assertEqual(result["keyExposure"], "private-tty-only")
self.assertEqual(configured["env"], "nb-mcp")
self.assertNotIn("--access-token", flattened)
self.assertIn(
[
"/opt/bin/nb",
"env",
"add",
"nb-mcp",
"--api-base-url",
"https://nb.example/api",
"--auth-type",
"token",
],
commands,
)
interactive_kwargs = calls[1][1]
self.assertNotIn("capture_output", interactive_kwargs)
def test_connect_key_reauthenticates_matching_env_and_refuses_name_collision(self) -> None:
calls = []
auth_complete = False
def matching_runner(command, **kwargs):
nonlocal auth_complete
calls.append(command)
if command[1:3] == ["env", "info"]:
auth_type = "token" if auth_complete else "oauth"
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": auth_type}}}),
stderr="",
)
if command[1:3] == ["env", "auth"]:
auth_complete = True
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
result = agent_install.connect_with_personal_key(
root,
"https://nb.example/api",
runner=matching_runner,
which=fake_which,
stdin_isatty=lambda: True,
)
self.assertEqual(result["action"], "reauthenticated")
self.assertIn(["/opt/bin/nb", "env", "auth", "nb-mcp", "--auth-type", "token"], calls)
def conflicting_runner(command, **kwargs):
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://other.example/api", "auth": {"type": "token"}}}),
stderr="",
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
with self.assertRaisesRegex(agent_install.InstallerError, "instead of overwriting"):
agent_install.connect_with_personal_key(
root,
"https://nb.example",
runner=conflicting_runner,
which=fake_which,
stdin_isatty=lambda: True,
)
def test_connect_key_requires_user_controlled_tty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
agent_install.connect_with_personal_key(
Path(tmp),
"https://nb.example",
which=fake_which,
stdin_isatty=lambda: False,
)
def test_client_config_uses_absolute_paths_and_is_read_only_by_default(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = agent_install.render_client_config(root, which=fake_which)
writable = agent_install.render_client_config(root, enable_writes=True, which=fake_which)
server = config["mcpServers"]["nocobase-crm"]
self.assertEqual(server["command"], str(root.resolve() / ".venv" / "bin" / "python"))
self.assertEqual(server["env"]["NOCOBASE_MCP_NB_BIN"], "/opt/bin/nb")
self.assertIn(str(root.resolve() / "config.json"), server["args"])
self.assertEqual(server["args"][:2], ["-m", "nocobase_crm_mcp.server"])
self.assertNotIn("--enable-writes", server["args"])
self.assertIn("--enable-writes", writable["mcpServers"]["nocobase-crm"]["args"])
def test_client_config_uses_windows_project_environment_python(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = agent_install.render_client_config(root, which=fake_which, os_name="nt")
server = config["mcpServers"]["nocobase-crm"]
self.assertEqual(server["command"], str(root.resolve() / ".venv" / "Scripts" / "python.exe"))
def test_doctor_checks_local_install_without_reading_nb_secrets(self) -> None:
commands = []
def runner(command, **kwargs):
commands.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
(root / "config.json").write_text(json.dumps({"env": "nb-mcp"}), encoding="utf-8")
result = agent_install.doctor_project(
root,
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
)
self.assertTrue(result["ok"])
self.assertEqual(commands[0], ["/opt/bin/uv", "lock", "--check"])
self.assertEqual(
commands[1],
[
str(root.resolve() / ".venv" / "bin" / "python"),
"-c",
"import mcp; import nocobase_crm_mcp",
],
)
self.assertNotIn("--show-secrets", json.dumps(result))
self.assertTrue(any("crm_health" in step for step in result["remainingVerification"]))
def test_release_manifest_version_matches_package_version(self) -> None:
root = Path(__file__).resolve().parent.parent
manifest = json.loads((root / "install-manifest.json").read_text(encoding="utf-8"))
package = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
version = package["project"]["version"]
self.assertEqual(manifest["version"], version)
self.assertEqual(manifest["distribution"]["releaseRef"], f"v{version}")
self.assertEqual(manifest["authentication"]["type"], "personal-api-key")
self.assertEqual(manifest["authentication"]["secretEntry"], "user-controlled-private-tty")
self.assertFalse(manifest["authentication"]["allowPassword"])
self.assertFalse(manifest["authentication"]["allowCommandLineSecret"])
self.assertEqual(manifest["runtime"]["launcher"], "project-venv-python")
self.assertEqual(manifest["runtime"]["dependencyProfile"], "runtime-only-no-dev")
self.assertEqual(manifest["entrypoints"]["guidedCommand"], "install")
self.assertFalse(manifest["installation"]["defaultBackgroundService"])
self.assertTrue(manifest["securityDefaults"]["readOnly"])
self.assertFalse(manifest["securityDefaults"]["uploads"])
if __name__ == "__main__":
unittest.main()