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: list[tuple[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append((str(request.url), request.headers["authorization"])) if request.url.path.endswith("/roles:check"): return httpx.Response( 200, json={"data": {"strategy": {"actions": ["view", "create"]}, "resources": []}}, ) 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[0][0].endswith("/roles:check")) self.assertTrue(seen[1][0].endswith("/attachments:create?attachmentField=t_projects.f_projects_attachments")) self.assertEqual([auth for _url, auth in seen], ["Bearer jwt", "Bearer jwt"]) def test_upload_accepts_collection_specific_project_update_permission(self) -> None: requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) if request.url.path.endswith("/roles:check"): return httpx.Response( 200, json={ "data": { "strategy": {"actions": ["view:own"]}, "resources": ["t_projects"], "actions": {"t_projects:update": {}}, } }, ) 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.assertEqual(len(requests), 2) def test_upload_rejects_role_without_project_write_before_attachment_create(self) -> None: requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response( 200, json={"data": {"strategy": {"actions": ["view:own", "trigger:own"]}, "resources": []}}, ) 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)) with self.assertRaises(NocoBaseHttpError) as caught: client.upload_attachment(path, "quote.pdf", "application/pdf") self.assertEqual(caught.exception.status_code, 403) self.assertFalse(caught.exception.authentication_failed) self.assertEqual([request.url.path for request in requests], ["/api/roles:check"]) def test_upload_accepts_root_role_and_main_data_source_acl(self) -> None: role_payloads = ( {"data": {"allowAll": True}}, { "data": {"strategy": {"actions": ["view:own"]}, "resources": []}, "meta": { "dataSources": { "main": { "strategy": {"actions": ["view", "update:own"]}, "resources": [], } } }, }, ) for role_payload in role_payloads: with self.subTest(role_payload=role_payload), TemporaryDirectory() as directory: path = Path(directory) / "quote.pdf" path.write_bytes(b"pdf") def handler(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/roles:check"): return httpx.Response(200, json=role_payload) return httpx.Response(200, json={"data": {"id": 42}}) client = NocoBaseHttpClient("https://nb.example/api", "jwt", transport=httpx.MockTransport(handler)) self.assertEqual(client.upload_attachment(path, "quote.pdf", "application/pdf")["id"], 42) 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_credential_provider_is_resolved_per_request_for_local_users(self) -> None: requests: list[httpx.Request] = [] credentials = iter( [ ("https://nb.example/api", "first-user-token"), ("https://nb.example/api", "refreshed-user-token"), ] ) def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(204) client = NocoBaseHttpClient( credential_provider=lambda: next(credentials), transport=httpx.MockTransport(handler), ) client.add_attachment_ids(7, [8]) client.remove_attachment_ids(7, [9]) self.assertEqual(requests[0].headers["authorization"], "Bearer first-user-token") self.assertEqual(requests[1].headers["authorization"], "Bearer refreshed-user-token") def test_credential_provider_failure_is_reported_as_authentication_failure(self) -> None: def missing_login() -> tuple[str, str]: raise RuntimeError("run nb env auth customer-prod") client = NocoBaseHttpClient( credential_provider=missing_login, transport=httpx.MockTransport(lambda request: self.fail("request must not be sent")), ) with self.assertRaises(NocoBaseHttpError) as caught: client.add_attachment_ids(7, [8]) self.assertTrue(caught.exception.authentication_failed) self.assertIn("nb env auth customer-prod", str(caught.exception)) 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("", str(caught.exception)) def test_http_error_distinguishes_invalid_key_from_acl_forbidden(self) -> None: responses = ( (httpx.Response(401, json={"errors": [{"code": "INVALID_TOKEN"}]}), True), (httpx.Response(403, json={"errors": [{"code": "EXPIRED_TOKEN"}]}), True), (httpx.Response(403, json={"errors": [{"code": "NO_PERMISSIONS"}]}), False), ) for response, authentication_failed in responses: with self.subTest(status=response.status_code, authentication_failed=authentication_failed): client = NocoBaseHttpClient( "https://nb.example/api", "api-key", transport=httpx.MockTransport(lambda request, response=response: response), ) with self.assertRaises(NocoBaseHttpError) as caught: client.add_attachment_ids(7, [8]) self.assertEqual(caught.exception.authentication_failed, authentication_failed) if __name__ == "__main__": unittest.main()