Skip to content

Commit 0773358

Browse files
committed
test: add template control edge case coverage
1 parent 3fcdd92 commit 0773358

1 file changed

Lines changed: 301 additions & 24 deletions

File tree

‎server/tests/test_control_templates.py‎

Lines changed: 301 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,61 @@ def _defaults_only_template_payload() -> dict[str, object]:
9090
}
9191

9292

93-
def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
93+
def _case_sensitive_template_payload(
94+
*,
95+
values: list[str] | None = None,
96+
case_sensitive: bool | None = None,
97+
action: str = "deny",
98+
) -> dict[str, object]:
99+
template_values: dict[str, object] = {}
100+
if values is not None:
101+
template_values["values"] = values
102+
if case_sensitive is not None:
103+
template_values["case_sensitive"] = case_sensitive
104+
105+
return {
106+
"template": {
107+
"description": "Case sensitivity template",
108+
"parameters": {
109+
"values": {
110+
"type": "string_list",
111+
"label": "Values",
112+
"required": False,
113+
"default": ["HELLO"],
114+
},
115+
"case_sensitive": {
116+
"type": "boolean",
117+
"label": "Case Sensitive",
118+
"required": False,
119+
"default": True,
120+
},
121+
},
122+
"definition_template": {
123+
"description": "Case sensitivity control",
124+
"execution": "server",
125+
"scope": {
126+
"step_names": ["templated-step"],
127+
"stages": ["pre"],
128+
},
129+
"condition": {
130+
"selector": {"path": "input"},
131+
"evaluator": {
132+
"name": "list",
133+
"config": {
134+
"values": {"$param": "values"},
135+
"match_mode": "exact",
136+
"case_sensitive": {"$param": "case_sensitive"},
137+
},
138+
},
139+
},
140+
"action": {"decision": action},
141+
},
142+
},
143+
"template_values": template_values,
144+
}
145+
146+
147+
def _raw_control_payload(pattern: str = "raw", *, action: str = "deny") -> dict[str, object]:
94148
return {
95149
"description": "Raw control",
96150
"enabled": True,
@@ -103,7 +157,7 @@ def _raw_control_payload(pattern: str = "raw") -> dict[str, object]:
103157
"config": {"pattern": pattern},
104158
},
105159
},
106-
"action": {"decision": "deny"},
160+
"action": {"decision": action},
107161
}
108162

109163

@@ -112,13 +166,18 @@ def _create_template_control(client: TestClient) -> int:
112166
return control_id
113167

114168

115-
def _create_template_control_with_name(client: TestClient) -> tuple[int, str]:
116-
control_name = f"template-control-{uuid.uuid4()}"
169+
def _create_template_control_with_name(
170+
client: TestClient,
171+
payload: dict[str, object] | None = None,
172+
*,
173+
name_prefix: str = "template-control",
174+
) -> tuple[int, str]:
175+
control_name = f"{name_prefix}-{uuid.uuid4()}"
117176
response = client.put(
118177
"/api/v1/controls",
119178
json={
120179
"name": control_name,
121-
"data": _template_payload(),
180+
"data": payload or _template_payload(),
122181
},
123182
)
124183
assert response.status_code == 200, response.text
@@ -130,23 +189,29 @@ def _assign_control_to_agent(
130189
control_id: int,
131190
*,
132191
agent_name: str | None = None,
192+
via_policy: bool = True,
133193
) -> 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-
141194
effective_agent_name = agent_name or f"template-agent-{uuid.uuid4().hex[:12]}"
142195
init_response = client.post(
143196
"/api/v1/agents/initAgent",
144197
json={"agent": {"agent_name": effective_agent_name}, "steps": []},
145198
)
146199
assert init_response.status_code == 200, init_response.text
147200

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
201+
if via_policy:
202+
policy_response = client.put("/api/v1/policies", json={"name": f"policy-{uuid.uuid4()}"})
203+
assert policy_response.status_code == 200, policy_response.text
204+
policy_id = policy_response.json()["policy_id"]
205+
206+
add_control_response = client.post(f"/api/v1/policies/{policy_id}/controls/{control_id}")
207+
assert add_control_response.status_code == 200, add_control_response.text
208+
209+
assign_response = client.post(f"/api/v1/agents/{effective_agent_name}/policy/{policy_id}")
210+
assert assign_response.status_code == 200, assign_response.text
211+
else:
212+
assign_response = client.post(f"/api/v1/agents/{effective_agent_name}/controls/{control_id}")
213+
assert assign_response.status_code == 200, assign_response.text
214+
150215
return effective_agent_name
151216

152217

@@ -162,6 +227,23 @@ def _create_raw_control(client: TestClient) -> int:
162227
return response.json()["control_id"]
163228

164229

