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
499 lines
21 KiB
Python
499 lines
21 KiB
Python
"""Tests for CloudTrailEventsTool (function-based, @tool decorated).
|
|
|
|
Covers the acceptance criteria: schema shape, availability (mirrors an AWS
|
|
tool / planner-selectable), and extraction, plus runtime behavior — filter
|
|
priority, the time-window helper, response shaping, and the synthetic
|
|
``aws_backend`` short-circuit that keeps scenario runs off real AWS.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from integrations.cloudtrail import (
|
|
DEFAULT_CLOUDTRAIL_REGION,
|
|
cloudtrail_extract_params,
|
|
cloudtrail_is_available,
|
|
)
|
|
from integrations.cloudtrail.tools.cloudtrail_events_tool import lookup_cloudtrail_events
|
|
from tests.tools.conftest import BaseToolContract
|
|
|
|
_RT = lookup_cloudtrail_events.__opensre_registered_tool__
|
|
|
|
|
|
class _FakeAWSBackend:
|
|
"""Minimal AWSBackend stand-in that records lookup_events calls."""
|
|
|
|
def __init__(self, response: dict[str, Any]) -> None:
|
|
self.response = response
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def lookup_events(
|
|
self,
|
|
lookup_attributes: list[dict[str, str]],
|
|
duration_minutes: int = 60,
|
|
max_results: int = 50,
|
|
region: str = "",
|
|
**_: Any,
|
|
) -> dict[str, Any]:
|
|
self.calls.append(
|
|
{
|
|
"lookup_attributes": lookup_attributes,
|
|
"duration_minutes": duration_minutes,
|
|
"max_results": max_results,
|
|
"region": region,
|
|
}
|
|
)
|
|
return self.response
|
|
|
|
|
|
class TestCloudTrailEventsToolContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _RT
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Discovery — the tool is auto-registered on the investigation surface
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_tool_discovered_on_investigation_surface() -> None:
|
|
from tools.registry import get_registered_tools
|
|
|
|
names = {t.name for t in get_registered_tools("investigation")}
|
|
assert "lookup_cloudtrail_events" in names
|
|
|
|
|
|
def test_cloudtrail_prioritized_but_not_auto_seeded_for_aws_alerts() -> None:
|
|
"""CloudTrail is prioritized for AWS alerts but left for the planner to call.
|
|
|
|
It is intentionally NOT in the auto-seed map: an unscoped, account-wide
|
|
lookup on every cloudwatch/eks/alertmanager alert would be noisy and press
|
|
CloudTrail's low lookup rate limit. The prompt prioritization map nudges the
|
|
planner to reach for it once it has a concrete resource/principal/time target.
|
|
"""
|
|
from core.domain.alerts.alert_source import ALERT_SOURCE_ROUTING
|
|
|
|
for alert_source in ("cloudwatch", "eks", "alertmanager"):
|
|
routing = ALERT_SOURCE_ROUTING[alert_source]
|
|
assert "cloudtrail" not in routing.seed_tool_sources
|
|
assert "cloudtrail" in routing.relevance_tool_sources
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Schema shape
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_schema_shape() -> None:
|
|
assert _RT.name == "lookup_cloudtrail_events"
|
|
assert _RT.source == "cloudtrail"
|
|
schema = _RT.input_schema
|
|
assert schema["type"] == "object"
|
|
props = schema["properties"]
|
|
# The three forensic filters + the window/region knobs are all present.
|
|
for field in ("resource_name", "event_source", "username", "region", "duration_minutes"):
|
|
assert field in props
|
|
# All filters are optional — the tool is account-wide and selectable without one.
|
|
assert schema["required"] == []
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Availability — mirrors an AWS tool (gates on the account-level "aws" source)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_is_available_on_verified_aws() -> None:
|
|
assert cloudtrail_is_available({"aws": {"connection_verified": True}}) is True
|
|
|
|
|
|
def test_is_available_on_role_or_credentials() -> None:
|
|
assert cloudtrail_is_available({"aws": {"role_arn": "arn:aws:iam::1:role/r"}}) is True
|
|
assert cloudtrail_is_available({"aws": {"credentials": {"access_key_id": "AKIA"}}}) is True
|
|
|
|
|
|
def test_is_available_on_injected_backend() -> None:
|
|
# The synthetic harness injects the fixture backend as "ec2_backend" on the
|
|
# "aws" source (see tests/synthetic/rds_postgres/run_suite.py).
|
|
assert cloudtrail_is_available({"aws": {"ec2_backend": object()}}) is True
|
|
|
|
|
|
def test_not_available_without_aws() -> None:
|
|
assert cloudtrail_is_available({}) is False
|
|
assert cloudtrail_is_available({"aws": {}}) is False
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Extraction
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_extract_params_region_from_source() -> None:
|
|
params = cloudtrail_extract_params(
|
|
{"aws": {"connection_verified": True, "region": "eu-west-1"}}
|
|
)
|
|
assert params["region"] == "eu-west-1"
|
|
assert params["aws_backend"] is None
|
|
|
|
|
|
def test_extract_params_defaults_region(monkeypatch) -> None:
|
|
monkeypatch.delenv("AWS_REGION", raising=False)
|
|
params = cloudtrail_extract_params({"aws": {"connection_verified": True}})
|
|
assert params["region"] == DEFAULT_CLOUDTRAIL_REGION
|
|
|
|
|
|
def test_extract_params_forwards_backend() -> None:
|
|
backend = object()
|
|
# Harness-shaped: the backend handle lives under "ec2_backend" on "aws".
|
|
params = cloudtrail_extract_params({"aws": {"ec2_backend": backend}})
|
|
assert params["aws_backend"] is backend
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Runtime behavior
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_lookup_success_and_shaping(mock_call) -> None:
|
|
detail = json.dumps(
|
|
{"awsRegion": "us-east-1", "sourceIPAddress": "1.2.3.4", "errorCode": "AccessDenied"}
|
|
)
|
|
mock_call.return_value = {
|
|
"success": True,
|
|
"data": {
|
|
"Events": [
|
|
{
|
|
"EventId": "evt-1",
|
|
"EventName": "DeleteSecurityGroup",
|
|
"EventTime": "2026-05-05T12:00:00Z",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"Username": "alice",
|
|
"ReadOnly": "false",
|
|
"AccessKeyId": "AKIAEXAMPLE",
|
|
"Resources": [
|
|
{"ResourceType": "AWS::EC2::SecurityGroup", "ResourceName": "sg-1"}
|
|
],
|
|
"CloudTrailEvent": detail,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
result = lookup_cloudtrail_events(event_source="ec2.amazonaws.com", duration_minutes=120)
|
|
|
|
assert result["available"] is True
|
|
assert result["total_events"] == 1
|
|
event = result["events"][0]
|
|
assert event["event_name"] == "DeleteSecurityGroup"
|
|
assert event["username"] == "alice"
|
|
# "false" -> real bool False (not the truthy string "false")
|
|
assert event["read_only"] is False
|
|
assert event["resources"] == [{"type": "AWS::EC2::SecurityGroup", "name": "sg-1"}]
|
|
assert event["aws_region"] == "us-east-1"
|
|
assert event["source_ip_address"] == "1.2.3.4"
|
|
assert event["error_code"] == "AccessDenied"
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_filter_priority_prefers_resource_name(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
lookup_cloudtrail_events(
|
|
resource_name="sg-1",
|
|
username="alice",
|
|
event_source="ec2.amazonaws.com",
|
|
)
|
|
|
|
sent = mock_call.call_args.kwargs["parameters"]
|
|
assert sent["LookupAttributes"] == [{"AttributeKey": "ResourceName", "AttributeValue": "sg-1"}]
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_filter_priority_username_over_event_source(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
lookup_cloudtrail_events(username="alice", event_source="iam.amazonaws.com")
|
|
|
|
sent = mock_call.call_args.kwargs["parameters"]
|
|
assert sent["LookupAttributes"] == [{"AttributeKey": "Username", "AttributeValue": "alice"}]
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_no_filter_omits_lookup_attributes(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
result = lookup_cloudtrail_events()
|
|
|
|
sent = mock_call.call_args.kwargs["parameters"]
|
|
assert "LookupAttributes" not in sent
|
|
# The time window is always sent.
|
|
assert "StartTime" in sent and "EndTime" in sent
|
|
assert result["filter"] is None
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_duration_clamped_and_window_built(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
result = lookup_cloudtrail_events(duration_minutes=10**9)
|
|
|
|
sent = mock_call.call_args.kwargs["parameters"]
|
|
# End is after start; duration clamped to the 90-day max.
|
|
assert sent["EndTime"] > sent["StartTime"]
|
|
assert result["duration_minutes"] == 90 * 24 * 60
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_max_results_clamped(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
lookup_cloudtrail_events(max_results=999)
|
|
|
|
assert mock_call.call_args.kwargs["parameters"]["MaxResults"] == 50
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_truncated_when_next_token_present(mock_call) -> None:
|
|
mock_call.return_value = {
|
|
"success": True,
|
|
"data": {"Events": [], "NextToken": "tok-abc"},
|
|
}
|
|
|
|
result = lookup_cloudtrail_events()
|
|
|
|
# A NextToken means more events exist beyond this page — surface it.
|
|
assert result["truncated"] is True
|
|
assert result["next_token"] == "tok-abc"
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_not_truncated_without_next_token(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
result = lookup_cloudtrail_events()
|
|
|
|
assert result["truncated"] is False
|
|
assert result["next_token"] is None
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_next_token_forwarded_to_api(mock_call) -> None:
|
|
mock_call.return_value = {"success": True, "data": {"Events": []}}
|
|
|
|
lookup_cloudtrail_events(next_token="tok-page-2")
|
|
|
|
assert mock_call.call_args.kwargs["parameters"]["NextToken"] == "tok-page-2"
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_lookup_failure(mock_call) -> None:
|
|
mock_call.return_value = {"success": False, "error": "ThrottlingException"}
|
|
|
|
result = lookup_cloudtrail_events()
|
|
|
|
assert result["available"] is False
|
|
assert result["error"] == "Failed to look up CloudTrail events. Check server logs for details."
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_short_circuits_to_aws_backend(mock_call) -> None:
|
|
backend = _FakeAWSBackend(
|
|
response={
|
|
"source": "cloudtrail",
|
|
"available": True,
|
|
"total_events": 0,
|
|
"events": [],
|
|
"error": None,
|
|
}
|
|
)
|
|
|
|
result = lookup_cloudtrail_events(
|
|
resource_name="sg-1",
|
|
region="us-east-1",
|
|
duration_minutes=30,
|
|
max_results=25,
|
|
aws_backend=backend,
|
|
)
|
|
|
|
mock_call.assert_not_called()
|
|
assert result["available"] is True
|
|
assert backend.calls == [
|
|
{
|
|
"lookup_attributes": [{"AttributeKey": "ResourceName", "AttributeValue": "sg-1"}],
|
|
"duration_minutes": 30,
|
|
"max_results": 25,
|
|
"region": "us-east-1",
|
|
}
|
|
]
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_harness_shaped_aws_source_short_circuits_off_real_aws(mock_call) -> None:
|
|
"""Regression: the synthetic harness injects the backend as aws['ec2_backend'].
|
|
|
|
Resolving via cloudtrail_extract_params (not injecting aws_backend directly)
|
|
must pick up that handle and short-circuit the tool, so a synthetic run —
|
|
e.g. an rds scenario with alert_source 'cloudwatch' — never reaches a real
|
|
boto3 lookup_events call even when ambient AWS creds are present.
|
|
"""
|
|
backend = _FakeAWSBackend(
|
|
response={"source": "cloudtrail", "available": True, "total_events": 0, "events": []}
|
|
)
|
|
# Source dict shaped exactly like tests/synthetic/rds_postgres/run_suite.py.
|
|
sources = {"aws": {"region": "us-east-1", "ec2_backend": backend}}
|
|
|
|
params = cloudtrail_extract_params(sources)
|
|
assert params["aws_backend"] is backend # resolved from ec2_backend, not _backend
|
|
|
|
result = lookup_cloudtrail_events(resource_name="sg-1", **params)
|
|
|
|
mock_call.assert_not_called()
|
|
assert backend.calls and backend.calls[0]["region"] == "us-east-1"
|
|
assert result["available"] is True
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Live payload fixtures (#3583) — real LookupEvents response shapes
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "cloudtrail"
|
|
|
|
|
|
def _fixture_payload(name: str) -> dict[str, Any]:
|
|
payload = json.loads((_FIXTURES / name).read_text(encoding="utf-8"))
|
|
payload.pop("_comment", None)
|
|
return payload
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_realistic_page_shapes_every_event(mock_call) -> None:
|
|
"""A faithful LookupEvents page (mixed principals, an errored call, a
|
|
service event without Username) shapes into the exact per-event contract."""
|
|
mock_call.return_value = {"success": True, "data": _fixture_payload("lookup_events_page.json")}
|
|
|
|
result = lookup_cloudtrail_events(duration_minutes=120)
|
|
|
|
assert result["available"] is True
|
|
assert result["total_events"] == 3
|
|
by_id = {event["event_id"]: event for event in result["events"]}
|
|
|
|
# Full-dict equality: locks every key of the shaped-event contract,
|
|
# including the ISO EventTime passthrough and event_source.
|
|
assert by_id["8e3c6f2b-4a1d-4c8e-9f2a-1b7d3e5a9c01"] == {
|
|
"event_id": "8e3c6f2b-4a1d-4c8e-9f2a-1b7d3e5a9c01",
|
|
"event_name": "PutRolePolicy",
|
|
"event_time": "2026-07-03T09:12:41+00:00",
|
|
"event_source": "iam.amazonaws.com",
|
|
"username": "deploy-bot",
|
|
"read_only": False,
|
|
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
|
"resources": [{"type": "AWS::IAM::Role", "name": "payments-service-role"}],
|
|
"resources_truncated": False,
|
|
"aws_region": "us-east-1",
|
|
"source_ip_address": "203.0.113.24",
|
|
"error_code": None,
|
|
}
|
|
|
|
# A denied call surfaces its errorCode from the CloudTrailEvent record.
|
|
denied = by_id["1f9a7c3e-6b2d-4a5f-8c1e-7d4b2a9f6e02"]
|
|
assert denied["event_name"] == "DeleteSecurityGroup"
|
|
assert denied["error_code"] == "Client.UnauthorizedOperation"
|
|
assert denied["source_ip_address"] == "198.51.100.7"
|
|
|
|
# AWS service events carry no Username or AccessKeyId — shaped as None,
|
|
# never a KeyError, and ReadOnly "true" coerces to a real bool.
|
|
service = by_id["5d2b8e4a-9c1f-4d7b-a3e6-8f5c2d1b9a03"]
|
|
assert service["username"] is None
|
|
assert service["access_key_id"] is None
|
|
assert service["read_only"] is True
|
|
assert service["resources"] == []
|
|
|
|
# More matching events exist beyond this page — surfaced, not swallowed.
|
|
assert result["truncated"] is True
|
|
assert result["next_token"] == "AAAAfR3kNzXhCq9lEXAMPLEtokenXo1v"
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_success_payload_omits_error_key(mock_call) -> None:
|
|
"""The success payload must NOT carry an "error" key.
|
|
|
|
The runtime tool loop (core.execution._normalize_result) flags a result as a
|
|
failure on the mere presence of an "error" key (is_error = "error" in raw) and
|
|
replaces the whole payload with {"error": ...} before the agent sees it. A
|
|
success dict with "error": None therefore hides every event from the
|
|
investigation — regression guard against reintroducing it.
|
|
"""
|
|
mock_call.return_value = {"success": True, "data": _fixture_payload("lookup_events_page.json")}
|
|
result = lookup_cloudtrail_events(duration_minutes=60)
|
|
|
|
assert result["available"] is True
|
|
assert result["total_events"] == 3
|
|
assert "error" not in result
|
|
|
|
# Failure paths still carry a real message (correctly flagged as is_error).
|
|
mock_call.return_value = {"success": False, "error": "ThrottlingException"}
|
|
failed = lookup_cloudtrail_events()
|
|
assert failed["available"] is False
|
|
assert "error" in failed and failed["error"]
|
|
|
|
|
|
@patch("integrations.cloudtrail.tools.cloudtrail_events_tool.execute_aws_sdk_call")
|
|
def test_weird_page_survives_and_shapes_gracefully(mock_call) -> None:
|
|
"""Degenerate live payloads must degrade to partial events, never raise.
|
|
|
|
The weird fixture packs the §1 cases: CloudTrailEvent as valid-but-non-dict
|
|
JSON (null / bare string / list) or invalid JSON, an event with only
|
|
EventId, ReadOnly as a real bool and as "True", and a null plus a
|
|
_sanitize_response truncation marker inside Resources — the one place the
|
|
marker is genuinely reachable (a single event CAN reference >MAX_LIST_ITEMS
|
|
resources, e.g. CreateTags across a fleet). The marker as the final Events
|
|
entry is a purely defensive case (LookupEvents pages cap at 50 <
|
|
MAX_LIST_ITEMS, so the sanitizer cannot produce it there): it pins that
|
|
non-dict Events entries are tolerated, not that the transport emits them.
|
|
"""
|
|
mock_call.return_value = {"success": True, "data": _fixture_payload("lookup_events_weird.json")}
|
|
|
|
result = lookup_cloudtrail_events()
|
|
|
|
assert result["available"] is True
|
|
# The trailing Events truncation marker (a str) is dropped, not shaped.
|
|
assert result["total_events"] == 7
|
|
by_id = {event["event_id"]: event for event in result["events"]}
|
|
|
|
# Valid-but-non-dict CloudTrailEvent JSON -> detail fields None, no crash.
|
|
for event_id in ("weird-null-detail", "weird-string-detail", "weird-list-detail"):
|
|
event = by_id[event_id]
|
|
assert event["aws_region"] is None
|
|
assert event["source_ip_address"] is None
|
|
assert event["error_code"] is None
|
|
|
|
# Invalid JSON keeps its existing graceful path.
|
|
assert by_id["weird-invalid-json-detail"]["aws_region"] is None
|
|
|
|
# An event carrying only EventId shapes with every other field defaulted.
|
|
bare = by_id["weird-bare-event"]
|
|
assert bare["event_name"] is None
|
|
assert bare["username"] is None
|
|
assert bare["read_only"] is None
|
|
assert bare["resources"] == []
|
|
assert bare["resources_truncated"] is False
|
|
|
|
# Non-dict Resources entries (null / truncation marker) are skipped; the
|
|
# real resource survives, a bool ReadOnly passes through unchanged, and the
|
|
# drop is surfaced so the planner knows the blast radius is understated.
|
|
mixed = by_id["weird-resources-mixed"]
|
|
assert mixed["resources"] == [{"type": "AWS::EC2::Instance", "name": "i-0abc123def456789a"}]
|
|
assert mixed["resources_truncated"] is True
|
|
assert mixed["read_only"] is True
|
|
assert mixed["aws_region"] == "eu-west-1"
|
|
|
|
# ReadOnly arrives with inconsistent casing in live payloads.
|
|
assert by_id["weird-readonly-mixed-case"]["read_only"] is True
|
|
|
|
assert result["truncated"] is True
|
|
assert result["next_token"] == "AAAAweirdPageToken"
|