Merge branch 'feat/project-write-upload'
# Conflicts: # README.md # nocobase_crm_mcp/server.py # scripts/manage_mcp_service.py # tests/test_mcp_service.py # tests/test_nb_client.py # tests/test_server.py
This commit is contained in:
@@ -117,6 +117,36 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Could not find nb"):
|
||||
manage_mcp_service.find_nb_path()
|
||||
|
||||
def test_generated_env_has_size_but_no_upload_jwt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = Path(tmp) / "mcp.env"
|
||||
|
||||
manage_mcp_service.write_token_env_file(env_path, "token")
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_MAX_BYTES=26214400\n", content)
|
||||
self.assertNotIn("NOCOBASE_MCP_UPLOAD_JWT", content)
|
||||
self.assertNotIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL", content)
|
||||
|
||||
def test_token_rotation_preserves_operator_credentials(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_path = Path(tmp) / "mcp.env"
|
||||
env_path.write_text(
|
||||
"NOCOBASE_MCP_TOKEN=old-token\n"
|
||||
"NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api\n"
|
||||
"NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manage_mcp_service.write_token_env_file(env_path, "new-token", nb_bin="/opt/node/bin/nb")
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_NB_BIN=/opt/node/bin/nb\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_MAX_BYTES=26214400\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n", content)
|
||||
|
||||
def test_render_remote_config_prints_url_and_authorization_header(self) -> None:
|
||||
output = manage_mcp_service.render_remote_config(
|
||||
hosts=["192.168.1.10"],
|
||||
@@ -155,6 +185,36 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("http://10.0.0.2:10087/mcp", output)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_new_token_preserves_operator_upload_settings_and_other_env_lines(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
|
||||
manage_mcp_service.write_token_env_file(env_path, "old-token")
|
||||
with env_path.open("a", encoding="utf-8") as env_file:
|
||||
env_file.write("# operator-managed upload credentials\n")
|
||||
env_file.write("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n")
|
||||
env_file.write("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n")
|
||||
env_file.write("EXTRA_OPERATOR_SETTING=preserve-me\n")
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
|
||||
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
|
||||
):
|
||||
manage_mcp_service.new_token(project_root=project_root)
|
||||
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", content)
|
||||
self.assertIn("# operator-managed upload credentials\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n", content)
|
||||
self.assertIn("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n", content)
|
||||
self.assertIn("EXTRA_OPERATOR_SETTING=preserve-me\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
run_systemctl.assert_not_called()
|
||||
|
||||
def test_start_service_uses_saved_token_and_restarts_service(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
|
||||
@@ -99,6 +99,77 @@ class NbClientTests(unittest.TestCase):
|
||||
self.assertEqual(calls[0]["cmd"][0], "/opt/node/bin/nb")
|
||||
self.assertEqual(calls[0]["env"]["PATH"], "/opt/node/bin:/usr/bin")
|
||||
|
||||
def test_create_record_uses_values_and_whitelist(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
client.create_record("t_projects", {"f_project_name": "EV inverter"}, ["f_project_name"])
|
||||
|
||||
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "create"])
|
||||
self.assertEqual(
|
||||
json.loads(commands[0][commands[0].index("--values") + 1]),
|
||||
{"f_project_name": "EV inverter"},
|
||||
)
|
||||
self.assertEqual(commands[0][commands[0].index("--whitelist") + 1], "f_project_name")
|
||||
|
||||
def test_update_record_uses_filter_by_tk(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{"id":7}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
client.update_record("t_projects", 7, {"client_ID": 3}, ["client_ID"])
|
||||
|
||||
self.assertEqual(commands[0][:4], ["nb", "api", "resource", "update"])
|
||||
self.assertEqual(commands[0][commands[0].index("--filter-by-tk") + 1], "7")
|
||||
|
||||
def test_record_writes_reject_empty_values_without_running_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
client.create_record("t_projects", {}, ["f_project_name"])
|
||||
with self.assertRaises(ValueError):
|
||||
client.update_record("t_projects", 7, {}, ["f_project_name"])
|
||||
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_record_writes_reject_nested_values_without_running_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(cmd, **kwargs):
|
||||
commands.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout='{"data":{}}', stderr="")
|
||||
|
||||
client = NbClient(env_name="nb-mcp", runner=runner)
|
||||
nested_values = [
|
||||
{"client": {"id": 3}},
|
||||
{"client_ids": [3]},
|
||||
{"client_ids": {3}},
|
||||
{"client_ids": (3,)},
|
||||
]
|
||||
|
||||
for values in nested_values:
|
||||
with self.subTest(operation="create", values=values):
|
||||
with self.assertRaises(ValueError):
|
||||
client.create_record("t_projects", values, list(values))
|
||||
with self.subTest(operation="update", values=values):
|
||||
with self.assertRaises(ValueError):
|
||||
client.update_record("t_projects", 7, values, list(values))
|
||||
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_list_collection_fields_uses_data_modeling_command(self) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import httpx
|
||||
|
||||
from nocobase_crm_mcp.config import load_config
|
||||
from nocobase_crm_mcp.nocobase_http import NocoBaseHttpClient, NocoBaseHttpError
|
||||
|
||||
|
||||
class ProjectConfigTests(unittest.TestCase):
|
||||
def test_load_config_parses_project_defaults(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.json"
|
||||
path.write_text(json.dumps({"projects": {"resource": "t_projects"}}), encoding="utf-8")
|
||||
|
||||
config = load_config(path)
|
||||
|
||||
self.assertEqual(config.project.resource, "t_projects")
|
||||
self.assertEqual(config.project.name_field, "f_project_name")
|
||||
self.assertEqual(config.project.customer_field, "client_ID")
|
||||
self.assertEqual(config.project.inquiry_date_field, "xm_project_inquiry_date")
|
||||
self.assertEqual(config.project.progress_field, "f_projects_progress")
|
||||
self.assertEqual(config.project.applications_field, "f_projects_applications")
|
||||
self.assertEqual(config.project.part_numbers_field, "f_projects_part_numbers")
|
||||
self.assertEqual(config.project.period_usage_field, "f_projects_period_usage")
|
||||
self.assertEqual(config.project.details_field, "f_projects_details")
|
||||
self.assertEqual(config.project.attachment_field, "f_projects_attachments")
|
||||
self.assertEqual(config.project.progress_values["SOP"], "a9t9awhtt3r")
|
||||
|
||||
|
||||
class NocoBaseHttpClientTests(unittest.TestCase):
|
||||
def test_upload_posts_file_with_attachment_field(self) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers["authorization"]
|
||||
return httpx.Response(200, json={"data": {"id": 42}})
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "quote.pdf"
|
||||
path.write_bytes(b"pdf")
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api/", "jwt", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
self.assertEqual(client.upload_attachment(path, "quote.pdf", "application/pdf")["id"], 42)
|
||||
|
||||
self.assertTrue(seen["url"].endswith("/attachments:create?attachmentField=t_projects.f_projects_attachments"))
|
||||
self.assertEqual(seen["auth"], "Bearer jwt")
|
||||
|
||||
def test_attachment_associations_post_ids_to_fixed_relation(self) -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(204)
|
||||
|
||||
client = NocoBaseHttpClient("https://nb.example/api", "jwt", transport=httpx.MockTransport(handler))
|
||||
client.add_attachment_ids(7, [8, 9])
|
||||
client.remove_attachment_ids(7, [10])
|
||||
|
||||
self.assertEqual(str(requests[0].url), "https://nb.example/api/t_projects/7/f_projects_attachments:add")
|
||||
self.assertEqual(json.loads(requests[0].content), {"tk": [8, 9]})
|
||||
self.assertEqual(str(requests[1].url), "https://nb.example/api/t_projects/7/f_projects_attachments:remove")
|
||||
self.assertEqual(json.loads(requests[1].content), {"tk": [10]})
|
||||
|
||||
def test_attachment_association_encodes_project_id_as_one_path_segment(self) -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(204)
|
||||
|
||||
client = NocoBaseHttpClient("https://nb.example/api", "jwt", transport=httpx.MockTransport(handler))
|
||||
client.add_attachment_ids("../project/7?#", [8])
|
||||
|
||||
self.assertEqual(
|
||||
str(requests[0].url),
|
||||
"https://nb.example/api/t_projects/..%2Fproject%2F7%3F%23/f_projects_attachments:add",
|
||||
)
|
||||
|
||||
def test_attachment_association_rejects_non_scalar_project_id_before_request(self) -> None:
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api",
|
||||
"jwt",
|
||||
transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "project_id"):
|
||||
client.add_attachment_ids({"id": 7}, [8])
|
||||
|
||||
def test_attachment_association_rejects_dot_path_project_ids_before_request(self) -> None:
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api",
|
||||
"jwt",
|
||||
transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")),
|
||||
)
|
||||
|
||||
for project_id in (".", ".."):
|
||||
with self.subTest(project_id=project_id), self.assertRaisesRegex(ValueError, "project_id"):
|
||||
client.add_attachment_ids(project_id, [8])
|
||||
|
||||
def test_rejects_blank_upload_credentials(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
NocoBaseHttpClient(" ", "jwt")
|
||||
with self.assertRaises(ValueError):
|
||||
NocoBaseHttpClient("https://nb.example/api", " ")
|
||||
|
||||
def test_http_error_redacts_jwt(self) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, text="Bearer super-secret-token is invalid")
|
||||
|
||||
client = NocoBaseHttpClient(
|
||||
"https://nb.example/api", "super-secret-token", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
with self.assertRaises(NocoBaseHttpError) as caught:
|
||||
client.add_attachment_ids(7, [8])
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 401)
|
||||
self.assertNotIn("super-secret-token", str(caught.exception))
|
||||
self.assertIn("<redacted>", str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,209 @@
|
||||
import unittest
|
||||
|
||||
from nocobase_crm_mcp.config import ProjectConfig
|
||||
from nocobase_crm_mcp.nocobase_http import NocoBaseHttpError
|
||||
from nocobase_crm_mcp.projects import PROJECT_WHITELIST, ProjectService
|
||||
|
||||
|
||||
class FakeNbClient:
|
||||
def __init__(self) -> None:
|
||||
self.create_calls: list[tuple[str, dict, tuple[str, ...]]] = []
|
||||
self.update_calls: list[tuple[str, object, dict, tuple[str, ...]]] = []
|
||||
|
||||
def create_record(self, resource, values, whitelist):
|
||||
self.create_calls.append((resource, dict(values), tuple(whitelist)))
|
||||
return {"data": {"id": 41}}
|
||||
|
||||
def update_record(self, resource, project_id, values, whitelist):
|
||||
self.update_calls.append((resource, project_id, dict(values), tuple(whitelist)))
|
||||
return {"data": {"id": project_id}}
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.add_calls: list[tuple[object, list[object]]] = []
|
||||
self.remove_calls: list[tuple[object, list[object]]] = []
|
||||
self.add_error: Exception | None = None
|
||||
self.remove_error: Exception | None = None
|
||||
|
||||
def add_attachment_ids(self, project_id, attachment_ids) -> None:
|
||||
self.add_calls.append((project_id, list(attachment_ids)))
|
||||
if self.add_error:
|
||||
raise self.add_error
|
||||
|
||||
def remove_attachment_ids(self, project_id, attachment_ids) -> None:
|
||||
self.remove_calls.append((project_id, list(attachment_ids)))
|
||||
if self.remove_error:
|
||||
raise self.remove_error
|
||||
|
||||
|
||||
class ProjectServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.nb = FakeNbClient()
|
||||
self.http = FakeHttpClient()
|
||||
self.config = ProjectConfig()
|
||||
self.service = ProjectService(self.config, self.nb, self.http)
|
||||
|
||||
def test_create_maps_fixed_fields_then_adds_attachment_ids(self) -> None:
|
||||
result = self.service.create_project(
|
||||
name=" EV inverter ", customer_id=3, progress="SOP", attachment_ids=[8, 9]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.nb.create_calls,
|
||||
[
|
||||
(
|
||||
"t_projects",
|
||||
{
|
||||
"f_project_name": "EV inverter",
|
||||
"client_ID": 3,
|
||||
"f_projects_progress": "a9t9awhtt3r",
|
||||
},
|
||||
PROJECT_WHITELIST,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.http.add_calls, [(41, [8, 9])])
|
||||
self.assertEqual(result["project_id"], 41)
|
||||
|
||||
def test_whitelist_contains_only_the_configured_scalar_fields(self) -> None:
|
||||
self.service.create_project(name="x")
|
||||
|
||||
self.assertEqual(
|
||||
PROJECT_WHITELIST,
|
||||
(
|
||||
self.config.name_field,
|
||||
self.config.customer_field,
|
||||
self.config.inquiry_date_field,
|
||||
self.config.progress_field,
|
||||
self.config.applications_field,
|
||||
self.config.part_numbers_field,
|
||||
self.config.period_usage_field,
|
||||
self.config.details_field,
|
||||
),
|
||||
)
|
||||
self.assertNotIn(self.config.attachment_field, PROJECT_WHITELIST)
|
||||
|
||||
def test_update_rejects_overlapping_attachment_lists_before_writing(self) -> None:
|
||||
result = self.service.update_project(41, add_attachment_ids=[8], remove_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
self.assertEqual(self.http.remove_calls, [])
|
||||
|
||||
def test_invalid_date_progress_and_blank_name_have_stable_codes(self) -> None:
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", inquiry_date="2026/07/12")["error"]["code"],
|
||||
"invalid_project_input",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", inquiry_date="20260712")["error"]["code"],
|
||||
"invalid_project_input",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.service.create_project(name="x", progress="草稿")["error"]["code"],
|
||||
"invalid_progress",
|
||||
)
|
||||
self.assertEqual(self.service.create_project(name=" \t ")["error"]["code"], "invalid_project_input")
|
||||
|
||||
def test_update_rejects_an_empty_request_before_writing(self) -> None:
|
||||
result = self.service.update_project(41)
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
|
||||
def test_create_rejects_nested_scalar_inputs_before_writing(self) -> None:
|
||||
cases = {
|
||||
"name": {"name": "x"},
|
||||
"customer_id": {"id": 3},
|
||||
"inquiry_date": ["2026-07-12"],
|
||||
"progress": ("SOP",),
|
||||
"applications": ["EV"],
|
||||
"part_numbers": ("123",),
|
||||
"period_usage": {"annual"},
|
||||
"details": {"note": "x"},
|
||||
}
|
||||
|
||||
for field, value in cases.items():
|
||||
with self.subTest(field=field):
|
||||
self.setUp()
|
||||
arguments = {"name": "x", **{field: value}}
|
||||
result = self.service.create_project(**arguments)
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.create_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_rejects_nested_project_id_before_any_client_call(self) -> None:
|
||||
for project_id in ({"id": 41}, [41], {41}, (41,)):
|
||||
with self.subTest(project_id=project_id):
|
||||
self.setUp()
|
||||
result = self.service.update_project(project_id, name="x", add_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_rejects_dot_path_project_id_before_any_client_call(self) -> None:
|
||||
for project_id in (".", ".."):
|
||||
with self.subTest(project_id=project_id):
|
||||
self.setUp()
|
||||
result = self.service.update_project(project_id, name="x", add_attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.update_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_create_rejects_nested_attachment_id_before_any_client_call(self) -> None:
|
||||
result = self.service.create_project(name="x", attachment_ids=[{"id": 8}])
|
||||
|
||||
self.assertEqual(result["error"]["code"], "invalid_project_input")
|
||||
self.assertEqual(self.nb.create_calls, [])
|
||||
self.assertEqual(self.http.add_calls, [])
|
||||
|
||||
def test_update_writes_scalars_then_deduplicated_attachment_changes_in_order(self) -> None:
|
||||
result = self.service.update_project(
|
||||
41,
|
||||
inquiry_date="2026-07-12",
|
||||
add_attachment_ids=[8, 8, 9],
|
||||
remove_attachment_ids=[10, 10],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.nb.update_calls,
|
||||
[
|
||||
(
|
||||
"t_projects",
|
||||
41,
|
||||
{"xm_project_inquiry_date": "2026-07-12"},
|
||||
PROJECT_WHITELIST,
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.http.add_calls, [(41, [8, 9])])
|
||||
self.assertEqual(self.http.remove_calls, [(41, [10])])
|
||||
self.assertEqual(result, {"project_id": 41})
|
||||
|
||||
def test_association_errors_keep_project_id_and_map_to_stable_codes(self) -> None:
|
||||
cases = [
|
||||
(404, "attachment_not_found"),
|
||||
(401, "nb_upload_unauthorized"),
|
||||
(403, "nb_upload_unauthorized"),
|
||||
(500, "nb_upload_failed"),
|
||||
]
|
||||
|
||||
for status_code, error_code in cases:
|
||||
with self.subTest(status_code=status_code):
|
||||
self.setUp()
|
||||
self.http.add_error = NocoBaseHttpError(status_code, "attachment request failed")
|
||||
|
||||
result = self.service.create_project(name="x", attachment_ids=[8])
|
||||
|
||||
self.assertEqual(result["project_id"], 41)
|
||||
self.assertEqual(result["error"]["code"], error_code)
|
||||
self.assertEqual(len(self.nb.create_calls), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+84
-1
@@ -2,9 +2,21 @@ import asyncio
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nocobase_crm_mcp import server as server_module
|
||||
from nocobase_crm_mcp.nb_client import NbCommandError
|
||||
from nocobase_crm_mcp.server import BearerTokenAuthMiddleware, FastMCP, create_http_app, create_server
|
||||
from nocobase_crm_mcp.server import (
|
||||
DEFAULT_HTTP_HOST,
|
||||
DEFAULT_HTTP_PATH,
|
||||
DEFAULT_HTTP_PORT,
|
||||
BearerTokenAuthMiddleware,
|
||||
FastMCP,
|
||||
create_http_app,
|
||||
create_server,
|
||||
run_http_server,
|
||||
)
|
||||
|
||||
|
||||
async def _ok_app(scope, receive, send):
|
||||
@@ -71,6 +83,19 @@ class MissingNbCrmService(FakeCrmService):
|
||||
raise FileNotFoundError(2, "No such file or directory", "nb")
|
||||
|
||||
|
||||
class FakeProjectService:
|
||||
def create_project(self, **kwargs):
|
||||
return {"project_id": 1}
|
||||
|
||||
def update_project(self, project_id, **kwargs):
|
||||
return {"project_id": project_id}
|
||||
|
||||
|
||||
class FakeUploadEndpoint:
|
||||
async def __call__(self, request):
|
||||
return JSONResponse({"uploaded": True})
|
||||
|
||||
|
||||
class ServerTests(unittest.TestCase):
|
||||
def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None:
|
||||
app = BearerTokenAuthMiddleware(_ok_app, "secret")
|
||||
@@ -115,6 +140,21 @@ class ServerTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_write_server_exposes_fixed_project_tools(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(
|
||||
crm_service=FakeCrmService(),
|
||||
project_service=FakeProjectService(),
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
names = {tool.name for tool in asyncio.run(server.list_tools())}
|
||||
|
||||
self.assertTrue({"create_project", "update_project"}.issubset(names))
|
||||
self.assertNotIn("destroy_project", names)
|
||||
|
||||
def test_tool_schemas_expose_raw_opt_in_parameters(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
@@ -162,6 +202,49 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertIs(service["client"], nb_client.return_value)
|
||||
nb_client.assert_called_once_with(env_name="nb-mcp", data_source="main", nb_bin="/opt/node/bin/nb")
|
||||
|
||||
def test_upload_route_requires_the_same_bearer_token(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(crm_service=FakeCrmService(), readonly=True)
|
||||
app = create_http_app(server, "secret", upload_endpoint=FakeUploadEndpoint())
|
||||
|
||||
self.assertEqual(run_asgi_request(app, path="/uploads")[0]["status"], 401)
|
||||
|
||||
def test_upload_route_accepts_the_matching_bearer_token(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
server = create_server(crm_service=FakeCrmService(), readonly=True)
|
||||
|
||||
async def upload_endpoint(request):
|
||||
return JSONResponse({"uploaded": True})
|
||||
|
||||
app = create_http_app(server, "secret", upload_endpoint=upload_endpoint)
|
||||
|
||||
async def request_upload() -> httpx.Response:
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client:
|
||||
return await client.post("/uploads", headers={"Authorization": "Bearer secret"})
|
||||
|
||||
response = asyncio.run(request_upload())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"uploaded": True})
|
||||
|
||||
def test_http_write_mode_requires_upload_api_base_url(self) -> None:
|
||||
with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True):
|
||||
with patch("uvicorn.run") as uvicorn_run:
|
||||
with self.assertRaisesRegex(RuntimeError, "NOCOBASE_MCP_UPLOAD_API_BASE_URL"):
|
||||
run_http_server(
|
||||
config_path=None,
|
||||
host=DEFAULT_HTTP_HOST,
|
||||
port=DEFAULT_HTTP_PORT,
|
||||
token_env="NOCOBASE_MCP_TOKEN",
|
||||
streamable_http_path=DEFAULT_HTTP_PATH,
|
||||
allowed_hosts=None,
|
||||
allowed_origins=None,
|
||||
)
|
||||
uvicorn_run.assert_not_called()
|
||||
|
||||
def test_config_template_tool_is_opt_in(self) -> None:
|
||||
if FastMCP is None:
|
||||
self.skipTest("MCP SDK is not installed")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import gc
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
from nocobase_crm_mcp.nocobase_http import NocoBaseHttpError
|
||||
from nocobase_crm_mcp.uploads import UploadEndpoint
|
||||
|
||||
|
||||
AUTH = {"Authorization": "Bearer secret"}
|
||||
|
||||
|
||||
class FakeHttpClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Path, str, str | None, bytes]] = []
|
||||
self.error: Exception | None = None
|
||||
|
||||
def upload_attachment(self, path: Path, filename: str, mimetype: str | None) -> dict[str, object]:
|
||||
self.calls.append((path, filename, mimetype, path.read_bytes()))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return {"id": 42}
|
||||
|
||||
|
||||
class UploadEndpointTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def _client_for(
|
||||
self,
|
||||
http_client: FakeHttpClient,
|
||||
temp_dir: str,
|
||||
*,
|
||||
max_bytes: int = 5,
|
||||
allowed_mime_types: tuple[str, ...] | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
endpoint = UploadEndpoint(http_client, max_bytes, temp_dir, allowed_mime_types)
|
||||
|
||||
async def upload_route(request):
|
||||
return await endpoint(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/uploads",
|
||||
upload_route,
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
]
|
||||
)
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
|
||||
|
||||
async def test_upload_returns_nocobase_attachment_id_and_removes_temp_file(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{"upload_id": 42, "filename": "quote.pdf", "size": 3, "mimetype": "application/pdf"},
|
||||
)
|
||||
self.assertEqual(http_client.calls[0][1:], ("quote.pdf", "application/pdf", b"pdf"))
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_rejects_missing_multiple_and_over_limit_files(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always", ResourceWarning)
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
missing = await client.post("/uploads", headers=AUTH, files={})
|
||||
multiple = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files=[
|
||||
("file", ("one.pdf", b"one", "application/pdf")),
|
||||
("file", ("two.pdf", b"two", "application/pdf")),
|
||||
],
|
||||
)
|
||||
over_limit = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("big.bin", b"x" * 6, "application/octet-stream")},
|
||||
)
|
||||
temp_paths = list(Path(temp_dir).iterdir())
|
||||
gc.collect()
|
||||
|
||||
self.assertEqual(missing.status_code, 400)
|
||||
self.assertEqual(missing.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(multiple.status_code, 400)
|
||||
self.assertEqual(multiple.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(over_limit.status_code, 413)
|
||||
self.assertEqual(over_limit.json()["error"]["code"], "upload_too_large")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(temp_paths, [])
|
||||
self.assertFalse(any(issubclass(warning.category, ResourceWarning) for warning in caught))
|
||||
|
||||
async def test_upload_rejects_non_file_form_fields(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
data={"description": "quote"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_rejects_oversized_misreported_chunked_body_before_multipart_parsing(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
endpoint = UploadEndpoint(http_client, max_bytes=5, temp_dir="/tmp")
|
||||
parsed = False
|
||||
|
||||
async def forbidden_form(self):
|
||||
nonlocal parsed
|
||||
parsed = True
|
||||
self.fail("multipart parsing must not begin for an oversized body")
|
||||
|
||||
body = b"x" * (5 + 65537)
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/uploads",
|
||||
"headers": [
|
||||
(b"content-type", b"multipart/form-data; boundary=test"),
|
||||
(b"content-length", b"1"),
|
||||
],
|
||||
}
|
||||
messages = [
|
||||
{"type": "http.request", "body": body[:10], "more_body": True},
|
||||
{"type": "http.request", "body": body[10:], "more_body": False},
|
||||
]
|
||||
|
||||
async def receive():
|
||||
return messages.pop(0)
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
starlette_request = Request(scope, receive=receive)
|
||||
with patch("nocobase_crm_mcp.uploads.Request.form", new=forbidden_form):
|
||||
response = await endpoint(starlette_request)
|
||||
|
||||
self.assertEqual(response.status_code, 413)
|
||||
self.assertFalse(parsed)
|
||||
self.assertEqual(http_client.calls, [])
|
||||
|
||||
async def test_upload_rejects_disallowed_mime_type_before_calling_nocobase(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(
|
||||
http_client, temp_dir, allowed_mime_types=("image/*", "application/pdf")
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("notes.txt", b"notes", "text/plain")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 415)
|
||||
self.assertEqual(response.json()["error"]["code"], "unsupported_media_type")
|
||||
self.assertEqual(http_client.calls, [])
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_maps_upstream_errors_and_removes_temp_file(self) -> None:
|
||||
cases = ((401, "nb_upload_unauthorized"), (403, "nb_upload_unauthorized"), (500, "nb_upload_failed"))
|
||||
|
||||
for status_code, error_code in cases:
|
||||
with self.subTest(status_code=status_code), TemporaryDirectory() as temp_dir:
|
||||
http_client = FakeHttpClient()
|
||||
http_client.error = NocoBaseHttpError(status_code, "upstream failure")
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
response = await client.post(
|
||||
"/uploads",
|
||||
headers=AUTH,
|
||||
files={"file": ("quote.pdf", b"pdf", "application/pdf")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.json()["error"]["code"], error_code)
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
async def test_upload_requires_post_and_multipart_form_data(self) -> None:
|
||||
http_client = FakeHttpClient()
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
async with await self._client_for(http_client, temp_dir) as client:
|
||||
get_response = await client.get("/uploads", headers=AUTH)
|
||||
json_response = await client.post("/uploads", headers=AUTH, json={"file": "not-a-file"})
|
||||
|
||||
self.assertEqual(get_response.status_code, 405)
|
||||
self.assertEqual(json_response.status_code, 400)
|
||||
self.assertEqual(json_response.json()["error"]["code"], "invalid_upload")
|
||||
self.assertEqual(list(Path(temp_dir).iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user