test(agent-chat): audit guardrail and error handling

This commit is contained in:
2026-07-06 01:20:12 +03:00
parent 49e4ac0fe2
commit 082d6af3ab
23 changed files with 1541 additions and 354 deletions

View File

@@ -0,0 +1,241 @@
# backend/tests/test_agent/test_agent_confirmation_v2.py
# #region Test.Agent.ConfirmationV2 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,guardrails,hitl]
# @BRIEF Contract tests for confirmation_metadata_for_tool and build_confirmation_contract_v2 — three-axis risk, env resolution, permission denied.
# @RELATION BINDS_TO -> [AgentChat.Confirmation]
# @TEST_FIXTURE: deploy_prod -> guarded + prod env_context
# @TEST_FIXTURE: deploy_staging -> guarded + staging env_context
# @TEST_FIXTURE: deploy_dev -> guarded + dev env_context
# @TEST_FIXTURE: delete_any -> dangerous risk_level
# @TEST_FIXTURE: read_only -> safe risk_level
# @TEST_FIXTURE: analyst_deploy -> permission_granted=false
# @TEST_FIXTURE: env_resolution -> tool_args.env_id > target_env > None
from src.agent._confirmation import (
_resolve_env_tier,
build_confirmation_contract_v2,
confirmation_metadata_for_tool,
permission_denied_payload,
)
# ── _resolve_env_tier ───────────────────────────────────────────────
# #region test_env_resolution_from_tool_args [C:2] [TYPE Function]
def test_resolve_env_tier_from_tool_args_env_id():
"""env_id in tool_args takes highest priority."""
assert _resolve_env_tier({"env_id": "prod-01"}, None) == "prod"
assert _resolve_env_tier({"env_id": "prod-01"}, "staging") == "prod" # tool_args wins
# #endregion test_env_resolution_from_tool_args
# #region test_env_resolution_from_environment_id [C:2] [TYPE Function]
def test_resolve_env_tier_from_environment_id():
"""environment_id in tool_args (alternative key) works."""
assert _resolve_env_tier({"environment_id": "staging-v2"}, None) == "staging"
# #endregion test_env_resolution_from_environment_id
# #region test_env_resolution_from_target_env [C:2] [TYPE Function]
def test_resolve_env_tier_from_target_env():
"""target_env fallback when tool_args has no env."""
assert _resolve_env_tier({}, "ss-dev") == "dev"
assert _resolve_env_tier({"query": "test"}, "prod-v2") == "prod"
# #endregion test_env_resolution_from_target_env
# #region test_env_resolution_null_when_no_env [C:2] [TYPE Function]
def test_resolve_env_tier_null_when_no_env():
"""When neither tool_args nor target_env provides env, return None."""
assert _resolve_env_tier({}, None) is None
assert _resolve_env_tier({"dashboard_id": 42}, None) is None
# #endregion test_env_resolution_null_when_no_env
# #region test_env_resolution_ambiguous_names [C:2] [TYPE Function]
def test_resolve_env_tier_ambiguous_names():
"""Environment names containing stag/test/local/dev are correctly tiered."""
assert _resolve_env_tier({"env_id": "autotest"}, None) == "staging" # "test" in autotest
assert _resolve_env_tier({"env_id": "localhost"}, None) == "dev" # "local" in localhost
# #endregion test_env_resolution_ambiguous_names
# ── build_confirmation_contract_v2 ───────────────────────────────────
# #region test_deploy_to_prod_is_guarded_with_prod_context [C:2] [TYPE Function]
def test_deploy_to_prod_is_guarded_with_prod_context():
"""Deploy to production → guarded risk + prod env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod-01"}, "admin", "prod-01",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["dangerous"] is False
assert contract["env_context"] == "prod"
assert contract["permission_granted"] is True
# #endregion test_deploy_to_prod_is_guarded_with_prod_context
# #region test_deploy_to_staging_is_guarded_with_staging_context [C:2] [TYPE Function]
def test_deploy_to_staging_is_guarded_with_staging_context():
"""Deploy to staging → guarded risk + staging env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "staging-v2"}, "admin", "staging-v2",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "staging"
# #endregion test_deploy_to_staging_is_guarded_with_staging_context
# #region test_deploy_to_dev_is_guarded_with_dev_context [C:2] [TYPE Function]
def test_deploy_to_dev_is_guarded_with_dev_context():
"""Deploy to dev → guarded risk + dev env_context."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "my-dev-env"}, "admin", "my-dev-env",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
assert contract["env_context"] == "dev"
# #endregion test_deploy_to_dev_is_guarded_with_dev_context
# #region test_delete_operation_is_dangerous [C:2] [TYPE Function]
def test_delete_operation_is_dangerous():
"""Delete-prefixed tools should be classified as dangerous."""
contract = build_confirmation_contract_v2(
"delete_dashboard", {}, "admin",
)
assert contract["risk"] == "write"
assert contract["risk_level"] == "dangerous"
assert contract["dangerous"] is True
# #endregion test_delete_operation_is_dangerous
# #region test_read_only_tool_is_safe [C:2] [TYPE Function]
def test_read_only_tool_is_safe():
"""Search/list/get tools should be classified as safe/read."""
contract = build_confirmation_contract_v2(
"search_dashboards", {"env_id": "prod"}, "admin", "prod",
)
assert contract["risk"] == "read"
assert contract["risk_level"] == "safe"
assert contract["dangerous"] is False
# #endregion test_read_only_tool_is_safe
# #region test_viewer_gets_permission_denied [C:2] [TYPE Function]
def test_viewer_permission_denied_for_write_tools():
"""Viewer role should be denied permission for write tools."""
contract = build_confirmation_contract_v2(
"deploy_dashboard", {"env_id": "prod"}, "viewer", "prod",
)
assert contract["permission_granted"] is False
assert contract["required_role"] == "admin"
assert contract["alternatives"] is not None
assert len(contract["alternatives"]) >= 1
# #endregion test_viewer_gets_permission_denied
# #region test_analyst_permission_denied_specific_tools [C:2] [TYPE Function]
def test_analyst_permission_denied_for_admin_tools():
"""Analyst/editor role should be denied for admin-only tools."""
for tool_name in ("deploy_dashboard", "execute_migration", "run_backup"):
contract = build_confirmation_contract_v2(tool_name, {}, "analyst")
assert contract["permission_granted"] is False, f"{tool_name} should be denied for analyst"
# #endregion test_analyst_permission_denied_specific_tools
# #region test_admin_write_tool_permission_granted [C:2] [TYPE Function]
def test_admin_write_tool_permission_granted():
"""Admin role should always have permission_granted=True for guarded tools."""
write_tools = ["deploy_dashboard", "commit_changes", "execute_migration"]
for tool_name in write_tools:
contract = build_confirmation_contract_v2(tool_name, {}, "admin")
assert contract["permission_granted"] is True, f"{tool_name} should be allowed for admin"
# #endregion test_admin_write_tool_permission_granted
# ── confirmation_metadata_for_tool ────────────────────────────────────
# #region test_metadata_for_tool_contains_required_fields [C:2] [TYPE Function]
def test_metadata_for_tool_contains_all_required_fields():
"""confirmation_metadata_for_tool should produce a complete metadata dict."""
meta = confirmation_metadata_for_tool(
"conv-123", "deploy_dashboard",
{"env_id": "prod-01"}, "admin", "prod-01",
)
required_fields = [
"type", "thread_id", "prompt", "tool_name", "tool_args",
"risk", "risk_level", "requires_confirmation",
"dangerous", "env_context",
]
for field in required_fields:
assert field in meta, f"Missing field '{field}' in metadata"
assert meta["type"] == "confirm_required"
assert meta["thread_id"] == "conv-123"
assert meta["tool_name"] == "deploy_dashboard"
assert meta["risk"] == "write"
assert meta["risk_level"] == "guarded"
assert meta["requires_confirmation"] is True
# #endregion test_metadata_for_tool_contains_required_fields
# ── permission_denied_payload ─────────────────────────────────────────
# #region test_permission_denied_payload_structure [C:2] [TYPE Function]
def test_permission_denied_payload_structure():
"""permission_denied_payload should produce valid JSON with correct type."""
import json
payload_str = permission_denied_payload("deploy_dashboard", "admin", "viewer")
payload = json.loads(payload_str)
assert payload["content"] == "⛔ Недостаточно прав для deploy_dashboard"
assert payload["metadata"]["type"] == "permission_denied"
assert payload["metadata"]["tool_name"] == "deploy_dashboard"
assert payload["metadata"]["required_role"] == "admin"
assert payload["metadata"]["user_role"] == "viewer"
assert payload["metadata"]["alternatives"] == []
# #endregion test_permission_denied_payload_structure
# #region test_permission_denied_payload_with_alternatives [C:2] [TYPE Function]
def test_permission_denied_payload_with_alternatives():
"""Alternatives list should be preserved in the payload."""
import json
alternatives = [{"action": "get_health_summary", "prompt": "Check system health"}]
payload_str = permission_denied_payload(
"deploy_dashboard", "admin", "viewer", alternatives,
)
payload = json.loads(payload_str)
assert payload["metadata"]["alternatives"] == alternatives
# #endregion test_permission_denied_payload_with_alternatives
# ── Unknown/null tool ─────────────────────────────────────────────────
# #region test_null_tool_name_handled [C:2] [TYPE Function]
def test_null_tool_name_handled_gracefully():
"""Null tool_name should produce 'unknown_action' fallback."""
contract = build_confirmation_contract_v2(None)
assert contract["operation"] == "unknown_action"
assert contract["risk_level"] == "safe"
# #endregion test_null_tool_name_handled
# #region test_execute_migration_is_guarded [C:2] [TYPE Function]
def test_execute_migration_is_guarded():
"""execute_migration should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("execute_migration", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_execute_migration_is_guarded
# #region test_commit_changes_is_guarded [C:2] [TYPE Function]
def test_commit_changes_is_guarded():
"""commit_changes should be classified as guarded (write)."""
contract = build_confirmation_contract_v2("commit_changes", {})
assert contract["risk"] == "write"
assert contract["risk_level"] == "guarded"
# #endregion test_commit_changes_is_guarded
# #endregion Test.Agent.ConfirmationV2

View File

@@ -0,0 +1,218 @@
# backend/tests/test_agent/test_agent_context.py
# #region Test.Agent.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation,uicontext]
# @BRIEF Contract tests for UIContext validation — enum checks, size limits, field lengths, boundary values.
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
# @TEST_FIXTURE: null -> returns {}
# @TEST_FIXTURE: dashboard+id+name -> passes
# @TEST_FIXTURE: malformed_objectType -> raises UIContextValidationError
# @TEST_FIXTURE: objectName>256 -> raises
# @TEST_FIXTURE: oversized>4KB -> raises
# @TEST_FIXTURE: contextVersion!=1 -> raises
# @TEST_FIXTURE: dataset_context -> passes
# @TEST_FIXTURE: migration_context -> passes
# @TEST_FIXTURE: non_numeric_objectId -> raises
# @TEST_FIXTURE: empty_route -> passes
import pytest
from src.agent._context import UIContextValidationError, validate_uicontext
# #region test_null_payload_returns_empty [C:2] [TYPE Function]
def test_null_payload_returns_empty_dict():
"""Null payloads should safely return an empty dict."""
assert validate_uicontext(None) == {}
# #endregion test_null_payload_returns_empty
# #region test_valid_dashboard_context_passes [C:2] [TYPE Function]
def test_valid_dashboard_context_passes():
"""Full dashboard UIContext with all fields should validate cleanly."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "Energy Report",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
result = validate_uicontext(payload)
assert result == payload
# #endregion test_valid_dashboard_context_passes
# #region test_valid_dataset_context_passes [C:2] [TYPE Function]
def test_valid_dataset_context_passes():
"""Dataset UIContext should pass validation."""
payload = {
"objectType": "dataset",
"objectId": "15",
"objectName": None,
"envId": "staging",
"route": "/datasets/15",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_dataset_context_passes
# #region test_valid_migration_context_passes [C:2] [TYPE Function]
def test_valid_migration_context_passes():
"""Migration UIContext should pass validation."""
payload = {
"objectType": "migration",
"objectId": None,
"objectName": None,
"envId": None,
"route": "/migrations",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_valid_migration_context_passes
# #region test_invalid_object_type_raises [C:2] [TYPE Function]
def test_invalid_object_type_raises_validation_error():
"""Unknown objectType values should be rejected."""
payload = {
"objectType": "chart",
"objectId": "42",
"envId": "ss-dev",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectType"):
validate_uicontext(payload)
# #endregion test_invalid_object_type_raises
# #region test_invalid_object_id_raises [C:2] [TYPE Function]
def test_non_numeric_object_id_raises():
"""ObjectId must be a numeric string or None."""
payload = {
"objectType": "dashboard",
"objectId": "abc-not-a-number",
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectId"):
validate_uicontext(payload)
# #endregion test_invalid_object_id_raises
# #region test_object_name_exceeds_limit_raises [C:2] [TYPE Function]
def test_object_name_exceeds_256_chars_raises():
"""ObjectName longer than 256 characters should be rejected."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 257,
"route": "/dashboards/42",
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="objectName"):
validate_uicontext(payload)
# #endregion test_object_name_exceeds_limit_raises
# #region test_object_name_at_boundary_passes [C:2] [TYPE Function]
def test_object_name_at_256_chars_passes():
"""ObjectName at exactly 256 characters should pass."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"objectName": "A" * 256,
"route": "/dashboards/42",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_object_name_at_boundary_passes
# #region test_invalid_context_version_raises [C:2] [TYPE Function]
def test_invalid_context_version_raises():
"""Only contextVersion=1 is currently supported."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 2,
}
with pytest.raises(UIContextValidationError, match="contextVersion"):
validate_uicontext(payload)
# #endregion test_invalid_context_version_raises
# #region test_missing_context_version_raises [C:2] [TYPE Function]
def test_missing_context_version_raises():
"""ContextVersion should always be present and equal 1."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
}
with pytest.raises(UIContextValidationError):
validate_uicontext(payload)
# #endregion test_missing_context_version_raises
# #region test_payload_exceeds_4kb_raises [C:2] [TYPE Function]
def test_payload_exceeds_4kb_raises():
"""Payloads larger than 4 KB should be rejected to prevent prompt injection."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/dashboards/42",
"contextVersion": 1,
# Create a field that pushes the serialized JSON over 4096 bytes
"padding": "X" * 4096,
}
with pytest.raises(UIContextValidationError, match="exceeds 4 KB"):
validate_uicontext(payload)
# #endregion test_payload_exceeds_4kb_raises
# #region test_route_exceeds_512_chars_raises [C:2] [TYPE Function]
def test_route_exceeds_512_chars_raises():
"""Route length should be capped at 512 characters."""
payload = {
"objectType": "dashboard",
"objectId": "42",
"route": "/" + "a" * 512,
"contextVersion": 1,
}
with pytest.raises(UIContextValidationError, match="route"):
validate_uicontext(payload)
# #endregion test_route_exceeds_512_chars_raises
# #region test_env_id_none_and_string_accepted [C:2] [TYPE Function]
def test_env_id_none_and_string_accepted():
"""envId should accept None and string values."""
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": None, "route": "/dashboards/42", "contextVersion": 1,
})["envId"] is None
assert validate_uicontext({
"objectType": "dashboard", "objectId": "42",
"envId": "ss-dev", "route": "/dashboards/42", "contextVersion": 1,
})["envId"] == "ss-dev"
# #endregion test_env_id_none_and_string_accepted
# #region test_without_object_type_passes [C:2] [TYPE Function]
def test_no_object_type_with_env_passes():
"""Context without objectType (general mode) should pass validation."""
payload = {
"objectType": None,
"objectId": None,
"envId": "ss-dev",
"route": "/settings",
"contextVersion": 1,
}
assert validate_uicontext(payload) == payload
# #endregion test_without_object_type_passes
# #endregion Test.Agent.Context

