4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""Direct unit tests for integrations/aws/aws_sdk_client.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
|
|
|
from integrations.aws.aws_sdk_client import (
|
|
MAX_LIST_ITEMS,
|
|
_is_operation_allowed,
|
|
_sanitize_response,
|
|
execute_aws_sdk_call,
|
|
)
|
|
|
|
|
|
class TestIsOperationAllowedAllowlist:
|
|
@pytest.mark.parametrize(
|
|
"op",
|
|
[
|
|
"describe_instances",
|
|
"describe_db_instances",
|
|
"get_role",
|
|
"get_function_configuration",
|
|
"list_functions",
|
|
"list_clusters",
|
|
"head_object",
|
|
"head_bucket",
|
|
"query",
|
|
"scan",
|
|
"select_object_content",
|
|
"batch_get_item",
|
|
"lookup_events",
|
|
],
|
|
)
|
|
def test_allowed_operations(self, op: str) -> None:
|
|
allowed, reason = _is_operation_allowed(op)
|
|
assert allowed is True, f"{op} should be allowed: {reason}"
|
|
assert reason == "Operation allowed"
|
|
|
|
|
|
class TestIsOperationAllowedBlocklist:
|
|
@pytest.mark.parametrize(
|
|
"op",
|
|
[
|
|
"delete_bucket",
|
|
"remove_tags",
|
|
"update_function_configuration",
|
|
"put_object",
|
|
"create_stack",
|
|
"modify_db_instance",
|
|
"terminate_instances",
|
|
"stop_instances",
|
|
"start_instances",
|
|
"reboot_instances",
|
|
"attach_volume",
|
|
"detach_volume",
|
|
"associate_route_table",
|
|
"disassociate_route_table",
|
|
],
|
|
)
|
|
def test_blocked_operations(self, op: str) -> None:
|
|
allowed, reason = _is_operation_allowed(op)
|
|
assert allowed is False, f"{op} should be blocked"
|
|
assert "blocked pattern" in reason
|
|
|
|
def test_blocklist_takes_precedence_over_allowlist(self) -> None:
|
|
allowed, _ = _is_operation_allowed("get_and_delete_item")
|
|
assert allowed is False
|
|
|
|
def test_unknown_operation_rejected(self) -> None:
|
|
allowed, reason = _is_operation_allowed("invoke_function")
|
|
assert allowed is False
|
|
assert "does not match any allowed patterns" in reason
|
|
|
|
def test_case_insensitivity(self) -> None:
|
|
allowed, _ = _is_operation_allowed("Describe_Instances")
|
|
assert allowed is True
|
|
|
|
allowed, _ = _is_operation_allowed("Delete_Bucket")
|
|
assert allowed is False
|
|
|
|
|
|
class TestSanitizeResponseDatetime:
|
|
def test_datetime_converted_to_iso(self) -> None:
|
|
dt = datetime(2024, 6, 15, 12, 30, 0, tzinfo=UTC)
|
|
assert _sanitize_response(dt) == "2024-06-15T12:30:00+00:00"
|
|
|
|
def test_datetime_nested_in_dict(self) -> None:
|
|
dt = datetime(2024, 1, 1, tzinfo=UTC)
|
|
result = _sanitize_response({"LaunchTime": dt})
|
|
assert result["LaunchTime"] == "2024-01-01T00:00:00+00:00"
|
|
|
|
|
|
class TestSanitizeResponseBytes:
|
|
def test_bytes_replaced_with_placeholder(self) -> None:
|
|
result = _sanitize_response(b"\x00\x01\x02")
|
|
assert result == "<binary data: 3 bytes>"
|
|
|
|
def test_bytes_nested_in_dict(self) -> None:
|
|
result = _sanitize_response({"Body": b"hello"})
|
|
assert result["Body"] == "<binary data: 5 bytes>"
|
|
|
|
|
|
class TestSanitizeResponseDeepNesting:
|
|
def test_max_depth_reached(self) -> None:
|
|
default_max_depth = 10
|
|
data: dict = {"leaf": "value"}
|
|
for _ in range(default_max_depth + 2):
|
|
data = {"nested": data}
|
|
result = _sanitize_response(data)
|
|
|
|
current = result
|
|
for _ in range(default_max_depth + 1):
|
|
current = current["nested"]
|
|
assert current == "... (max depth reached)"
|
|
|
|
def test_custom_max_depth(self) -> None:
|
|
data: dict = {"leaf": "value"}
|
|
for _ in range(5):
|
|
data = {"nested": data}
|
|
result = _sanitize_response(data, max_depth=3)
|
|
|
|
current = result
|
|
for _ in range(4):
|
|
current = current["nested"]
|
|
assert current == "... (max depth reached)"
|
|
|
|
|
|
class TestSanitizeResponseOversizedLists:
|
|
def test_list_over_max_is_truncated(self) -> None:
|
|
big_list = list(range(MAX_LIST_ITEMS + 50))
|
|
result = _sanitize_response(big_list)
|
|
assert len(result) == MAX_LIST_ITEMS + 1
|
|
assert "50 more items truncated" in result[-1]
|
|
|
|
def test_list_exactly_at_max_not_truncated(self) -> None:
|
|
exact_list = list(range(MAX_LIST_ITEMS))
|
|
result = _sanitize_response(exact_list)
|
|
assert len(result) == MAX_LIST_ITEMS
|
|
|
|
def test_list_under_max_not_truncated(self) -> None:
|
|
small_list = [1, 2, 3]
|
|
result = _sanitize_response(small_list)
|
|
assert result == [1, 2, 3]
|
|
|
|
|
|
class TestSanitizeResponseMisc:
|
|
def test_none_returns_none(self) -> None:
|
|
assert _sanitize_response(None) is None
|
|
|
|
def test_primitive_passthrough(self) -> None:
|
|
assert _sanitize_response(42) == 42
|
|
assert _sanitize_response("hello") == "hello"
|
|
assert _sanitize_response(3.14) == 3.14
|
|
|
|
def test_response_metadata_stripped(self) -> None:
|
|
data = {
|
|
"Instances": [{"InstanceId": "i-1"}],
|
|
"ResponseMetadata": {"RequestId": "abc", "HTTPStatusCode": 200},
|
|
}
|
|
result = _sanitize_response(data)
|
|
assert "ResponseMetadata" not in result
|
|
assert result["Instances"] == [{"InstanceId": "i-1"}]
|
|
|
|
def test_tuple_handled(self) -> None:
|
|
result = _sanitize_response((1, 2, 3))
|
|
assert result == [1, 2, 3]
|
|
|
|
def test_complex_nested_structure(self) -> None:
|
|
dt = datetime(2024, 6, 1, tzinfo=UTC)
|
|
data = {
|
|
"Reservations": [
|
|
{
|
|
"Instances": [
|
|
{
|
|
"InstanceId": "i-abc",
|
|
"LaunchTime": dt,
|
|
"Tags": [{"Key": "Name", "Value": "web"}],
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"ResponseMetadata": {"HTTPStatusCode": 200},
|
|
}
|
|
result = _sanitize_response(data)
|
|
assert "ResponseMetadata" not in result
|
|
inst = result["Reservations"][0]["Instances"][0]
|
|
assert inst["InstanceId"] == "i-abc"
|
|
assert inst["LaunchTime"] == "2024-06-01T00:00:00+00:00"
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_boto3_client():
|
|
fake_client = MagicMock()
|
|
fake_client.meta.region_name = "us-east-1"
|
|
with patch("integrations.aws.aws_sdk_client.boto3.client", return_value=fake_client):
|
|
yield fake_client
|
|
|
|
|
|
class TestExecuteAwsSdkCallHappyPath:
|
|
def test_success_with_no_params(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.return_value = {
|
|
"Reservations": [],
|
|
"ResponseMetadata": {"HTTPStatusCode": 200},
|
|
}
|
|
result = execute_aws_sdk_call("ec2", "describe_instances")
|
|
assert result["success"] is True
|
|
assert result["service"] == "ec2"
|
|
assert result["operation"] == "describe_instances"
|
|
assert "ResponseMetadata" not in result["data"]
|
|
assert result["metadata"]["region"] == "us-east-1"
|
|
|
|
def test_success_with_params(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.return_value = {
|
|
"Reservations": [{"Instances": [{"InstanceId": "i-1234"}]}],
|
|
"ResponseMetadata": {"HTTPStatusCode": 200},
|
|
}
|
|
params = {"Filters": [{"Name": "instance-state-name", "Values": ["running"]}]}
|
|
result = execute_aws_sdk_call("ec2", "describe_instances", parameters=params)
|
|
assert result["success"] is True
|
|
mock_boto3_client.describe_instances.assert_called_once_with(**params)
|
|
|
|
def test_region_override(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.list_clusters.return_value = {"ClusterArns": []}
|
|
with patch(
|
|
"integrations.aws.aws_sdk_client.boto3.client", return_value=mock_boto3_client
|
|
) as m:
|
|
execute_aws_sdk_call("ecs", "list_clusters", region="eu-west-1")
|
|
m.assert_called_once_with("ecs", region_name="eu-west-1")
|
|
|
|
|
|
class TestExecuteAwsSdkCallValidation:
|
|
def test_empty_service_name(self) -> None:
|
|
result = execute_aws_sdk_call("", "describe_instances")
|
|
assert result["success"] is False
|
|
assert "required" in result["error"]
|
|
|
|
def test_empty_operation_name(self) -> None:
|
|
result = execute_aws_sdk_call("ec2", "")
|
|
assert result["success"] is False
|
|
assert "required" in result["error"]
|
|
|
|
def test_blocked_operation(self) -> None:
|
|
result = execute_aws_sdk_call("ec2", "terminate_instances")
|
|
assert result["success"] is False
|
|
assert "not allowed" in result["error"].lower()
|
|
assert result["metadata"]["validation_failed"] is True
|
|
|
|
def test_unknown_operation_pattern(self) -> None:
|
|
result = execute_aws_sdk_call("ec2", "invoke_magic")
|
|
assert result["success"] is False
|
|
assert "not allowed" in result["error"].lower()
|
|
|
|
def test_rejected_operation_does_not_create_client(self) -> None:
|
|
with patch("integrations.aws.aws_sdk_client.boto3.client") as client:
|
|
result = execute_aws_sdk_call("ec2", "terminate_instances")
|
|
|
|
assert result["success"] is False
|
|
client.assert_not_called()
|
|
|
|
|
|
class TestExecuteAwsSdkCallMissingOperation:
|
|
def test_operation_not_found_on_service(self, mock_boto3_client) -> None:
|
|
# MagicMock creates arbitrary attributes unless they are explicitly deleted.
|
|
del mock_boto3_client.describe_nonexistent
|
|
result = execute_aws_sdk_call("ec2", "describe_nonexistent")
|
|
assert result["success"] is False
|
|
assert "not found" in result["error"].lower()
|
|
|
|
|
|
class TestExecuteAwsSdkCallCredentialErrors:
|
|
def test_no_credentials_error_when_creating_client(self) -> None:
|
|
with patch(
|
|
"integrations.aws.aws_sdk_client.boto3.client",
|
|
side_effect=NoCredentialsError(),
|
|
):
|
|
result = execute_aws_sdk_call("ec2", "describe_instances")
|
|
|
|
assert result["success"] is False
|
|
assert "credentials" in result["error"].lower()
|
|
assert result["metadata"]["error_type"] == "credentials"
|
|
|
|
def test_no_credentials_error(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.side_effect = NoCredentialsError()
|
|
result = execute_aws_sdk_call("ec2", "describe_instances")
|
|
assert result["success"] is False
|
|
assert "credentials" in result["error"].lower()
|
|
assert result["metadata"]["error_type"] == "credentials"
|
|
|
|
|
|
class TestExecuteAwsSdkCallParamValidation:
|
|
def test_param_validation_error(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.side_effect = ParamValidationError(
|
|
report="Missing required param: InstanceIds"
|
|
)
|
|
result = execute_aws_sdk_call("ec2", "describe_instances", parameters={"bad": "param"})
|
|
assert result["success"] is False
|
|
assert "invalid parameters" in result["error"].lower()
|
|
assert result["metadata"]["error_type"] == "validation"
|
|
|
|
|
|
class TestExecuteAwsSdkCallClientError:
|
|
def test_client_error(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.side_effect = ClientError(
|
|
{
|
|
"Error": {"Code": "UnauthorizedAccess", "Message": "Not authorized"},
|
|
"ResponseMetadata": {"HTTPStatusCode": 403},
|
|
},
|
|
"DescribeInstances",
|
|
)
|
|
result = execute_aws_sdk_call("ec2", "describe_instances")
|
|
assert result["success"] is False
|
|
assert "UnauthorizedAccess" in result["error"]
|
|
assert result["metadata"]["error_type"] == "client_error"
|
|
assert result["metadata"]["error_code"] == "UnauthorizedAccess"
|
|
assert result["metadata"]["status_code"] == 403
|
|
|
|
|
|
class TestExecuteAwsSdkCallUnexpectedError:
|
|
def test_unexpected_runtime_error(self, mock_boto3_client) -> None:
|
|
mock_boto3_client.describe_instances.side_effect = RuntimeError("boom")
|
|
result = execute_aws_sdk_call("ec2", "describe_instances")
|
|
assert result["success"] is False
|
|
assert "unexpected error" in result["error"].lower()
|
|
assert result["metadata"]["error_type"] == "unexpected"
|