384 lines
16 KiB
Python
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()
|