230+
def _evaluate_step(
231+
client: TestClient,
232+
agent_name: str,
233+
*,
234+
step_name: str,
235+
input_value: str,
236+
step_type: str = "llm",
237+
stage: str = "pre",
238+
):
239+
request = EvaluationRequest(
240+
agent_name=agent_name,
241+
step=Step(type=step_type, name=step_name, input=input_value, output=None),
242+
stage=stage,
243+
)
244+
return client.post("/api/v1/evaluation", json=request.model_dump(mode="json"))
245+
246+
165247
def test_render_control_template_preview_returns_rendered_control(client: TestClient) -> None:
166248
response = client.post("/api/v1/control-templates/render", json=_template_payload())
167249

@@ -215,27 +297,222 @@ def test_template_backed_control_evaluates_after_policy_attachment(client: TestC
215297
control_id, control_name = _create_template_control_with_name(client)
216298
agent_name = _assign_control_to_agent(client, control_id)
217299

218-
safe_request = EvaluationRequest(
219-
agent_name=agent_name,
220-
step=Step(type="llm", name="other-step", input="hello", output=None),
221-
stage="pre",
300+
safe_response = _evaluate_step(
301+
client,
302+
agent_name,
303+
step_name="other-step",
304+
input_value="hello",
222305
)
223-
safe_response = client.post("/api/v1/evaluation", json=safe_request.model_dump(mode="json"))
224306
assert safe_response.status_code == 200, safe_response.text
225307
assert safe_response.json()["is_safe"] is True
226308

227-
deny_request = EvaluationRequest(
228-
agent_name=agent_name,
229-
step=Step(type="llm", name="templated-step", input="hello", output=None),
230-
stage="pre",
309+
deny_response = _evaluate_step(
310+
client,
311+
agent_name,
312+
step_name="templated-step",
313+
input_value="hello",
231314
)
232-
deny_response = client.post("/api/v1/evaluation", json=deny_request.model_dump(mode="json"))
233315
assert deny_response.status_code == 200, deny_response.text
234316
body = deny_response.json()
235317
assert body["is_safe"] is False
236318
assert body["matches"][0]["control_name"] == control_name
237319

238320

321+
def test_template_backed_control_can_be_disabled_and_reenabled_in_evaluation(
322+
client: TestClient,
323+
) -> None:
324+
control_id, control_name = _create_template_control_with_name(client)
325+
agent_name = _assign_control_to_agent(client, control_id)
326+
327+
initial_response = _evaluate_step(
328+
client,
329+
agent_name,
330+
step_name="templated-step",
331+
input_value="hello",
332+
)
333+
assert initial_response.status_code == 200, initial_response.text
334+
assert initial_response.json()["is_safe"] is False
335+
336+
disable_response = client.patch(f"/api/v1/controls/{control_id}", json={"enabled": False})
337+
assert disable_response.status_code == 200, disable_response.text
338+
339+
disabled_eval = _evaluate_step(
340+
client,
341+
agent_name,
342+
step_name="templated-step",
343+
input_value="hello",
344+
)
345+
assert disabled_eval.status_code == 200, disabled_eval.text
346+
assert disabled_eval.json()["is_safe"] is True
347+
348+
enable_response = client.patch(f"/api/v1/controls/{control_id}", json={"enabled": True})
349+
assert enable_response.status_code == 200, enable_response.text
350+
351+
reenabled_eval = _evaluate_step(
352+
client,
353+
agent_name,
354+
step_name="templated-step",
355+
input_value="hello",
356+
)
357+
assert reenabled_eval.status_code == 200, reenabled_eval.text
358+
body = reenabled_eval.json()
359+
assert body["is_safe"] is False
360+
assert body["matches"][0]["control_name"] == control_name
361+
362+
363+
def test_template_backed_control_update_changes_scope_behavior(client: TestClient) -> None:
364+
control_id, control_name = _create_template_control_with_name(client)
365+
agent_name = _assign_control_to_agent(client, control_id)
366+
367+
initial_eval = _evaluate_step(
368+
client,
369+
agent_name,
370+
step_name="templated-step",
371+
input_value="hello",
372+
)
373+
assert initial_eval.status_code == 200, initial_eval.text
374+
assert initial_eval.json()["is_safe"] is False
375+
376+
updated_payload = _template_payload()
377+
updated_payload["template_values"] = {
378+
"pattern": "hello",
379+
"step_name": "updated-step",
380+
}
381+
update_response = client.put(
382+
f"/api/v1/controls/{control_id}/data",
383+
json={"data": updated_payload},
384+
)
385+
assert update_response.status_code == 200, update_response.text
386+
387+
old_scope_eval = _evaluate_step(
388+
client,
389+
agent_name,
390+
step_name="templated-step",
391+
input_value="hello",
392+
)
393+
assert old_scope_eval.status_code == 200, old_scope_eval.text
394+
assert old_scope_eval.json()["is_safe"] is True
395+
396+
updated_scope_eval = _evaluate_step(
397+
client,
398+
agent_name,
399+
step_name="updated-step",
400+
input_value="hello",
401+
)
402+
assert updated_scope_eval.status_code == 200, updated_scope_eval.text
403+
body = updated_scope_eval.json()
404+
assert body["is_safe"] is False
405+
assert body["matches"][0]["control_name"] == control_name
406+
407+
408+
def test_template_backed_control_supports_direct_agent_attachment(client: TestClient) -> None:
409+
control_id, control_name = _create_template_control_with_name(client)
410+
agent_name = _assign_control_to_agent(client, control_id, via_policy=False)
411+
412+
response = _evaluate_step(
413+
client,
414+
agent_name,
415+
step_name="templated-step",
416+
input_value="hello",
417+
)
418+
419+
assert response.status_code == 200, response.text
420+
body = response.json()
421+
assert body["is_safe"] is False
422+
assert body["matches"][0]["control_name"] == control_name
423+
424+
425+
def test_template_backed_control_preserves_falsey_values_and_uses_them_in_behavior(
426+
client: TestClient,
427+
) -> None:
428+
payload = _case_sensitive_template_payload(values=[], case_sensitive=False)
429+
control_id, control_name = _create_template_control_with_name(
430+
client,
431+
payload,
432+
name_prefix="falsey-template-control",
433+
)
434+
agent_name = _assign_control_to_agent(client, control_id)
435+
436+
get_response = client.get(f"/api/v1/controls/{control_id}/data")
437+
assert get_response.status_code == 200, get_response.text
438+
data = get_response.json()["data"]
439+
assert data["template_values"] == {
440+
"values": [],
441+
"case_sensitive": False,
442+
}
443+
444+
non_applicable_eval = _evaluate_step(
445+
client,
446+
agent_name,
447+
step_name="templated-step",
448+
input_value="hello",
449+
)
450+
assert non_applicable_eval.status_code == 200, non_applicable_eval.text
451+
assert non_applicable_eval.json()["is_safe"] is True
452+
453+
updated_payload = _case_sensitive_template_payload(
454+
values=["HELLO"],
455+
case_sensitive=False,
456+
)
457+
update_response = client.put(
458+
f"/api/v1/controls/{control_id}/data",
459+
json={"data": updated_payload},
460+
)
461+
assert update_response.status_code == 200, update_response.text
462+
463+
deny_eval = _evaluate_step(
464+
client,
465+
agent_name,
466+
step_name="templated-step",
467+
input_value="hello",
468+
)
469+
assert deny_eval.status_code == 200, deny_eval.text
470+
body = deny_eval.json()
471+
assert body["is_safe"] is False
472+
assert body["matches"][0]["control_name"] == control_name
473+
474+
475+
def test_mixed_raw_and_template_backed_controls_obey_deny_precedence(
476+
client: TestClient,
477+
) -> None:
478+
template_control_id, template_control_name = _create_template_control_with_name(client)
479+
agent_name = _assign_control_to_agent(client, template_control_id)
480+
481+
policy_response = client.get(f"/api/v1/agents/{agent_name}/policy")
482+
assert policy_response.status_code == 200, policy_response.text
483+
policy_id = policy_response.json()["policy_id"]
484+
485+
raw_warn_name = f"raw-warn-{uuid.uuid4()}"
486+
raw_warn_response = client.put(
487+
"/api/v1/controls",
488+
json={
489+
"name": raw_warn_name,
490+
"data": _raw_control_payload("hello", action="warn"),
491+
},
492+
)
493+
assert raw_warn_response.status_code == 200, raw_warn_response.text
494+
raw_warn_control_id = raw_warn_response.json()["control_id"]
495+
496+
add_control_response = client.post(f"/api/v1/policies/{policy_id}/controls/{raw_warn_control_id}")
497+
assert add_control_response.status_code == 200, add_control_response.text
498+
499+
response = _evaluate_step(
500+
client,
501+
agent_name,
502+
step_name="templated-step",
503+
input_value="hello",
504+
)
505+
506+
assert response.status_code == 200, response.text
507+
body = response.json()
508+
assert body["is_safe"] is False
509+
assert len(body["matches"]) == 2
510+
names = {match["control_name"] for match in body["matches"]}
511+
actions = {match["action"] for match in body["matches"]}
512+
assert names == {template_control_name, raw_warn_name}
513+
assert actions == {"deny", "warn"}
514+
515+
239516
def test_raw_control_can_be_replaced_with_template_backed_control(client: TestClient) -> None:
240517
control_id = _create_raw_control(client)
241518

0 commit comments

Comments
 (0)