336 lines
12 KiB
Python
336 lines
12 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from unittest.mock import AsyncMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from yuxi.channels.adapters.slack.approval import (
|
||
|
|
ApprovalManager,
|
||
|
|
ApprovalRequest,
|
||
|
|
ApprovalStatus,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.slack.approval_native import (
|
||
|
|
ExecApprovalConfig,
|
||
|
|
is_authorized_sender,
|
||
|
|
normalize_approver_id,
|
||
|
|
resolve_origin_target,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestApprovalStatus:
|
||
|
|
def test_values(self):
|
||
|
|
assert ApprovalStatus.PENDING == "pending"
|
||
|
|
assert ApprovalStatus.APPROVED == "approved"
|
||
|
|
assert ApprovalStatus.REJECTED == "rejected"
|
||
|
|
assert ApprovalStatus.EXPIRED == "expired"
|
||
|
|
assert ApprovalStatus.EXECUTED == "executed"
|
||
|
|
assert ApprovalStatus.FAILED == "failed"
|
||
|
|
|
||
|
|
|
||
|
|
class TestApprovalRequest:
|
||
|
|
def test_creation(self):
|
||
|
|
req = ApprovalRequest(
|
||
|
|
approval_id="req001",
|
||
|
|
title="Test Approval",
|
||
|
|
detail="Approve this action",
|
||
|
|
command="/do_something",
|
||
|
|
chat_id="C001",
|
||
|
|
message_ts="123.456",
|
||
|
|
created_by="U001",
|
||
|
|
)
|
||
|
|
assert req.approval_id == "req001"
|
||
|
|
assert req.title == "Test Approval"
|
||
|
|
assert req.detail == "Approve this action"
|
||
|
|
assert req.command == "/do_something"
|
||
|
|
assert req.chat_id == "C001"
|
||
|
|
assert req.message_ts == "123.456"
|
||
|
|
assert req.created_by == "U001"
|
||
|
|
assert req.status == ApprovalStatus.PENDING
|
||
|
|
assert req.approved_by == ""
|
||
|
|
assert req.exec_result == ""
|
||
|
|
assert req.exec_error == ""
|
||
|
|
|
||
|
|
def test_default_values(self):
|
||
|
|
req = ApprovalRequest(
|
||
|
|
approval_id="req002",
|
||
|
|
title="Test",
|
||
|
|
detail="Desc",
|
||
|
|
command="/cmd",
|
||
|
|
chat_id="C001",
|
||
|
|
message_ts="123.456",
|
||
|
|
)
|
||
|
|
assert req.created_by == ""
|
||
|
|
assert req.approved_by == ""
|
||
|
|
assert req.status == ApprovalStatus.PENDING
|
||
|
|
assert req.exec_result == ""
|
||
|
|
assert req.exec_error == ""
|
||
|
|
|
||
|
|
def test_is_pending(self):
|
||
|
|
req = ApprovalRequest("r1", "T", "D", "/c", "C1", "ts")
|
||
|
|
assert req.is_pending is True
|
||
|
|
req.status = ApprovalStatus.APPROVED
|
||
|
|
assert req.is_pending is False
|
||
|
|
|
||
|
|
def test_is_resolved(self):
|
||
|
|
req = ApprovalRequest("r1", "T", "D", "/c", "C1", "ts")
|
||
|
|
assert req.is_resolved is False
|
||
|
|
req.status = ApprovalStatus.APPROVED
|
||
|
|
assert req.is_resolved is True
|
||
|
|
req.status = ApprovalStatus.REJECTED
|
||
|
|
assert req.is_resolved is True
|
||
|
|
req.status = ApprovalStatus.EXPIRED
|
||
|
|
assert req.is_resolved is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestApprovalManager:
|
||
|
|
@pytest.fixture
|
||
|
|
def manager(self):
|
||
|
|
return ApprovalManager(ttl_seconds=600)
|
||
|
|
|
||
|
|
def test_create_approval(self, manager):
|
||
|
|
req = manager.create_approval("Title", "Detail", "/cmd", "C001", "123.456", "U001")
|
||
|
|
assert req.title == "Title"
|
||
|
|
assert req.status == ApprovalStatus.PENDING
|
||
|
|
assert len(req.approval_id) == 12
|
||
|
|
assert manager.get_approval(req.approval_id) is req
|
||
|
|
|
||
|
|
def test_create_approval_unique_ids(self, manager):
|
||
|
|
req1 = manager.create_approval("T1", "D1", "/c1", "C1", "ts")
|
||
|
|
req2 = manager.create_approval("T2", "D2", "/c2", "C2", "ts")
|
||
|
|
assert req1.approval_id != req2.approval_id
|
||
|
|
|
||
|
|
def test_get_approval_not_found(self, manager):
|
||
|
|
assert manager.get_approval("nonexistent") is None
|
||
|
|
|
||
|
|
def test_approve(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
updated = manager.approve(req.approval_id, "U001")
|
||
|
|
assert updated is not None
|
||
|
|
assert updated.status == ApprovalStatus.APPROVED
|
||
|
|
assert updated.approved_by == "U001"
|
||
|
|
|
||
|
|
def test_approve_not_found(self, manager):
|
||
|
|
assert manager.approve("nonexistent") is None
|
||
|
|
|
||
|
|
def test_approve_already_resolved(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id, "U001")
|
||
|
|
result = manager.approve(req.approval_id, "U002")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_reject(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
updated = manager.reject(req.approval_id)
|
||
|
|
assert updated is not None
|
||
|
|
assert updated.status == ApprovalStatus.REJECTED
|
||
|
|
|
||
|
|
def test_reject_not_found(self, manager):
|
||
|
|
assert manager.reject("nonexistent") is None
|
||
|
|
|
||
|
|
def test_mark_executed(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
updated = manager.mark_executed(req.approval_id, "Execution successful")
|
||
|
|
assert updated is not None
|
||
|
|
assert updated.status == ApprovalStatus.EXECUTED
|
||
|
|
assert updated.exec_result == "Execution successful"
|
||
|
|
|
||
|
|
def test_mark_executed_not_approved(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
result = manager.mark_executed(req.approval_id)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_mark_failed(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
updated = manager.mark_failed(req.approval_id, "Something went wrong")
|
||
|
|
assert updated is not None
|
||
|
|
assert updated.status == ApprovalStatus.FAILED
|
||
|
|
assert updated.exec_error == "Something went wrong"
|
||
|
|
|
||
|
|
def test_mark_failed_not_approved(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
result = manager.mark_failed(req.approval_id)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_execute_approval_success(self, manager):
|
||
|
|
async def handler(**kwargs):
|
||
|
|
return {"success": True}
|
||
|
|
|
||
|
|
manager.register_exec_handler("/cmd", handler)
|
||
|
|
req = manager.create_approval("T", "D", "/cmd arg1", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
|
||
|
|
result = await manager.execute_approval(req.approval_id)
|
||
|
|
assert result["success"] is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_execute_approval_no_handler(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/unknown_cmd", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
|
||
|
|
result = await manager.execute_approval(req.approval_id)
|
||
|
|
assert result["success"] is False
|
||
|
|
assert "No exec handler" in result["error"]
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_execute_approval_not_approved(self, manager):
|
||
|
|
req = manager.create_approval("T", "D", "/cmd", "C1", "ts")
|
||
|
|
result = await manager.execute_approval(req.approval_id)
|
||
|
|
assert result["success"] is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_execute_approval_handler_error(self, manager):
|
||
|
|
async def failing_handler(**kwargs):
|
||
|
|
raise ValueError("boom")
|
||
|
|
|
||
|
|
manager.register_exec_handler("/cmd", failing_handler)
|
||
|
|
req = manager.create_approval("T", "D", "/cmd", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
|
||
|
|
result = await manager.execute_approval(req.approval_id)
|
||
|
|
assert result["success"] is False
|
||
|
|
assert "boom" in result["error"]
|
||
|
|
|
||
|
|
def test_list_pending_all(self, manager):
|
||
|
|
manager.create_approval("T1", "D1", "/c1", "C1", "ts")
|
||
|
|
manager.create_approval("T2", "D2", "/c2", "C1", "ts")
|
||
|
|
pending = manager.list_pending()
|
||
|
|
assert len(pending) == 2
|
||
|
|
|
||
|
|
def test_list_pending_by_chat(self, manager):
|
||
|
|
manager.create_approval("T1", "D1", "/c1", "C1", "ts")
|
||
|
|
manager.create_approval("T2", "D2", "/c2", "C2", "ts")
|
||
|
|
pending = manager.list_pending(chat_id="C1")
|
||
|
|
assert len(pending) == 1
|
||
|
|
assert pending[0]["title"] == "T1"
|
||
|
|
|
||
|
|
def test_list_pending_excludes_resolved(self, manager):
|
||
|
|
req = manager.create_approval("T1", "D1", "/c1", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
pending = manager.list_pending()
|
||
|
|
assert len(pending) == 0
|
||
|
|
|
||
|
|
def test_cleanup_expired_resolved(self, manager):
|
||
|
|
manager._ttl_seconds = 0.01
|
||
|
|
req = manager.create_approval("T", "D", "/c", "C1", "ts")
|
||
|
|
req.created_at = datetime.now(UTC) - timedelta(seconds=10)
|
||
|
|
manager.reject(req.approval_id)
|
||
|
|
|
||
|
|
manager.create_approval("T2", "D2", "/c2", "C2", "ts")
|
||
|
|
manager._cleanup_expired()
|
||
|
|
assert manager.get_approval(req.approval_id) is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_execute_approval_not_found(self, manager):
|
||
|
|
result = await manager.execute_approval("nonexistent")
|
||
|
|
assert result["success"] is False
|
||
|
|
|
||
|
|
def test_register_exec_handler_prefix_match(self, manager):
|
||
|
|
async def handler(**kwargs):
|
||
|
|
return {"text": kwargs["command"]}
|
||
|
|
|
||
|
|
manager.register_exec_handler("/cmd", handler)
|
||
|
|
req = manager.create_approval("T", "D", "/cmd arg1", "C1", "ts")
|
||
|
|
manager.approve(req.approval_id)
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
result = asyncio.get_event_loop().run_until_complete(manager.execute_approval(req.approval_id))
|
||
|
|
assert result["success"] is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestNormalizeApproverId:
|
||
|
|
def test_slack_format(self):
|
||
|
|
assert normalize_approver_id("slack:U12345") == "U12345"
|
||
|
|
|
||
|
|
def test_mention_format(self):
|
||
|
|
assert normalize_approver_id("<@U12345>") == "U12345"
|
||
|
|
|
||
|
|
def test_plain_id(self):
|
||
|
|
assert normalize_approver_id("U12345") == "U12345"
|
||
|
|
|
||
|
|
def test_strips_whitespace(self):
|
||
|
|
assert normalize_approver_id(" U12345 ") == "U12345"
|
||
|
|
|
||
|
|
def test_unknown_format_passthrough(self):
|
||
|
|
assert normalize_approver_id("unknown_id") == "unknown_id"
|
||
|
|
|
||
|
|
|
||
|
|
class TestExecApprovalConfig:
|
||
|
|
def test_defaults(self):
|
||
|
|
config = ExecApprovalConfig()
|
||
|
|
assert config.enabled == "auto"
|
||
|
|
assert config.approvers == []
|
||
|
|
assert config.normalized_approvers == []
|
||
|
|
|
||
|
|
def test_with_approvers(self):
|
||
|
|
config = ExecApprovalConfig(approvers=["slack:U001", "U002", "<@U003>"])
|
||
|
|
assert config.normalized_approvers == ["U001", "U002", "U003"]
|
||
|
|
|
||
|
|
def test_is_approver_in_list(self):
|
||
|
|
config = ExecApprovalConfig(approvers=["U001", "U002"])
|
||
|
|
assert config.is_approver("U001") is True
|
||
|
|
assert config.is_approver("U003") is False
|
||
|
|
|
||
|
|
def test_is_approver_empty_list(self):
|
||
|
|
config = ExecApprovalConfig(approvers=[])
|
||
|
|
assert config.is_approver("U001") is True
|
||
|
|
|
||
|
|
def test_is_approver_with_format(self):
|
||
|
|
config = ExecApprovalConfig(approvers=["U001"])
|
||
|
|
assert config.is_approver("<@U001>") is True
|
||
|
|
assert config.is_approver("slack:U001") is True
|
||
|
|
|
||
|
|
def test_from_config_none(self):
|
||
|
|
config = ExecApprovalConfig.from_config(None)
|
||
|
|
assert config.enabled == "auto"
|
||
|
|
assert config.approvers == []
|
||
|
|
|
||
|
|
def test_from_config_empty(self):
|
||
|
|
config = ExecApprovalConfig.from_config({})
|
||
|
|
assert config.enabled == "auto"
|
||
|
|
assert config.approvers == []
|
||
|
|
|
||
|
|
def test_from_config_with_values(self):
|
||
|
|
config = ExecApprovalConfig.from_config(
|
||
|
|
{
|
||
|
|
"execApprovals": {
|
||
|
|
"enabled": "manual",
|
||
|
|
"approvers": ["U001", "U002"],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
assert config.enabled == "manual"
|
||
|
|
assert config.approvers == ["U001", "U002"]
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveOriginTarget:
|
||
|
|
def test_turn_source(self):
|
||
|
|
assert resolve_origin_target("turn", "session", "fallback") == "turn"
|
||
|
|
|
||
|
|
def test_session_target(self):
|
||
|
|
assert resolve_origin_target("", "session", "fallback") == "session"
|
||
|
|
|
||
|
|
def test_fallback_target(self):
|
||
|
|
assert resolve_origin_target("", "", "fallback") == "fallback"
|
||
|
|
|
||
|
|
def test_all_empty(self):
|
||
|
|
assert resolve_origin_target("", "", "") == ""
|
||
|
|
|
||
|
|
|
||
|
|
class TestIsAuthorizedSender:
|
||
|
|
def test_authorized(self):
|
||
|
|
config = ExecApprovalConfig(approvers=["U001"])
|
||
|
|
assert is_authorized_sender("U001", config) is True
|
||
|
|
|
||
|
|
def test_not_authorized(self):
|
||
|
|
config = ExecApprovalConfig(approvers=["U001"])
|
||
|
|
assert is_authorized_sender("U002", config) is False
|
||
|
|
|
||
|
|
def test_empty_approvers_authorizes_all(self):
|
||
|
|
config = ExecApprovalConfig(approvers=[])
|
||
|
|
assert is_authorized_sender("U001", config) is True
|