@@ -46,6 +46,23 @@ def _template_payload() -> dict[str, object]:
4646 }
4747
4848
49+ def _raw_control_payload (pattern : str = "raw" ) -> dict [str , object ]:
50+ return {
51+ "description" : "Raw control" ,
52+ "enabled" : True ,
53+ "execution" : "server" ,
54+ "scope" : {"step_types" : ["llm" ], "stages" : ["pre" ]},
55+ "condition" : {
56+ "selector" : {"path" : "input" },
57+ "evaluator" : {
58+ "name" : "regex" ,
59+ "config" : {"pattern" : pattern },
60+ },
61+ },
62+ "action" : {"decision" : "deny" },
63+ }
64+
65+
4966def _create_template_control (client : TestClient ) -> int :
5067 response = client .put (
5168 "/api/v1/controls" ,
@@ -58,6 +75,18 @@ def _create_template_control(client: TestClient) -> int:
5875 return response .json ()["control_id" ]
5976
6077
78+ def _create_raw_control (client : TestClient ) -> int :
79+ response = client .put (
80+ "/api/v1/controls" ,
81+ json = {
82+ "name" : f"raw-control-{ uuid .uuid4 ()} " ,
83+ "data" : _raw_control_payload (),
84+ },
85+ )
86+ assert response .status_code == 200 , response .text
87+ return response .json ()["control_id" ]
88+
89+
6190def test_render_control_template_preview_returns_rendered_control (client : TestClient ) -> None :
6291 response = client .post ("/api/v1/control-templates/render" , json = _template_payload ())
6392
@@ -84,6 +113,23 @@ def test_create_template_backed_control_persists_template_metadata(client: TestC
84113 assert data ["condition" ]["evaluator" ]["config" ]["pattern" ] == "hello"
85114
86115
116+ def test_raw_control_can_be_replaced_with_template_backed_control (client : TestClient ) -> None :
117+ control_id = _create_raw_control (client )
118+
119+ put_response = client .put (
120+ f"/api/v1/controls/{ control_id } /data" ,
121+ json = {"data" : _template_payload ()},
122+ )
123+ assert put_response .status_code == 200 , put_response .text
124+
125+ get_response = client .get (f"/api/v1/controls/{ control_id } /data" )
126+ assert get_response .status_code == 200 , get_response .text
127+ data = get_response .json ()["data" ]
128+ assert data ["template" ]["description" ] == "Regex denial template"
129+ assert data ["template_values" ]["pattern" ] == "hello"
130+ assert data ["condition" ]["evaluator" ]["config" ]["pattern" ] == "hello"
131+
132+
87133def test_template_update_preserves_enabled_value (client : TestClient ) -> None :
88134 control_id = _create_template_control (client )
89135
@@ -157,6 +203,7 @@ def test_template_backed_control_rejects_raw_put_update(client: TestClient) -> N
157203 )
158204
159205 assert response .status_code == 409
206+ assert response .json ()["error_code" ] == "CONTROL_TEMPLATE_CONFLICT"
160207
161208
162209def test_list_controls_includes_template_backed_flag_and_filter (client : TestClient ) -> None :
@@ -167,3 +214,130 @@ def test_list_controls_includes_template_backed_flag_and_filter(client: TestClie
167214 controls = response .json ()["controls" ]
168215 template_control = next (control for control in controls if control ["id" ] == control_id )
169216 assert template_control ["template_backed" ] is True
217+
218+
219+ def test_list_controls_template_backed_false_returns_only_raw_controls (client : TestClient ) -> None :
220+ template_control_id = _create_template_control (client )
221+ raw_control_id = _create_raw_control (client )
222+
223+ response = client .get ("/api/v1/controls" , params = {"template_backed" : False })
224+ assert response .status_code == 200 , response .text
225+ control_ids = {control ["id" ] for control in response .json ()["controls" ]}
226+ assert raw_control_id in control_ids
227+ assert template_control_id not in control_ids
228+
229+
230+ def test_render_control_template_rejects_extra_request_fields (client : TestClient ) -> None :
231+ payload = _template_payload ()
232+ payload ["execution" ] = "server"
233+
234+ response = client .post ("/api/v1/control-templates/render" , json = payload )
235+
236+ assert response .status_code == 422
237+ assert response .json ()["error_code" ] == "VALIDATION_ERROR"
238+
239+
240+ def test_render_control_template_maps_invalid_regex_parameter (client : TestClient ) -> None :
241+ payload = _template_payload ()
242+ payload ["template_values" ] = {"pattern" : "[" }
243+
244+ response = client .post ("/api/v1/control-templates/render" , json = payload )
245+
246+ assert response .status_code == 422
247+ body = response .json ()
248+ assert body ["error_code" ] == "TEMPLATE_PARAMETER_INVALID"
249+ assert any (
250+ err .get ("field" ) == "template_values.pattern"
251+ and err .get ("parameter" ) == "pattern"
252+ for err in body .get ("errors" , [])
253+ )
254+
255+
256+ def test_render_control_template_rejects_malformed_param_binding (client : TestClient ) -> None :
257+ payload = _template_payload ()
258+ payload ["template" ] = deepcopy (payload ["template" ])
259+ payload ["template" ]["definition_template" ]["condition" ]["evaluator" ]["config" ]["pattern" ] = { # type: ignore[index]
260+ "$param" : "pattern" ,
261+ "extra" : True ,
262+ }
263+
264+ response = client .post ("/api/v1/control-templates/render" , json = payload )
265+
266+ assert response .status_code == 422
267+ body = response .json ()
268+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
269+ assert any (err .get ("code" ) == "invalid_param_binding" for err in body .get ("errors" , []))
270+
271+
272+ def test_render_control_template_rejects_non_string_param_reference (client : TestClient ) -> None :
273+ payload = _template_payload ()
274+ payload ["template" ] = deepcopy (payload ["template" ])
275+ payload ["template" ]["definition_template" ]["condition" ]["evaluator" ]["config" ]["pattern" ] = { # type: ignore[index]
276+ "$param" : 123 ,
277+ }
278+
279+ response = client .post ("/api/v1/control-templates/render" , json = payload )
280+
281+ assert response .status_code == 422
282+ body = response .json ()
283+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
284+ assert any (err .get ("code" ) == "invalid_param_binding" for err in body .get ("errors" , []))
285+
286+
287+ def test_render_control_template_rejects_agent_scoped_evaluator (client : TestClient ) -> None :
288+ payload = _template_payload ()
289+ payload ["template" ] = deepcopy (payload ["template" ])
290+ payload ["template" ]["definition_template" ]["condition" ]["evaluator" ]["name" ] = "agent-x:custom" # type: ignore[index]
291+
292+ response = client .post ("/api/v1/control-templates/render" , json = payload )
293+
294+ assert response .status_code == 422
295+ body = response .json ()
296+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
297+ assert any (
298+ err .get ("code" ) == "agent_scoped_evaluator_not_supported"
299+ for err in body .get ("errors" , [])
300+ )
301+
302+
303+ def test_render_control_template_rejects_legacy_flat_format (client : TestClient ) -> None :
304+ payload = _template_payload ()
305+ payload ["template" ] = deepcopy (payload ["template" ])
306+ payload ["template" ]["definition_template" ] = { # type: ignore[index]
307+ "execution" : "server" ,
308+ "scope" : {"step_types" : ["llm" ], "stages" : ["pre" ]},
309+ "selector" : {"path" : "input" },
310+ "evaluator" : {
311+ "name" : "regex" ,
312+ "config" : {"pattern" : {"$param" : "pattern" }},
313+ },
314+ "action" : {"decision" : "deny" },
315+ }
316+
317+ response = client .post ("/api/v1/control-templates/render" , json = payload )
318+
319+ assert response .status_code == 422
320+ body = response .json ()
321+ assert body ["error_code" ] == "TEMPLATE_RENDER_ERROR"
322+ assert any (
323+ err .get ("code" ) == "legacy_condition_format_not_supported"
324+ for err in body .get ("errors" , [])
325+ )
326+
327+
328+ def test_render_control_template_rejects_invalid_parameter_name_at_api_boundary (
329+ client : TestClient ,
330+ ) -> None :
331+ payload = _template_payload ()
332+ payload ["template" ] = deepcopy (payload ["template" ])
333+ payload ["template" ]["parameters" ] = { # type: ignore[index]
334+ "bad.name" : {
335+ "type" : "string" ,
336+ "label" : "Bad Name" ,
337+ }
338+ }
339+
340+ response = client .post ("/api/v1/control-templates/render" , json = payload )
341+
342+ assert response .status_code == 422
343+ assert response .json ()["error_code" ] == "VALIDATION_ERROR"
0 commit comments