Files
nocobase-mcp/tests/test_nocobase_http.py
T

269 lines
12 KiB
Python

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("<redacted>", 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()