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_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", "--python", "3.12"]) 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"], "/opt/bin/uv") self.assertEqual(server["env"]["NOCOBASE_MCP_NB_BIN"], "/opt/bin/nb") self.assertIn(str(root.resolve()), server["args"]) self.assertNotIn("--enable-writes", server["args"]) self.assertIn("--enable-writes", writable["mcpServers"]["nocobase-crm"]["args"]) 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.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.assertTrue(manifest["securityDefaults"]["readOnly"]) self.assertFalse(manifest["securityDefaults"]["uploads"]) if __name__ == "__main__": unittest.main()