View File

@@ -0,0 +1,255 @@
# backend/tests/test_agent/test_agent_tool_filter.py
# #region Test.Agent.ToolFilter [C:3] [TYPE Module] [SEMANTICS test,agent-chat,tools,filter,pipeline,rbac]
# @BRIEF Contract tests for build_tool_pipeline and enforce_tool_permission — two-layer RBAC + context affinity.
# @RELATION BINDS_TO -> [AgentChat.ToolFilter]
# @TEST_FIXTURE: null_object_type -> all RBAC-allowed tools pass
# @TEST_FIXTURE: dashboard+admin -> all dashboard tools + capabilities kept
# @TEST_FIXTURE: dashboard+analyst -> admin-only tools removed from dashboard
# @TEST_FIXTURE: dataset+admin -> all dataset tools + capabilities kept
# @TEST_FIXTURE: dataset+viewer -> admin-only tools removed from dataset
# @TEST_FIXTURE: migration+admin -> all migration tools + capabilities kept
# @TEST_FIXTURE: unknown_object_type -> graceful fallback to full list
# @TEST_FIXTURE: invocation_guard_admin -> deploy_dashboard allowed for admin
# @TEST_FIXTURE: invocation_guard_viewer -> deploy_dashboard rejected for viewer
# @TEST_FIXTURE: unknown_tool_invocation -> always allowed
# @TEST_FIXTURE: show_capabilities_always_included -> mandatory tool
# @TEST_FIXTURE: idempotent -> build_tool_pipeline never mutates input list
from types import SimpleNamespace
from src.agent._tool_filter import (
_CONTEXT_TOOL_AFFINITY,
_TOOL_PERMISSIONS,
build_tool_pipeline,
enforce_tool_permission,
)
# #region _tools_helper [C:1] [TYPE Function] [SEMANTICS test,helper]
# @BRIEF Create SimpleNamespace-based tool mimics with .name attribute for pipeline tests.
def _tools(names: list[str]) -> list[SimpleNamespace]:
return [SimpleNamespace(name=name) for name in names]
# #endregion _tools_helper
# ── build_tool_pipeline ──────────────────────────────────────────────
# #region test_null_object_type_returns_all_rbac_allowed [C:2] [TYPE Function]
def test_null_object_type_returns_all_rbac_allowed_tools():
"""With no object_type, all tools pass except those blocked by RBAC (viewer)."""
tools = _tools(["search_dashboards", "deploy_dashboard", "show_capabilities"])
# Admin: all tools pass
result_admin = [t.name for t in build_tool_pipeline(tools, "admin", None)]
assert "deploy_dashboard" in result_admin
assert "search_dashboards" in result_admin
assert "show_capabilities" in result_admin
# Viewer: deploy_dashboard blocked by RBAC
result_viewer = [t.name for t in build_tool_pipeline(tools, "viewer", None)]
assert "deploy_dashboard" not in result_viewer
assert "search_dashboards" in result_viewer
assert "show_capabilities" in result_viewer
# #endregion test_null_object_type_returns_all_rbac_allowed
# #region test_dashboard_context_admin [C:2] [TYPE Function]
def test_dashboard_context_admin_keeps_affinity_tools():
"""Dashboard context + admin role: keep all dashboard tools + capabilities."""
tools = _tools([
"search_dashboards", "get_health_summary", "deploy_dashboard",
"run_llm_validation", "run_llm_documentation", "execute_migration",
"create_branch", "commit_changes", "show_capabilities",
# Non-dashboard tools (should be excluded)
"run_backup", "superset_execute_sql", "list_environments",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dashboard")]
assert "search_dashboards" in result
assert "get_health_summary" in result
assert "deploy_dashboard" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_dashboard_context_admin
# #region test_dashboard_context_viewer [C:2] [TYPE Function]
def test_dashboard_context_viewer_removes_admin_only():
"""Dashboard context + viewer: admin-only tools removed even from dashboard affinity."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "execute_migration",
"commit_changes", "run_llm_validation", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dashboard")]
assert "search_dashboards" in result
assert "run_llm_validation" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "execute_migration" not in result
assert "commit_changes" not in result
# #endregion test_dashboard_context_viewer
# #region test_dataset_context_admin [C:2] [TYPE Function]
def test_dataset_context_admin_keeps_dataset_tools():
"""Dataset context + admin: keep dataset affinity tools, exclude non-dataset."""
tools = _tools([
"superset_explore_database", "superset_format_sql", "superset_execute_sql",
"superset_audit_permissions", "superset_create_dataset",
"search_dashboards", "get_task_status", "list_environments",
"show_capabilities",
# Non-dataset tools
"deploy_dashboard", "run_backup", "start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "superset_format_sql" in result
assert "show_capabilities" in result
assert "deploy_dashboard" not in result
assert "run_backup" not in result
# #endregion test_dataset_context_admin
# #region test_dataset_context_viewer [C:2] [TYPE Function]
def test_dataset_context_viewer_removes_admin_tools():
"""Dataset context + viewer: admin-only tools in dataset affinity removed."""
tools = _tools([
"superset_explore_database", "superset_execute_sql",
"search_dashboards", "show_capabilities",
"start_maintenance",
])
result = [t.name for t in build_tool_pipeline(tools, "viewer", "dataset")]
assert "superset_explore_database" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
assert "start_maintenance" not in result
# #endregion test_dataset_context_viewer
# #region test_migration_context_admin [C:2] [TYPE Function]
def test_migration_context_admin_keeps_migration_tools():
"""Migration context + admin: keep migration affinity tools, exclude others."""
tools = _tools([
"execute_migration", "search_dashboards", "get_health_summary",
"deploy_dashboard", "list_environments", "show_capabilities",
# Non-migration tools
"run_backup", "superset_execute_sql",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "migration")]
assert "execute_migration" in result
assert "search_dashboards" in result
assert "show_capabilities" in result
assert "run_backup" not in result
assert "superset_execute_sql" not in result
# #endregion test_migration_context_admin
# #region test_unknown_object_type_falls_back [C:2] [TYPE Function]
def test_unknown_object_type_falls_back_to_full_list():
"""Unknown object_type should not filter — behaves like null context."""
tools = _tools([
"search_dashboards", "deploy_dashboard", "run_backup",
"superset_execute_sql", "show_capabilities",
])
result = [t.name for t in build_tool_pipeline(tools, "admin", "unknown_type")]
# All RBAC-allowed tools pass (admin sees all)
assert "search_dashboards" in result
assert "deploy_dashboard" in result
assert "run_backup" in result
assert "superset_execute_sql" in result
assert "show_capabilities" in result
# #endregion test_unknown_object_type_falls_back
# #region test_show_capabilities_always_included [C:2] [TYPE Function]
def test_show_capabilities_always_included():
"""show_capabilities must survive all filtering stages regardless of context."""
tools = _tools(["show_capabilities"])
# Must appear in any context+role combination
for obj_type in (None, "dashboard", "dataset", "migration"):
for role in ("admin", "editor", "viewer"):
result = [t.name for t in build_tool_pipeline(tools, role, obj_type)]
assert "show_capabilities" in result, (
f"show_capabilities missing for role={role}, object_type={obj_type}"
)
# #endregion test_show_capabilities_always_included
# #region test_pipeline_is_idempotent [C:2] [TYPE Function]
def test_pipeline_does_not_mutate_input_list():
"""build_tool_pipeline must return a new list and not mutate the input."""
tools = _tools(["search_dashboards", "show_capabilities"])
original_ids = [id(t) for t in tools]
_ = build_tool_pipeline(tools, "admin", "dashboard")
# Input list unchanged
assert [id(t) for t in tools] == original_ids
assert len(tools) == 2
# #endregion test_pipeline_is_idempotent
# ── enforce_tool_permission ──────────────────────────────────────────
# #region test_invocation_guard_admin_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_admin_allowed():
"""Admin role should be allowed to invoke all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "admin") is True, (
f"Admin should be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_admin_allowed
# #region test_invocation_guard_viewer_denied [C:2] [TYPE Function]
def test_enforce_tool_permission_viewer_denied():
"""Viewer role should be denied for all restricted tools."""
restricted = ["deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance"]
for tool_name in restricted:
assert enforce_tool_permission(tool_name, "viewer") is False, (
f"Viewer should NOT be allowed to invoke '{tool_name}'"
)
# #endregion test_invocation_guard_viewer_denied
# #region test_invocation_guard_unknown_tool_allowed [C:2] [TYPE Function]
def test_enforce_tool_permission_unknown_tool_always_allowed():
"""Unknown tools (not in _TOOL_PERMISSIONS) should always be allowed."""
assert enforce_tool_permission("search_dashboards", "viewer") is True
assert enforce_tool_permission("show_capabilities", "viewer") is True
assert enforce_tool_permission("nonexistent_tool", "viewer") is True
# #endregion test_invocation_guard_unknown_tool_allowed
# #region test_context_affinity_coverage [C:2] [TYPE Function]
def test_context_affinity_maps_exist_for_all_expected_types():
"""Verify that all three known object types have affinity mappings."""
assert "dashboard" in _CONTEXT_TOOL_AFFINITY
assert "dataset" in _CONTEXT_TOOL_AFFINITY
assert "migration" in _CONTEXT_TOOL_AFFINITY
# Each mapping should have at least 3 tools
for obj_type in ("dashboard", "dataset", "migration"):
assert len(_CONTEXT_TOOL_AFFINITY[obj_type]) >= 3, (
f"Context affinity for '{obj_type}' should have ≥3 tools"
)
# #endregion test_context_affinity_coverage
# #region test_rbac_maps_exist_for_write_tools [C:2] [TYPE Function]
def test_rbac_permissions_for_write_tools():
"""Verify all admin-only tools are explicitly listed in _TOOL_PERMISSIONS."""
expected_admin_tools = [
"deploy_dashboard", "commit_changes", "create_branch",
"run_backup", "execute_migration", "start_maintenance", "end_maintenance",
]
for tool_name in expected_admin_tools:
assert tool_name in _TOOL_PERMISSIONS, (
f"'{tool_name}' should be in _TOOL_PERMISSIONS"
)
assert "admin" in _TOOL_PERMISSIONS[tool_name]
# #endregion test_rbac_permissions_for_write_tools
# #endregion Test.Agent.ToolFilter

View File

@@ -7,10 +7,10 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import json
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_async_iter(items):
@@ -24,15 +24,20 @@ def _make_async_iter(items):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
raise StopAsyncIteration from None
return AsyncIter(items)
def _skip_pipeline(results: list[str]) -> list[str]:
"""Filter out pipeline_result events emitted before the agent stream loop."""
return [r for r in results if json.loads(r).get("metadata", {}).get("type") != "pipeline_result"]
def _make_agent_mock(stream_events=None, raise_on_call=False):
"""Create a properly mocked agent for async iteration."""
agent = MagicMock()
if raise_on_call:
agent.astream_events = MagicMock(side_effect=stream_events if isinstance(stream_events, list) else stream_events)
agent.astream_events = MagicMock(side_effect=stream_events)
elif stream_events is not None:
agent.astream_events = MagicMock(return_value=_make_async_iter(stream_events))
else:
@@ -160,7 +165,7 @@ class TestConfirmationMetadata:
assert meta["risk_level"] == "safe"
def test_fast_resume_deny_closes_without_langgraph(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from src.agent._confirmation import _pending_confirmations, handle_resume
_pending_confirmations["conv-fast-deny"] = {
"tool_name": "list_environments",
@@ -175,7 +180,7 @@ class TestConfirmationMetadata:
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
def test_fast_resume_confirm_executes_tool_directly(self):
from src.agent._confirmation import handle_resume, _pending_confirmations
from src.agent._confirmation import _pending_confirmations, handle_resume
tool = MagicMock()
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
@@ -188,7 +193,7 @@ class TestConfirmationMetadata:
with patch("src.agent._confirmation.find_tool", return_value=tool), \
patch("src.agent._confirmation._format_tool_output_via_llm") as mock_format:
# Make the mock format helper yield nothing — don't need LLM in unit test
async def _empty_format(*args, **kwargs):
async def _empty_format(*_args, **_kwargs):
return
yield # pragma: no cover — async generator requires at least one yield
mock_format.side_effect = _empty_format
@@ -205,7 +210,7 @@ class TestConfirmationMetadata:
class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_concurrent_send(self, mock_request):
from src.agent.app import agent_handler, _user_locks
from src.agent.app import _user_locks, agent_handler
# Handler resolves user_id as "admin" when no user_id_str or JWT is provided
_user_locks["admin"] = True
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
@@ -215,7 +220,7 @@ class TestAgentHandler:
@pytest.mark.asyncio
async def test_handles_file_too_large(self, mock_request):
from src.agent.app import agent_handler, MAX_FILE_SIZE_BYTES
from src.agent.app import MAX_FILE_SIZE_BYTES, agent_handler
message = {"text": "analyze", "files": ["fake_path"]}
with patch("os.path.exists", return_value=True), \
patch("os.path.getsize", return_value=MAX_FILE_SIZE_BYTES + 1):
@@ -267,8 +272,9 @@ class TestAgentHandler:
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) > 0
token_data = json.loads(results[0])
filtered = _skip_pipeline(results)
assert len(filtered) > 0
token_data = json.loads(filtered[0])
assert token_data["metadata"]["type"] == "stream_token"
@pytest.mark.asyncio
@@ -297,7 +303,8 @@ class TestAgentHandler:
"ss-dev",
)]
assert len(results) == 1
# pipeline_result + stream_token = 2 events
assert len(results) == 2
messages_arg = agent.astream_events.call_args.args[0]["messages"]
llm_text = messages_arg[0].content
assert "[RUNTIME CONTEXT]" in llm_text
@@ -320,9 +327,10 @@ class TestAgentHandler:
patch("src.agent.app.save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 2
assert json.loads(results[0])["metadata"]["type"] == "tool_start"
assert json.loads(results[1])["metadata"]["type"] == "tool_end"
filtered = _skip_pipeline(results)
assert len(filtered) == 2
assert json.loads(filtered[0])["metadata"]["type"] == "tool_start"
assert json.loads(filtered[1])["metadata"]["type"] == "tool_end"
@pytest.mark.asyncio
async def test_tool_error_event(self, mock_request):
@@ -337,20 +345,22 @@ class TestAgentHandler:
patch("src.agent.app.save_conversation", AsyncMock()), \
patch("src.agent.app.log_tool_event", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
assert json.loads(results[0])["metadata"]["type"] == "tool_error"
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert json.loads(filtered[0])["metadata"]["type"] == "tool_error"
@pytest.mark.asyncio
async def test_output_parser_exception_retry(self, mock_request):
from src.agent.app import agent_handler
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
mock_stream_after = _make_async_iter([
{"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="retried")}}
])
call_count = [0]
def mock_astream(*args, **kwargs):
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise OutputParserException("bad output")
@@ -363,16 +373,18 @@ class TestAgentHandler:
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) > 0
filtered = _skip_pipeline(results)
assert len(filtered) > 0
@pytest.mark.asyncio
async def test_output_parser_exception_final_failure(self, mock_request):
from src.agent.app import agent_handler
from langchain_core.exceptions import OutputParserException
from src.agent.app import agent_handler
call_count = [0]
def mock_astream(*args, **kwargs):
def mock_astream(*_args, **_kwargs):
call_count[0] += 1
raise OutputParserException(f"bad {call_count[0]}")
@@ -383,15 +395,16 @@ class TestAgentHandler:
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_MALFORMED_OUTPUT"
@pytest.mark.asyncio
async def test_non_llm_error_saves_conversation(self, mock_request):
from src.agent.app import agent_handler
def mock_astream(*args, **kwargs):
def mock_astream(*_args, **_kwargs):
raise RuntimeError("API connection error")
agent = MagicMock()
@@ -401,17 +414,75 @@ class TestAgentHandler:
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
# Handler catches RuntimeError, yields an error chunk, and saves conversation
# Handler catches RuntimeError, yields a pipeline result first, then error chunk
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
assert len(results) == 1
data = json.loads(results[0])
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limit_error_yields_code(self, mock_request):
"""RateLimitError (429) yields LLM_RATE_LIMITED, not guardrails card."""
from openai import RateLimitError
from src.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RateLimitError(
"429 quota exceeded",
response=MagicMock(status_code=429),
body={"error": {"message": "quota exceeded"}},
)
agent = MagicMock()
agent.astream_events = mock_astream
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
assert data["metadata"]["code"] == "LLM_RATE_LIMITED"
assert "Превышен лимит" in data["content"]
# save_conversation should be called even on error
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_generic_exception_with_llm_keyword_yields_error_not_guardrails(self, mock_request):
"""Generic exception containing 'quota' keyword yields error, not confirmation."""
from src.agent.app import agent_handler
def mock_astream(*_args, **_kwargs):
raise RuntimeError("All codex accounts reached configured quota threshold")
agent = MagicMock()
agent.astream_events = mock_astream
# Even though aget_state may return a truthy 'next', the error
# contains LLM keywords and should skip the HITL recovery path.
agent.aget_state = AsyncMock(return_value=MagicMock(next=("tools",)))
save_mock = AsyncMock()
with patch("src.agent.app.create_agent", return_value=agent), \
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", save_mock):
results = [r async for r in agent_handler("hello", [], mock_request, None, None)]
filtered = _skip_pipeline(results)
assert len(filtered) == 1
data = json.loads(filtered[0])
# Must be PROCESSING_ERROR (not confirm_required)
assert data["metadata"]["code"] == "PROCESSING_ERROR"
save_mock.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_jwt_passes_gracefully(self):
from src.agent.app import agent_handler
from jose import JWTError
from src.agent.app import agent_handler
req = MagicMock()
req.headers = {"authorization": "Bearer invalid.jwt"}
mock_event_stream = [
@@ -423,7 +494,8 @@ class TestAgentHandler:
patch("src.agent.app.get_all_tools", return_value=[]), \
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], req, None, None)]
assert len(results) == 1
filtered = _skip_pipeline(results)
assert len(filtered) == 1
@pytest.mark.asyncio
async def test_valid_user_jwt_with_audience_is_kept_for_tool_auth(self, mock_request):
@@ -442,7 +514,8 @@ class TestAgentHandler:
patch("src.agent.app.save_conversation", AsyncMock()):
results = [r async for r in agent_handler("hi", [], mock_request, None, None, None, token)]
assert len(results) == 1
filtered = _skip_pipeline(results)
assert len(filtered) == 1
assert get_user_jwt() == token
# #endregion test_agent_handler
@@ -559,11 +632,12 @@ class TestFileUploadParsing:
])
mock_create.return_value = agent
results = [r async for r in agent_handler(message, [], mock_request, None, None)]
assert len(results) >= 2
# First result is file upload metadata, second is stream token
# Now: file_uploaded, pipeline_result, stream_token (3 events)
assert len(results) >= 3
# First result is file upload metadata, third (after pipeline) is stream token
file_data = json.loads(results[0])
assert file_data["metadata"]["type"] == "file_uploaded"
token_data = json.loads(results[1])
token_data = json.loads(results[2])
assert token_data["metadata"]["type"] == "stream_token"
# #endregion test_file_upload_parsing