437 lines
14 KiB
Python
437 lines
14 KiB
Python
"""Unit tests for the automation engine (U7, R13-R17).
|
|
|
|
Tests the trigger evaluation, action dispatch, retry logic, and webhook
|
|
handling. These tests do NOT require PostgreSQL — they mock the repo/service
|
|
interfaces to test the engine logic in isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from agentkit.bitable.automation import AutomationEngine, generate_webhook_token
|
|
from agentkit.bitable.models import (
|
|
AutomationRule,
|
|
AutomationStatus,
|
|
AutomationTriggerType,
|
|
)
|
|
|
|
|
|
def _make_rule(
|
|
rule_id: str = "a1",
|
|
table_id: str = "t1",
|
|
trigger_type: str = "record_created",
|
|
action_type: str = "send_webhook",
|
|
enabled: bool = True,
|
|
webhook_token: str | None = None,
|
|
field_ids: list[str] | None = None,
|
|
action_config: dict | None = None,
|
|
) -> AutomationRule:
|
|
"""Build an AutomationRule for testing."""
|
|
trig_cfg: dict = {"type": trigger_type}
|
|
if field_ids:
|
|
trig_cfg["field_ids"] = field_ids
|
|
act_cfg = action_config or {"type": action_type, "url": "https://example.com/hook"}
|
|
return AutomationRule(
|
|
id=rule_id,
|
|
table_id=table_id,
|
|
name=f"Rule {rule_id}",
|
|
trigger_config=trig_cfg,
|
|
action_config=act_cfg,
|
|
enabled=enabled,
|
|
webhook_token=webhook_token,
|
|
)
|
|
|
|
|
|
def _make_mock_service() -> tuple[MagicMock, MagicMock]:
|
|
"""Create a mock service with a mock repo for the engine."""
|
|
service = MagicMock()
|
|
repo = MagicMock()
|
|
service.repo = repo
|
|
# Async methods on repo
|
|
repo.list_automations_for_trigger = AsyncMock(return_value=[])
|
|
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
|
|
repo.create_automation_log = AsyncMock(return_value="log1")
|
|
repo.update_automation_log = AsyncMock(return_value=None)
|
|
# Async methods on service
|
|
service.create_record = AsyncMock(return_value=MagicMock(id="r_new"))
|
|
service.update_record_values = AsyncMock(return_value=None)
|
|
return service, repo
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Trigger evaluation (R13)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fire_trigger_matches_record_created() -> None:
|
|
"""record_created trigger fires matching rules."""
|
|
service, repo = _make_mock_service()
|
|
rule = _make_rule(trigger_type="record_created")
|
|
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.fire_trigger(
|
|
table_id="t1",
|
|
trigger_type=AutomationTriggerType.record_created,
|
|
record_id="r1",
|
|
record_values={"f1": "value"},
|
|
)
|
|
assert matched == 1
|
|
repo.list_automations_for_trigger.assert_awaited_once_with(
|
|
table_id="t1", trigger_type="record_created"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fire_trigger_skips_disabled_rules() -> None:
|
|
"""Disabled rules don't fire."""
|
|
service, repo = _make_mock_service()
|
|
rule = _make_rule(enabled=False)
|
|
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.fire_trigger(
|
|
table_id="t1",
|
|
trigger_type=AutomationTriggerType.record_created,
|
|
)
|
|
assert matched == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fire_trigger_record_updated_field_filter() -> None:
|
|
"""record_updated with field_ids filter only fires on overlap."""
|
|
service, repo = _make_mock_service()
|
|
rule_watching_f1 = _make_rule(trigger_type="record_updated", field_ids=["f1"])
|
|
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_watching_f1])
|
|
|
|
engine = AutomationEngine(service)
|
|
# Changed f2 only — rule watches f1 → no match
|
|
matched = await engine.fire_trigger(
|
|
table_id="t1",
|
|
trigger_type=AutomationTriggerType.record_updated,
|
|
changed_field_ids=["f2"],
|
|
)
|
|
assert matched == 0
|
|
|
|
# Changed f1 → match
|
|
matched = await engine.fire_trigger(
|
|
table_id="t1",
|
|
trigger_type=AutomationTriggerType.record_updated,
|
|
changed_field_ids=["f1", "f2"],
|
|
)
|
|
assert matched == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fire_trigger_no_field_filter_matches_any() -> None:
|
|
"""record_updated without field_ids fires on any update."""
|
|
service, repo = _make_mock_service()
|
|
rule_no_filter = _make_rule(trigger_type="record_updated", field_ids=[])
|
|
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_no_filter])
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.fire_trigger(
|
|
table_id="t1",
|
|
trigger_type=AutomationTriggerType.record_updated,
|
|
changed_field_ids=["f_anything"],
|
|
)
|
|
assert matched == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhook inbound (R16)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_webhook_matches_token() -> None:
|
|
"""Inbound webhook fires the rule with matching token."""
|
|
service, repo = _make_mock_service()
|
|
rule = _make_rule(webhook_token="whk_test123")
|
|
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.handle_webhook("whk_test123", {"event": "test"})
|
|
assert matched is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_webhook_unknown_token() -> None:
|
|
"""Unknown webhook token returns False (no match)."""
|
|
service, repo = _make_mock_service()
|
|
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.handle_webhook("whk_unknown", {"event": "test"})
|
|
assert matched is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_webhook_disabled_rule() -> None:
|
|
"""Disabled rules don't fire via webhook."""
|
|
service, repo = _make_mock_service()
|
|
rule = _make_rule(enabled=False, webhook_token="whk_test")
|
|
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
|
|
|
|
engine = AutomationEngine(service)
|
|
matched = await engine.handle_webhook("whk_test", {})
|
|
assert matched is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action execution (R14)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_create_record_resolves_trigger_placeholders() -> None:
|
|
"""create_record action resolves @trigger.X placeholders."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r_trigger",
|
|
"record_values": {"f_name": "Alice"},
|
|
"action_config": {
|
|
"type": "create_record",
|
|
"target_table_id": "t2",
|
|
"values": {
|
|
"f_target_name": "@trigger.f_name",
|
|
"f_static": "static_value",
|
|
},
|
|
},
|
|
}
|
|
await engine._execute_action(job)
|
|
service.create_record.assert_awaited_once()
|
|
args, kwargs = service.create_record.call_args
|
|
assert args[0] == "t2" # target_table_id
|
|
resolved = args[1]
|
|
assert resolved["f_target_name"] == "Alice"
|
|
assert resolved["f_static"] == "static_value"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_update_record_defaults_to_trigger_record() -> None:
|
|
"""update_record defaults target_record_id to the trigger record."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_updated",
|
|
"record_id": "r_trigger",
|
|
"record_values": {"f_status": "done"},
|
|
"action_config": {
|
|
"type": "update_record",
|
|
"values": {"f_log": "completed"},
|
|
},
|
|
}
|
|
await engine._execute_action(job)
|
|
service.update_record_values.assert_awaited_once_with("r_trigger", {"f_log": "completed"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_send_webhook_posts_payload(httpx_mock, monkeypatch) -> None:
|
|
"""send_webhook POSTs JSON to the configured URL."""
|
|
# ponytail: uses httpx_mock fixture from pytest-httpx if available;
|
|
# otherwise this test is skipped.
|
|
pytest.importorskip("pytest_httpx")
|
|
# Bypass SSRF host check — this test validates the POST behavior, not the guard.
|
|
monkeypatch.setattr("agentkit.bitable.automation._assert_safe_host", lambda _host: None)
|
|
httpx_mock.add_response(url="https://example.com/hook", status_code=200)
|
|
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {"f1": "val"},
|
|
"action_config": {
|
|
"type": "send_webhook",
|
|
"url": "https://example.com/hook",
|
|
},
|
|
}
|
|
await engine._execute_action(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_unknown_type_raises() -> None:
|
|
"""Unknown action type raises ValueError."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {},
|
|
"action_config": {"type": "invalid_action"},
|
|
}
|
|
with pytest.raises(ValueError, match="Unknown action type"):
|
|
await engine._execute_action(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_ssrf_guard_blocks_loopback() -> None:
|
|
"""send_webhook rejects loopback URLs (SSRF protection, P1)."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {"f1": "val"},
|
|
"action_config": {
|
|
"type": "send_webhook",
|
|
"url": "http://127.0.0.1:8080/internal",
|
|
},
|
|
}
|
|
with pytest.raises(ValueError, match="Disallowed|unsafe|private|loopback"):
|
|
await engine._execute_action(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_ssrf_guard_blocks_private_ip() -> None:
|
|
"""send_webhook rejects RFC1918 private IPs (SSRF protection, P1)."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a2",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {},
|
|
"action_config": {
|
|
"type": "send_webhook",
|
|
"url": "http://10.0.0.1/admin",
|
|
},
|
|
}
|
|
with pytest.raises(ValueError, match="Disallowed|unsafe|private"):
|
|
await engine._execute_action(job)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_ssrf_guard_blocks_non_http_scheme() -> None:
|
|
"""send_webhook rejects non-HTTP(S) schemes (file://, gopher://, etc.)."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
job = {
|
|
"automation_id": "a3",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {},
|
|
"action_config": {
|
|
"type": "send_webhook",
|
|
"url": "file:///etc/passwd",
|
|
},
|
|
}
|
|
with pytest.raises(ValueError, match="scheme"):
|
|
await engine._execute_action(job)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Retry logic (R15)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_succeeds_on_second_attempt(monkeypatch) -> None:
|
|
"""Action that fails once then succeeds is logged correctly."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
# Make _execute_action fail once, then succeed
|
|
call_count = [0]
|
|
|
|
async def _flaky(job):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
raise RuntimeError("transient failure")
|
|
|
|
monkeypatch.setattr(engine, "_execute_action", _flaky)
|
|
# Speed up retries
|
|
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {},
|
|
"action_config": {"type": "send_webhook", "url": "x"},
|
|
}
|
|
await engine._execute_with_retry(job)
|
|
assert call_count[0] == 2 # Failed once, succeeded on second
|
|
# Two log entries created (one per attempt)
|
|
assert repo.create_automation_log.await_count == 2
|
|
# Final log status is success
|
|
repo.update_automation_log.assert_awaited_with(
|
|
log_id="log1",
|
|
status=AutomationStatus.success,
|
|
completed_at=repo.update_automation_log.await_args.kwargs["completed_at"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_exhausted_after_3_attempts(monkeypatch) -> None:
|
|
"""Action that always fails is retried 3 times then logged as error."""
|
|
service, repo = _make_mock_service()
|
|
engine = AutomationEngine(service)
|
|
|
|
async def _always_fail(job):
|
|
raise RuntimeError("permanent failure")
|
|
|
|
monkeypatch.setattr(engine, "_execute_action", _always_fail)
|
|
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
|
|
|
|
job = {
|
|
"automation_id": "a1",
|
|
"table_id": "t1",
|
|
"trigger_type": "record_created",
|
|
"record_id": "r1",
|
|
"record_values": {},
|
|
"action_config": {"type": "send_webhook", "url": "x"},
|
|
}
|
|
await engine._execute_with_retry(job)
|
|
assert repo.create_automation_log.await_count == 3 # 3 attempts
|
|
# Last log status is error
|
|
last_call = repo.update_automation_log.await_args_list[-1]
|
|
assert last_call.kwargs["status"] == AutomationStatus.error
|
|
assert "permanent failure" in last_call.kwargs["error_message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhook token generation (R16)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_generate_webhook_token_format() -> None:
|
|
"""Webhook tokens have the whk_ prefix and sufficient entropy."""
|
|
token = generate_webhook_token()
|
|
assert token.startswith("whk_")
|
|
# token_urlsafe(32) produces ~43 chars; with prefix, ~47
|
|
assert len(token) > 40
|
|
|
|
|
|
def test_generate_webhook_token_uniqueness() -> None:
|
|
"""Two generated tokens are different (non-deterministic)."""
|
|
tokens = {generate_webhook_token() for _ in range(10)}
|
|
assert len(tokens) == 10
|