Skip to content

Commit 3fcdd92

Browse files
committed
test: add behavioral coverage for control templates
1 parent 19aa096 commit 3fcdd92

2 files changed

Lines changed: 289 additions & 3 deletions

File tree

‎sdks/python/tests/test_local_evaluation.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,33 @@ def make_control_dict(
9393
}
9494

9595

96+
def add_template_metadata(control: dict[str, Any], *, pattern: str = "test") -> dict[str, Any]:
97+
"""Attach realistic template metadata to a cached control payload."""
98+
control["control"]["template"] = {
99+
"parameters": {
100+
"pattern": {
101+
"type": "regex_re2",
102+
"label": "Pattern",
103+
}
104+
},
105+
"definition_template": {
106+
"description": "Template-backed control",
107+
"execution": control["control"]["execution"],
108+
"scope": control["control"]["scope"],
109+
"condition": {
110+
"selector": {"path": "input"},
111+
"evaluator": {
112+
"name": "regex",
113+
"config": {"pattern": {"$param": "pattern"}},
114+
},
115+
},
116+
"action": control["control"]["action"],
117+
},
118+
}
119+
control["control"]["template_values"] = {"pattern": pattern}
120+
return control
121+
122+
96123
NON_APPLICABLE_CONTROL_CASES = [
97124
pytest.param({"enabled": False}, id="disabled"),
98125
pytest.param({"stage": "post"}, id="stage_mismatch"),
@@ -271,6 +298,37 @@ async def test_server_only_controls_calls_server(self, agent_name, llm_payload):
271298

272299
assert result.is_safe is True
273300

301+
@pytest.mark.asyncio
302+
async def test_server_only_template_backed_controls_still_call_server(
303+
self,
304+
agent_name,
305+
llm_payload,
306+
):
307+
"""Template metadata must not break routing for server-executed cached controls."""
308+
controls = [
309+
add_template_metadata(
310+
make_control_dict(1, "templated_server_ctrl", execution="server"),
311+
),
312+
]
313+
314+
client = MagicMock(spec=AgentControlClient)
315+
mock_response = MagicMock()
316+
mock_response.json.return_value = {"is_safe": True, "confidence": 1.0}
317+
mock_response.raise_for_status = MagicMock()
318+
client.http_client = AsyncMock()
319+
client.http_client.post = AsyncMock(return_value=mock_response)
320+
321+
result = await check_evaluation_with_local(
322+
client=client,
323+
agent_name=agent_name,
324+
step=llm_payload,
325+
stage="pre",
326+
controls=controls,
327+
)
328+
329+
client.http_client.post.assert_called_once()
330+
assert result.is_safe is True
331+
274332
@pytest.mark.asyncio
275333
@pytest.mark.parametrize(
276334
"control_kwargs",
@@ -447,6 +505,36 @@ async def test_local_legacy_flat_control_executes_locally(self, agent_name, llm_
447505
assert len(result.matches) == 1
448506
assert result.matches[0].control_name == "legacy_local_ctrl"
449507

508+
@pytest.mark.asyncio
509+
async def test_template_backed_local_control_executes_locally(
510+
self,
511+
agent_name,
512+
llm_payload,
513+
):
514+
"""Template metadata should be ignored by runtime parsing in local evaluation."""
515+
controls = [
516+
add_template_metadata(
517+
make_control_dict(1, "templated_local_ctrl", execution="sdk", pattern=r"test"),
518+
),
519+
]
520+
client = MagicMock(spec=AgentControlClient)
521+
client.http_client = AsyncMock()
522+
client.http_client.post = AsyncMock()
523+
524+
result = await check_evaluation_with_local(
525+
client=client,
526+
agent_name=agent_name,
527+
step=llm_payload,
528+
stage="pre",
529+
controls=controls,
530+
)
531+
532+
client.http_client.post.assert_not_called()
533+
assert result.is_safe is False
534+
assert result.matches is not None
535+
assert len(result.matches) == 1
536+
assert result.matches[0].control_name == "templated_local_ctrl"
537+
450538
@pytest.mark.asyncio
451539
async def test_local_deny_short_circuits(self, agent_name, llm_payload):
452540
"""Local deny should return immediately without calling server."""

‎server/tests/test_control_templates.py‎

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import uuid
66
from copy import deepcopy
77

8+
from agent_control_models import EvaluationRequest, Step
89
from fastapi.testclient import TestClient
910

1011

@@ -46,6 +47,49 @@ def _template_payload() -> dict[str, object]:
4647
}
4748

4849

50+
def _defaults_only_template_payload() -> dict[str, object]:
51+
return {
52+
"template": {
53+
"description": "List evaluator template",
54+
"parameters": {
55+
"values": {
56+
"type": "string_list",
57+
"label": "Values",
58+
"default": ["secret", "blocked"],
59+
},
60+
"logic": {
61+
"type": "enum",
62+
"label": "Logic",
63+
"allowed_values": ["any", "all"],
64+
"default": "any",
65+
},
66+
"case_sensitive": {
67+
"type": "boolean",
68+
"label": "Case Sensitive",
69+
"default": False,
70+
},
71+
},
72+
"definition_template": {
73+
"description": "Defaulted list control",
74+
"execution": "server",
75+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
76+
"condition": {
77+
"selector": {"path": "input"},
78+
"evaluator": {
79+
"name": "list",
80+
"config": {
81+
"values": {"$param": "values"},
82+
"logic": {"$param": "logic"},
83+
"case_sensitive": {"$param": "case_sensitive"},
84+
},
85+
},
86+
},
87+
"action": {"decision": "deny"},
88+
},
89+
}
90+
}
91+
92+
4993
def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
5094
return {
5195
"description": "Raw control",
@@ -64,15 +108,46 @@ def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
64108

65109

66110
def _create_template_control(client: TestClient) -> int:
111+
control_id, _ = _create_template_control_with_name(client)
112+
return control_id
113+
114+
115+
def _create_template_control_with_name(client: TestClient) -> tuple[int, str]:
116+
control_name = f"template-control-{uuid.uuid4()}"
67117
response = client.put(
68118
"/api/v1/controls",
69119
json={
70-
"name": f"template-control-{uuid.uuid4()}",
120+
"name": control_name,
71121
"data": _template_payload(),
72122
},
73123
)
74124
assert response.status_code == 200, response.text
75-
return response.json()["control_id"]
125+
return response.json()["control_id"], control_name
126+
127+
128+
def _assign_control_to_agent(
129+
client: TestClient,
130+
control_id: int,
131+
*,
132+
agent_name: str | None = None,
133+
) -> str:
134+
policy_response = client.put("/api/v1/policies", json={"name": f"policy-{uuid.uuid4()}"})
135+
assert policy_response.status_code == 200, policy_response.text
136+
policy_id = policy_response.json()["policy_id"]
137+
138+
add_control_response = client.post(f"/api/v1/policies/{policy_id}/controls/{control_id}")
139+
assert add_control_response.status_code == 200, add_control_response.text
140+
141+
effective_agent_name = agent_name or f"template-agent-{uuid.uuid4().hex[:12]}"
142+
init_response = client.post(
143+
"/api/v1/agents/initAgent",
144+
json={"agent": {"agent_name": effective_agent_name}, "steps": []},
145+
)
146+
assert init_response.status_code == 200, init_response.text
147+
148+
assign_response = client.post(f"/api/v1/agents/{effective_agent_name}/policy/{policy_id}")
149+
assert assign_response.status_code == 200, assign_response.text
150+
return effective_agent_name
76151

77152

78153
def _create_raw_control(client: TestClient) -> int:
@@ -102,17 +177,65 @@ def test_render_control_template_preview_returns_rendered_control(client: TestCl
102177
assert control["scope"]["step_names"] == ["templated-step"]
103178

104179

180+
def test_render_control_template_preview_uses_defaults_when_values_omitted(
181+
client: TestClient,
182+
) -> None:
183+
response = client.post("/api/v1/control-templates/render", json=_defaults_only_template_payload())
184+
185+
assert response.status_code == 200, response.text
186+
control = response.json()["control"]
187+
assert control["template_values"] == {
188+
"values": ["secret", "blocked"],
189+
"logic": "any",
190+
"case_sensitive": False,
191+
}
192+
assert control["condition"]["evaluator"]["name"] == "list"
193+
assert control["condition"]["evaluator"]["config"] == {
194+
"values": ["secret", "blocked"],
195+
"logic": "any",
196+
"case_sensitive": False,
197+
}
198+
199+
105200
def test_create_template_backed_control_persists_template_metadata(client: TestClient) -> None:
106201
control_id = _create_template_control(client)
107202

108203
response = client.get(f"/api/v1/controls/{control_id}/data")
109204
assert response.status_code == 200, response.text
110205
data = response.json()["data"]
111206
assert data["template"]["description"] == "Regex denial template"
112-
assert data["template_values"]["pattern"] == "hello"
207+
assert data["template_values"] == {
208+
"pattern": "hello",
209+
"step_name": "templated-step",
210+
}
113211
assert data["condition"]["evaluator"]["config"]["pattern"] == "hello"
114212

115213

214+
def test_template_backed_control_evaluates_after_policy_attachment(client: TestClient) -> None:
215+
control_id, control_name = _create_template_control_with_name(client)
216+
agent_name = _assign_control_to_agent(client, control_id)
217+
218+
safe_request = EvaluationRequest(
219+
agent_name=agent_name,
220+
step=Step(type="llm", name="other-step", input="hello", output=None),
221+
stage="pre",
222+
)
223+
safe_response = client.post("/api/v1/evaluation", json=safe_request.model_dump(mode="json"))
224+
assert safe_response.status_code == 200, safe_response.text
225+
assert safe_response.json()["is_safe"] is True
226+
227+
deny_request = EvaluationRequest(
228+
agent_name=agent_name,
229+
step=Step(type="llm", name="templated-step", input="hello", output=None),
230+
stage="pre",
231+
)
232+
deny_response = client.post("/api/v1/evaluation", json=deny_request.model_dump(mode="json"))
233+
assert deny_response.status_code == 200, deny_response.text
234+
body = deny_response.json()
235+
assert body["is_safe"] is False
236+
assert body["matches"][0]["control_name"] == control_name
237+
238+
116239
def test_raw_control_can_be_replaced_with_template_backed_control(client: TestClient) -> None:
117240
control_id = _create_raw_control(client)
118241

@@ -178,6 +301,22 @@ def test_template_validate_maps_missing_parameter_error(client: TestClient) -> N
178301
)
179302

180303

304+
def test_render_control_template_rejects_unknown_template_value_key(client: TestClient) -> None:
305+
payload = _template_payload()
306+
payload["template_values"] = {"pattern": "hello", "unknown": "value"}
307+
308+
response = client.post("/api/v1/control-templates/render", json=payload)
309+
310+
assert response.status_code == 422
311+
body = response.json()
312+
assert body["error_code"] == "TEMPLATE_PARAMETER_INVALID"
313+
assert any(
314+
err.get("field") == "template_values.unknown"
315+
and err.get("code") == "unknown_parameter"
316+
for err in body.get("errors", [])
317+
)
318+
319+
181320
def test_template_backed_control_rejects_raw_put_update(client: TestClient) -> None:
182321
control_id = _create_template_control(client)
183322
raw_payload = deepcopy(
@@ -284,6 +423,27 @@ def test_render_control_template_rejects_non_string_param_reference(client: Test
284423
assert any(err.get("code") == "invalid_param_binding" for err in body.get("errors", []))
285424

286425

426+
def test_render_control_template_rejects_unused_parameter(client: TestClient) -> None:
427+
payload = _template_payload()
428+
payload["template"] = deepcopy(payload["template"])
429+
payload["template"]["parameters"]["unused"] = { # type: ignore[index]
430+
"type": "string",
431+
"label": "Unused",
432+
"default": "still-unused",
433+
}
434+
435+
response = client.post("/api/v1/control-templates/render", json=payload)
436+
437+
assert response.status_code == 422
438+
body = response.json()
439+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
440+
assert any(
441+
err.get("field") == "template.parameters.unused"
442+
and err.get("code") == "unused_template_parameter"
443+
for err in body.get("errors", [])
444+
)
445+
446+
287447
def test_render_control_template_rejects_agent_scoped_evaluator(client: TestClient) -> None:
288448
payload = _template_payload()
289449
payload["template"] = deepcopy(payload["template"])
@@ -300,6 +460,25 @@ def test_render_control_template_rejects_agent_scoped_evaluator(client: TestClie
300460
)
301461

302462

463+
def test_render_control_template_rejects_forbidden_top_level_template_fields(
464+
client: TestClient,
465+
) -> None:
466+
for forbidden_field, value in (("enabled", True), ("name", "templated-name")):
467+
payload = _template_payload()
468+
payload["template"] = deepcopy(payload["template"])
469+
payload["template"]["definition_template"][forbidden_field] = value # type: ignore[index]
470+
471+
response = client.post("/api/v1/control-templates/render", json=payload)
472+
473+
assert response.status_code == 422
474+
body = response.json()
475+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
476+
assert any(
477+
err.get("field") == forbidden_field and err.get("code") == "forbidden_template_field"
478+
for err in body.get("errors", [])
479+
)
480+
481+
303482
def test_render_control_template_rejects_legacy_flat_format(client: TestClient) -> None:
304483
payload = _template_payload()
305484
payload["template"] = deepcopy(payload["template"])
@@ -341,3 +520,22 @@ def test_render_control_template_rejects_invalid_parameter_name_at_api_boundary(
341520

342521
assert response.status_code == 422
343522
assert response.json()["error_code"] == "VALIDATION_ERROR"
523+
524+
525+
def test_render_control_template_keeps_non_parameterized_errors_on_rendered_fields(
526+
client: TestClient,
527+
) -> None:
528+
payload = _template_payload()
529+
payload["template"] = deepcopy(payload["template"])
530+
payload["template"]["definition_template"]["action"]["decision"] = "block" # type: ignore[index]
531+
532+
response = client.post("/api/v1/control-templates/render", json=payload)
533+
534+
assert response.status_code == 422
535+
body = response.json()
536+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
537+
assert any(
538+
err.get("field") == "action.decision"
539+
and err.get("rendered_field") == "action.decision"
540+
for err in body.get("errors", [])
541+
)

0 commit comments

Comments
 (0)