@dpantani/tdmcp 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ {
2
+ "id": "pose_skeleton_mediapipe",
3
+ "name": "Pose Skeleton (MediaPipe)",
4
+ "description": "A live stick-figure skeleton drawn from the 33 MediaPipe body landmarks (the classic body-tracking look). Point the 'posein' Select CHOP at the pose-landmarks CHOP from the free torinmb/mediapipe-touchdesigner plugin. Webcam only — no Kinect or extra hardware. Or use create_pose_skeleton for a self-contained synthetic preview.",
5
+ "tags": ["mediapipe", "pose", "skeleton", "body-tracking", "camera"],
6
+ "difficulty": "advanced",
7
+ "td_version_min": "2022",
8
+ "nodes": [
9
+ {
10
+ "name": "posein",
11
+ "type": "selectCHOP",
12
+ "comment": "Set 'chops' to the MediaPipe plugin's pose-landmarks CHOP (33 samples, tx/ty/tz)"
13
+ },
14
+ { "name": "geo", "type": "geometryCOMP" },
15
+ {
16
+ "name": "skeleton",
17
+ "type": "scriptSOP",
18
+ "parent": "geo",
19
+ "render": true,
20
+ "comment": "onCook builds a point per landmark + a polyline per bone"
21
+ },
22
+ { "name": "skel_cb", "type": "textDAT", "parent": "geo", "comment": "Script SOP callbacks" },
23
+ {
24
+ "name": "wire",
25
+ "type": "lineMAT",
26
+ "parameters": {
27
+ "linenearcolorr": 0.2,
28
+ "linenearcolorg": 1.0,
29
+ "linenearcolorb": 0.9,
30
+ "widthnear": 3
31
+ }
32
+ },
33
+ { "name": "cam", "type": "cameraCOMP", "parameters": { "tz": 4.6 } },
34
+ {
35
+ "name": "render",
36
+ "type": "renderTOP",
37
+ "parameters": {
38
+ "outputresolution": "custom",
39
+ "resolutionw": 1280,
40
+ "resolutionh": 720,
41
+ "antialias": "3"
42
+ }
43
+ },
44
+ { "name": "out1", "type": "nullTOP" }
45
+ ],
46
+ "connections": [{ "from": "render", "to": "out1" }],
47
+ "parameters": [
48
+ { "name": "callbacks", "node": "skeleton", "param": "callbacks", "value": "skel_cb" },
49
+ { "name": "material", "node": "geo", "param": "material", "value": "wire" },
50
+ { "name": "geometry", "node": "render", "param": "geometry", "value": "geo" },
51
+ { "name": "camera", "node": "render", "param": "camera", "value": "cam" }
52
+ ],
53
+ "python_code": {
54
+ "skel_cb": "BONES = [[0,1],[1,2],[2,3],[3,7],[0,4],[4,5],[5,6],[6,8],[9,10],[11,12],[11,13],[13,15],[15,17],[15,19],[15,21],[17,19],[12,14],[14,16],[16,18],[16,20],[16,22],[18,20],[11,23],[12,24],[23,24],[23,25],[25,27],[27,29],[27,31],[29,31],[24,26],[26,28],[28,30],[28,32],[30,32]]\n\ndef onCook(scriptOp):\n scriptOp.clear()\n pose = op('../posein')\n if pose is None or pose.numChans < 3 or pose.numSamples < 1:\n return\n tx = pose['tx']; ty = pose['ty']; tz = pose['tz']\n if tx is None or ty is None or tz is None:\n return\n n = pose.numSamples\n pts = []\n for i in range(n):\n p = scriptOp.appendPoint()\n p.x = float(tx[i]); p.y = float(ty[i]); p.z = float(tz[i])\n pts.append(p)\n for a, b in BONES:\n if a < n and b < n:\n poly = scriptOp.appendPoly(2, closed=False, addPoints=False)\n poly[0].point = pts[a]\n poly[1].point = pts[b]\n return\n"
55
+ },
56
+ "controls": [
57
+ {
58
+ "name": "LineWidth",
59
+ "type": "float",
60
+ "min": 0,
61
+ "max": 20,
62
+ "default": 3,
63
+ "bind_to": ["wire.widthnear"]
64
+ }
65
+ ],
66
+ "preview_description": "A glowing cyan skeleton mirroring the performer's body in real time against black."
67
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { createWriteStream, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { finished } from "node:stream/promises";
6
+
7
+ const DEFAULT_URL =
8
+ "https://github.com/shader-park/shader-park-touchdesigner/releases/latest/download/Shader_Park_TD.tox";
9
+ const DEFAULT_OUT = "vendor/shader-park/Shader_Park_TD.tox";
10
+
11
+ function usage() {
12
+ return [
13
+ "Download the official Shader Park TouchDesigner .tox plugin.",
14
+ "",
15
+ "Usage:",
16
+ " npm run shader-park:tox",
17
+ " node scripts/fetch-shader-park-td.mjs --out vendor/shader-park/Shader_Park_TD.tox",
18
+ "",
19
+ "Options:",
20
+ " --out <path> Destination path. Defaults to vendor/shader-park/Shader_Park_TD.tox",
21
+ " --url <url> Override the release asset URL.",
22
+ " -h, --help Show this help.",
23
+ ].join("\n");
24
+ }
25
+
26
+ function parseArgs(argv) {
27
+ const args = { out: DEFAULT_OUT, url: DEFAULT_URL, help: false };
28
+ for (let i = 0; i < argv.length; i += 1) {
29
+ const arg = argv[i];
30
+ if (arg === "-h" || arg === "--help") {
31
+ args.help = true;
32
+ } else if (arg === "--out") {
33
+ const value = argv[i + 1];
34
+ if (!value) throw new Error("--out requires a path");
35
+ args.out = value;
36
+ i += 1;
37
+ } else if (arg === "--url") {
38
+ const value = argv[i + 1];
39
+ if (!value) throw new Error("--url requires a URL");
40
+ args.url = value;
41
+ i += 1;
42
+ } else {
43
+ throw new Error(`Unknown argument: ${arg}`);
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+
49
+ async function download(url, outPath) {
50
+ const response = await fetch(url, {
51
+ headers: { "user-agent": "tdmcp-fetch-shader-park-td" },
52
+ redirect: "follow",
53
+ });
54
+ if (!response.ok || !response.body) {
55
+ throw new Error(`Download failed: ${response.status} ${response.statusText}`);
56
+ }
57
+
58
+ mkdirSync(dirname(outPath), { recursive: true });
59
+ const file = createWriteStream(outPath);
60
+ await finished(Readable.fromWeb(response.body).pipe(file));
61
+ return Number(response.headers.get("content-length") ?? 0);
62
+ }
63
+
64
+ try {
65
+ const args = parseArgs(process.argv.slice(2));
66
+ if (args.help) {
67
+ console.log(usage());
68
+ process.exit(0);
69
+ }
70
+ const outPath = resolve(args.out);
71
+ const bytes = await download(args.url, outPath);
72
+ const size = bytes > 0 ? ` (${bytes} bytes)` : "";
73
+ console.log(`Downloaded Shader_Park_TD.tox to ${outPath}${size}`);
74
+ } catch (error) {
75
+ console.error(error instanceof Error ? error.message : String(error));
76
+ console.error("");
77
+ console.error(usage());
78
+ process.exit(1);
79
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for dotsimulate's LOPs "MCP Client": injects hardening env, then
3
+ // execs the tdmcp stdio server. LOPs' servers_config.json has no documented
4
+ // `env` field, so point its `command` at this file instead.
5
+ //
6
+ // command: "node"
7
+ // args: ["/abs/path/to/tdmcp/scripts/tdmcp-lops.mjs"]
8
+ //
9
+ // Sets TDMCP_RAW_PYTHON=off and TDMCP_TOOL_PROFILE=safe so an autonomous in-TD
10
+ // agent gets the curated, non-destructive tool surface. These are FORCED
11
+ // (override anything inherited from the parent) — hardening must win.
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18
+ const entry = resolve(root, "dist", "index.js");
19
+
20
+ if (!existsSync(entry)) {
21
+ // stderr ONLY — stdout is the MCP stdio channel and must stay clean.
22
+ process.stderr.write(
23
+ `[tdmcp-lops] ${entry} not found. Run \`npm run build\` in ${root} first.\n`,
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ const env = {
29
+ ...process.env,
30
+ TDMCP_RAW_PYTHON: "off",
31
+ TDMCP_TOOL_PROFILE: "safe",
32
+ };
33
+
34
+ const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], {
35
+ stdio: "inherit", // pipe stdin/stdout/stderr straight through (MCP handshake)
36
+ env,
37
+ });
38
+
39
+ // Forward termination so the spawned server's lifecycle matches this wrapper's.
40
+ // MCP clients that stop the configured command by killing this process must not
41
+ // leave an orphaned tdmcp server holding stdio + bridge connections.
42
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
43
+ process.on(signal, () => {
44
+ child.kill(signal);
45
+ });
46
+ }
47
+
48
+ child.on("error", (err) => {
49
+ process.stderr.write(`[tdmcp-lops] failed to start tdmcp: ${err.message}\n`);
50
+ process.exit(1);
51
+ });
52
+ child.on("close", (code) => process.exit(code ?? 1));
@@ -1,3 +1,3 @@
1
1
  """Version of the tdmcp TouchDesigner bridge."""
2
2
 
3
- BRIDGE_VERSION = "0.3.0"
3
+ BRIDGE_VERSION = "0.5.0"
@@ -1,307 +0,0 @@
1
- """Unit tests for the tdmcp bridge HTTP router.
2
-
3
- The bridge normally runs inside TouchDesigner, where `td`, `op`, `app` and the
4
- operator classes are injected globals. To exercise the router's pure logic —
5
- auth, the arbitrary-code gate, request parsing, path decoding and route
6
- dispatch — off-TD, we install a stub `td` module before importing the package
7
- and replace the service layer with recorders for dispatch assertions.
8
-
9
- Run from the repo root: `python3 -m unittest discover -s td/tests`
10
- (or `npm run test:bridge`). No third-party dependencies — stdlib only.
11
- """
12
-
13
- import os
14
- import sys
15
- import types
16
- import unittest
17
- from unittest import mock
18
-
19
- # --- Make the bridge importable without TouchDesigner --------------------------
20
- _HERE = os.path.dirname(os.path.abspath(__file__))
21
- _MODULES = os.path.abspath(os.path.join(_HERE, "..", "modules"))
22
- if _MODULES not in sys.path:
23
- sys.path.insert(0, _MODULES)
24
-
25
- # `mcp.services.*` bind `op/app/project` from `td` at import time; a stub suffices.
26
- _td_stub = types.ModuleType("td")
27
- _td_stub.op = mock.MagicMock(name="op")
28
- _td_stub.app = mock.MagicMock(name="app")
29
- _td_stub.project = mock.MagicMock(name="project")
30
- sys.modules.setdefault("td", _td_stub)
31
-
32
- from mcp.controllers import api_controller as ac # noqa: E402
33
-
34
-
35
- def _clear_exec_env():
36
- os.environ.pop("TDMCP_BRIDGE_ALLOW_EXEC", None)
37
-
38
-
39
- def _clear_token_env():
40
- os.environ.pop("TDMCP_BRIDGE_TOKEN", None)
41
-
42
-
43
- class AuthTests(unittest.TestCase):
44
- def tearDown(self):
45
- _clear_token_env()
46
-
47
- def test_no_token_means_auth_off(self):
48
- _clear_token_env()
49
- # Should not raise even with no Authorization header.
50
- ac._check_auth({"method": "GET"})
51
-
52
- def test_valid_bearer_token_passes(self):
53
- os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
54
- ac._check_auth({"Authorization": "Bearer s3cret"})
55
-
56
- def test_invalid_token_raises(self):
57
- os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
58
- with self.assertRaises(PermissionError):
59
- ac._check_auth({"Authorization": "Bearer wrong"})
60
-
61
- def test_missing_header_raises_when_token_required(self):
62
- os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
63
- with self.assertRaises(PermissionError):
64
- ac._check_auth({"method": "GET"})
65
-
66
- def test_header_lookup_is_case_insensitive_and_nested(self):
67
- os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
68
- # Header nested under a 'headers' dict with odd casing — must still match.
69
- ac._check_auth({"headers": {"authorization": "Bearer s3cret"}})
70
-
71
-
72
- class ExecGateTests(unittest.TestCase):
73
- def tearDown(self):
74
- _clear_exec_env()
75
- _clear_token_env()
76
-
77
- def test_allowed_by_default(self):
78
- _clear_exec_env()
79
- self.assertTrue(ac._exec_allowed())
80
-
81
- def test_disabled_values(self):
82
- for value in ("0", "false", "FALSE", "no", "off", " Off "):
83
- os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = value
84
- self.assertFalse(ac._exec_allowed(), value)
85
-
86
- def test_enabled_values(self):
87
- for value in ("1", "true", "yes", "on", "", "anything"):
88
- os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = value
89
- self.assertTrue(ac._exec_allowed(), value)
90
-
91
- def test_route_blocks_exec_when_disabled(self):
92
- os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
93
- with self.assertRaises(PermissionError):
94
- ac._route("POST", "/api/exec", {}, {"script": "print(1)"})
95
-
96
- def test_route_blocks_node_method_when_disabled(self):
97
- os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
98
- with self.assertRaises(PermissionError):
99
- ac._route("POST", "/api/nodes/project1/geo1/method", {}, {"method": "cook"})
100
-
101
-
102
- class RoutingTests(unittest.TestCase):
103
- """Dispatch logic, with the service layer swapped for recorders."""
104
-
105
- def setUp(self):
106
- _clear_exec_env()
107
- self._saved = {
108
- "api": ac.api_service,
109
- "batch": ac.batch_service,
110
- "analysis": ac.analysis_service,
111
- "preview": ac.preview_service,
112
- }
113
- ac.api_service = mock.MagicMock(name="api_service")
114
- ac.batch_service = mock.MagicMock(name="batch_service")
115
- ac.analysis_service = mock.MagicMock(name="analysis_service")
116
- ac.preview_service = mock.MagicMock(name="preview_service")
117
-
118
- def tearDown(self):
119
- ac.api_service = self._saved["api"]
120
- ac.batch_service = self._saved["batch"]
121
- ac.analysis_service = self._saved["analysis"]
122
- ac.preview_service = self._saved["preview"]
123
-
124
- def test_get_info(self):
125
- ac._route("GET", "/api/info", {}, {})
126
- ac.api_service.get_info.assert_called_once_with()
127
-
128
- def test_create_node(self):
129
- ac._route("POST", "/api/nodes", {}, {"parent_path": "/project1", "type": "noiseTOP"})
130
- ac.api_service.create_node.assert_called_once()
131
- self.assertEqual(ac.api_service.create_node.call_args.args[0], "/project1")
132
- self.assertEqual(ac.api_service.create_node.call_args.args[1], "noiseTOP")
133
-
134
- def test_list_nodes_uses_parent_query(self):
135
- ac._route("GET", "/api/nodes", {"parent": ["/project1"]}, {})
136
- ac.api_service.get_nodes.assert_called_once_with("/project1")
137
-
138
- def test_exec_dispatch_when_allowed(self):
139
- ac._route("POST", "/api/exec", {}, {"script": "x=1", "return_output": False})
140
- ac.api_service.exec_script.assert_called_once_with("x=1", False)
141
-
142
- def test_batch_dispatch(self):
143
- ops = [{"action": "create", "parent_path": "/p", "type": "noiseTOP"}]
144
- ac._route("POST", "/api/batch", {}, {"operations": ops})
145
- ac.batch_service.run.assert_called_once_with(ops)
146
-
147
- def test_node_get_patch_delete(self):
148
- ac._route("GET", "/api/nodes/project1/noise1", {}, {})
149
- ac.api_service.get_node.assert_called_once_with("/project1/noise1")
150
-
151
- ac._route("PATCH", "/api/nodes/project1/noise1", {}, {"parameters": {"period": 4}})
152
- ac.api_service.update_parameters.assert_called_once_with("/project1/noise1", {"period": 4})
153
-
154
- ac._route("DELETE", "/api/nodes/project1/noise1", {}, {})
155
- ac.api_service.delete_node.assert_called_once_with("/project1/noise1")
156
-
157
- def test_node_method_dispatch(self):
158
- ac._route(
159
- "POST",
160
- "/api/nodes/project1/geo1/method",
161
- {},
162
- {"method": "cook", "args": [1], "kwargs": {"force": True}},
163
- )
164
- ac.api_service.call_method.assert_called_once_with("/project1/geo1", "cook", [1], {"force": True})
165
-
166
- def test_node_errors_dispatch(self):
167
- ac._route("GET", "/api/nodes/project1/geo1/errors", {}, {})
168
- ac.api_service.get_node_errors.assert_called_once_with("/project1/geo1", recursive=False)
169
-
170
- def test_preview_dispatch_with_dimensions(self):
171
- ac._route("GET", "/api/preview/project1/out1", {"width": ["800"], "height": ["600"]}, {})
172
- ac.preview_service.capture.assert_called_once_with("/project1/out1", 800, 600)
173
-
174
- def test_network_topology_dispatch(self):
175
- ac._route("GET", "/api/network/project1/topology", {"recursive": ["true"]}, {})
176
- ac.analysis_service.topology.assert_called_once_with("/project1", recursive=True)
177
-
178
- def test_unknown_route_raises(self):
179
- with self.assertRaises(ValueError):
180
- ac._route("GET", "/api/does-not-exist", {}, {})
181
-
182
- def test_create_node_missing_fields_raises_descriptive(self):
183
- with self.assertRaises(ValueError) as cm:
184
- ac._route("POST", "/api/nodes", {}, {})
185
- self.assertIn("parent_path", str(cm.exception))
186
- self.assertIn("type", str(cm.exception))
187
-
188
- def test_exec_missing_script_raises_descriptive(self):
189
- with self.assertRaises(ValueError) as cm:
190
- ac._route("POST", "/api/exec", {}, {})
191
- self.assertIn("script", str(cm.exception))
192
-
193
-
194
- class ParsingTests(unittest.TestCase):
195
- def test_parse_body_variants(self):
196
- self.assertEqual(ac._parse_body({"data": ""}), {})
197
- self.assertEqual(ac._parse_body({"data": None}), {})
198
- self.assertEqual(ac._parse_body({"data": '{"a": 1}'}), {"a": 1})
199
- self.assertEqual(ac._parse_body({"data": b'{"b": 2}'}), {"b": 2})
200
- self.assertEqual(ac._parse_body({"data": {"c": 3}}), {"c": 3})
201
-
202
- def test_node_path_rejoins_and_restores_leading_slash(self):
203
- self.assertEqual(ac._node_path(["project1", "noise1"]), "/project1/noise1")
204
- # Percent-encoded segment decodes back to a slash-bearing path.
205
- self.assertEqual(ac._node_path(["project1%2Fgeo1"]), "/project1/geo1")
206
-
207
- def test_qs_returns_first_or_default(self):
208
- self.assertEqual(ac._qs({"k": ["v1", "v2"]}, "k"), "v1")
209
- self.assertEqual(ac._qs({}, "missing", "fallback"), "fallback")
210
-
211
- def test_find_header_top_level_and_default(self):
212
- self.assertEqual(ac._find_header({"X-Token": "abc"}, "x-token"), "abc")
213
- self.assertIsNone(ac._find_header({"other": "v"}, "x-token"))
214
-
215
- def test_find_header_accepts_list_value(self):
216
- # A repeated/multi-value header may arrive as a list; take the first str.
217
- self.assertEqual(ac._find_header({"Origin": ["http://x", "http://y"]}, "origin"), "http://x")
218
- self.assertIsNone(ac._find_header({"Origin": []}, "origin"))
219
- self.assertIsNone(ac._find_header({"Origin": [123]}, "origin"))
220
-
221
-
222
- class OriginTests(unittest.TestCase):
223
- """The Origin guard blocks browser-driven CSRF / DNS-rebinding."""
224
-
225
- def test_no_origin_is_allowed(self):
226
- ac._check_origin({"method": "GET"}) # the Node client sends no Origin
227
-
228
- def test_loopback_origins_allowed(self):
229
- for origin in (
230
- "http://127.0.0.1:9980",
231
- "http://localhost",
232
- "https://localhost:3000",
233
- "http://[::1]:9980",
234
- ):
235
- ac._check_origin({"Origin": origin}) # must not raise
236
-
237
- def test_cross_origin_rejected(self):
238
- for origin in ("http://evil.com", "https://attacker.example:8443", "http://192.168.1.5"):
239
- with self.assertRaises(PermissionError, msg=origin):
240
- ac._check_origin({"Origin": origin})
241
-
242
- def test_opaque_null_origin_rejected(self):
243
- with self.assertRaises(PermissionError):
244
- ac._check_origin({"Origin": "null"})
245
-
246
- def test_origin_lookup_is_case_insensitive_and_nested(self):
247
- with self.assertRaises(PermissionError):
248
- ac._check_origin({"headers": {"origin": "http://evil.com"}})
249
-
250
- def test_handle_rejects_cross_origin_with_403(self):
251
- resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": "http://evil.com"}, {})
252
- self.assertEqual(resp["statusCode"], 403)
253
- self.assertIn("cross-origin", resp["data"])
254
-
255
- def test_handle_rejects_list_valued_cross_origin(self):
256
- # Some TD builds surface a header as a list; it must not slip the guard.
257
- resp = ac.handle({"method": "GET", "uri": "/api/info", "Origin": ["http://evil.com"]}, {})
258
- self.assertEqual(resp["statusCode"], 403)
259
- self.assertIn("cross-origin", resp["data"])
260
-
261
-
262
- class HandleTests(unittest.TestCase):
263
- def tearDown(self):
264
- _clear_exec_env()
265
- _clear_token_env()
266
-
267
- def _resp(self):
268
- return {}
269
-
270
- def test_ok_envelope(self):
271
- with mock.patch.object(ac, "_route", return_value={"hello": "world"}):
272
- resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
273
- self.assertEqual(resp["statusCode"], 200)
274
- self.assertIn('"ok": true', resp["data"])
275
- self.assertIn("world", resp["data"])
276
-
277
- def test_auth_failure_is_401(self):
278
- os.environ["TDMCP_BRIDGE_TOKEN"] = "s3cret"
279
- resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
280
- self.assertEqual(resp["statusCode"], 401)
281
- self.assertIn('"ok": false', resp["data"])
282
-
283
- def test_error_is_400(self):
284
- with mock.patch.object(ac, "_route", side_effect=ValueError("boom")):
285
- resp = ac.handle({"method": "GET", "uri": "/api/info"}, self._resp())
286
- self.assertEqual(resp["statusCode"], 400)
287
- self.assertIn("boom", resp["data"])
288
-
289
- def test_exec_disabled_surfaces_as_403_with_message(self):
290
- os.environ["TDMCP_BRIDGE_ALLOW_EXEC"] = "0"
291
- resp = ac.handle(
292
- {"method": "POST", "uri": "/api/exec", "data": '{"script": "1"}'}, self._resp()
293
- )
294
- self.assertEqual(resp["statusCode"], 403)
295
- self.assertIn("disabled", resp["data"])
296
-
297
- def test_missing_required_field_surfaces_as_400_with_name(self):
298
- # A POST with valid JSON but a missing field gets a descriptive 400,
299
- # not a bare KeyError message of just the key name.
300
- resp = ac.handle({"method": "POST", "uri": "/api/nodes", "data": "{}"}, self._resp())
301
- self.assertEqual(resp["statusCode"], 400)
302
- self.assertIn("parent_path", resp["data"])
303
- self.assertIn("Missing required field", resp["data"])
304
-
305
-
306
- if __name__ == "__main__":
307
- unittest.main()