Files
prowler/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py
T

794 lines
30 KiB
Python

import json
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from botocore.exceptions import ClientError
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
CloudTrailTimeline,
)
class TestCloudTrailTimeline:
@pytest.fixture
def mock_session(self):
return MagicMock()
@pytest.fixture
def sample_cloudtrail_event(self):
return {
"EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/admin",
"userName": "admin",
},
"sourceIPAddress": "203.0.113.1",
"userAgent": "aws-cli/2.0.0",
"requestParameters": {"instanceType": "t3.micro"},
"responseElements": {
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
},
}
),
}
def test_init_default_lookback(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._lookback_days == 90
def test_init_custom_lookback(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session, lookback_days=30)
assert timeline._lookback_days == 30
def test_init_lookback_capped_at_max(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session, lookback_days=365)
assert timeline._lookback_days == 90
def test_init_default_max_results(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._max_results == 50
def test_init_custom_max_results(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session, max_results=10)
assert timeline._max_results == 10
def test_init_default_write_events_only(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._write_events_only is True
def test_init_write_events_only_disabled(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
assert timeline._write_events_only is False
def test_get_resource_timeline_defaults_to_us_east_1(self, mock_session):
"""When no region is provided, should default to us-east-1 for global resources."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
timeline.get_resource_timeline(
resource_uid="arn:aws:iam::123456789012:user/admin"
)
# Verify us-east-1 was used as the default region
mock_session.client.assert_called_with("cloudtrail", region_name="us-east-1")
def test_get_resource_timeline_missing_identifier_raises(self, mock_session):
timeline = CloudTrailTimeline(session=mock_session)
with pytest.raises(ValueError, match="Either resource_id or resource_uid"):
timeline.get_resource_timeline(region="us-east-1")
def test_get_resource_timeline_with_resource_id(
self, mock_session, sample_cloudtrail_event
):
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="i-1234567890abcdef0"
)
assert len(result) == 1
assert result[0]["event_name"] == "RunInstances"
assert result[0]["actor"] == "user/admin"
assert result[0]["source_ip_address"] == "203.0.113.1"
def test_get_resource_timeline_with_resource_uid(
self, mock_session, sample_cloudtrail_event
):
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1",
resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
)
assert len(result) == 1
assert result[0]["event_name"] == "RunInstances"
def test_get_resource_timeline_prefers_uid_over_id(self, mock_session):
"""When both resource_id and resource_uid are provided, UID is tried first."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
timeline.get_resource_timeline(
region="us-east-1",
resource_id="i-1234",
resource_uid="arn:aws:ec2:us-east-1:123:instance/i-1234",
)
# Verify UID was used on the first lookup call
first_call = mock_client.lookup_events.call_args_list[0]
lookup_attrs = first_call.kwargs["LookupAttributes"]
assert (
lookup_attrs[0]["AttributeValue"]
== "arn:aws:ec2:us-east-1:123:instance/i-1234"
)
def test_get_resource_timeline_client_error(self, mock_session):
mock_client = MagicMock()
mock_client.lookup_events.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"LookupEvents",
)
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
with pytest.raises(ClientError):
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
def test_get_resource_timeline_multiple_events(self, mock_session):
events = [
{
"EventId": "event-1-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/admin",
}
}
),
},
{
"EventId": "event-2-id",
"EventTime": datetime(2024, 1, 15, 11, 30, 0, tzinfo=timezone.utc),
"EventName": "StopInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/ops",
}
}
),
},
]
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": events}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="i-1234"
)
assert len(result) == 2
assert result[0]["event_name"] == "RunInstances"
assert result[1]["event_name"] == "StopInstances"
def test_get_resource_timeline_uses_max_results(self, mock_session):
"""Verify MaxResults is passed to lookup_events."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session, max_results=25)
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
# Verify MaxResults was passed to lookup_events
call_args = mock_client.lookup_events.call_args
assert call_args.kwargs["MaxResults"] == 25
def test_get_resource_timeline_filters_read_only_events(self, mock_session):
"""Verify read-only events are filtered when write_events_only=True."""
events = [
{
"EventId": "write-event-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "CreateSecurityGroup",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
},
{
"EventId": "read-event-id",
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
"EventName": "DescribeSecurityGroups",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
},
{
"EventId": "another-read-id",
"EventTime": datetime(2024, 1, 15, 10, 32, 0, tzinfo=timezone.utc),
"EventName": "GetSecurityGroupRules",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
},
]
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": events}
mock_session.client.return_value = mock_client
# Default: write_events_only=True
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="sg-123"
)
# Only the write event should be returned
assert len(result) == 1
assert result[0]["event_name"] == "CreateSecurityGroup"
def test_get_resource_timeline_includes_read_events_when_disabled(
self, mock_session
):
"""Verify all events returned when write_events_only=False."""
events = [
{
"EventId": "write-event-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "CreateSecurityGroup",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
},
{
"EventId": "read-event-id",
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
"EventName": "DescribeSecurityGroups",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
},
]
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": events}
mock_session.client.return_value = mock_client
# Disable filtering
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="sg-123"
)
# All events should be returned
assert len(result) == 2
assert result[0]["event_name"] == "CreateSecurityGroup"
assert result[1]["event_name"] == "DescribeSecurityGroups"
class TestExtractActor:
def test_extract_actor_iam_user(self):
user_identity = {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/alice",
"userName": "alice",
}
assert CloudTrailTimeline._extract_actor(user_identity) == "user/alice"
def test_extract_actor_assumed_role(self):
user_identity = {
"type": "AssumedRole",
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name",
}
assert (
CloudTrailTimeline._extract_actor(user_identity)
== "assumed-role/MyRole/session-name"
)
def test_extract_actor_assumed_role_sso(self):
"""SSO sessions store the user identity in the session name."""
user_identity = {
"type": "AssumedRole",
"arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com",
}
assert (
CloudTrailTimeline._extract_actor(user_identity)
== "assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com"
)
def test_extract_actor_root(self):
user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"}
assert CloudTrailTimeline._extract_actor(user_identity) == "root"
def test_extract_actor_service(self):
user_identity = {
"type": "AWSService",
"invokedBy": "elasticloadbalancing.amazonaws.com",
}
assert (
CloudTrailTimeline._extract_actor(user_identity)
== "elasticloadbalancing.amazonaws.com"
)
def test_extract_actor_unknown(self):
assert CloudTrailTimeline._extract_actor({}) == "Unknown"
def test_extract_actor_username_only_returns_unknown(self):
"""When userIdentity carries only userName/principalId (no arn or
invokedBy), we deliberately return "Unknown" — we rely on the ARN
from the upstream service for the actor."""
assert (
CloudTrailTimeline._extract_actor({"type": "IAMUser", "userName": "alice"})
== "Unknown"
)
assert (
CloudTrailTimeline._extract_actor(
{"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
)
== "Unknown"
)
def test_extract_actor_federated_user(self):
user_identity = {
"type": "FederatedUser",
"arn": "arn:aws:sts::123456789012:federated-user/developer",
}
assert (
CloudTrailTimeline._extract_actor(user_identity)
== "federated-user/developer"
)
class TestParseEvent:
@pytest.fixture
def mock_session(self):
return MagicMock()
@pytest.fixture
def sample_cloudtrail_event(self):
return {
"EventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/admin",
"userName": "admin",
},
"sourceIPAddress": "203.0.113.1",
"userAgent": "aws-cli/2.0.0",
"requestParameters": {"instanceType": "t3.micro"},
"responseElements": {
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
},
}
),
}
def test_parse_event_success(self, mock_session, sample_cloudtrail_event):
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(sample_cloudtrail_event)
assert result is not None
assert result["event_name"] == "RunInstances"
assert result["event_source"] == "ec2.amazonaws.com"
assert result["actor"] == "user/admin"
assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin"
assert result["actor_type"] == "IAMUser"
def test_parse_event_malformed_json(self, mock_session):
event = {
"EventId": "malformed-event-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": "not valid json",
}
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._parse_event(event) is None
def test_parse_event_with_error_fields(self, mock_session):
event = {
"EventId": "error-event-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "CreateBucket",
"EventSource": "s3.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {"type": "IAMUser", "userName": "developer"},
"errorCode": "AccessDenied",
"errorMessage": "Access Denied",
}
),
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
assert result is not None
assert result["error_code"] == "AccessDenied"
assert result["error_message"] == "Access Denied"
def test_parse_event_dict_cloud_trail_event(self, mock_session):
"""Test parsing when CloudTrailEvent is already a dict (not JSON string)."""
event = {
"EventId": "dict-event-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": {
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/admin",
},
},
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
assert result is not None
assert result["event_name"] == "RunInstances"
assert result["actor"] == "user/admin"
def test_parse_event_missing_event_id(self, mock_session):
"""Test parsing event without EventId returns None (event_id is required)."""
event = {
# Missing EventId
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
),
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
# Should return None because event_id is required by TimelineEvent model
assert result is None
def test_parse_event_uses_request_data_and_response_data_fields(self, mock_session):
"""Test that parsed event uses request_data and response_data field names."""
event = {
"EventId": "field-names-test-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "CreateBucket",
"EventSource": "s3.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {"type": "IAMUser", "userName": "admin"},
"requestParameters": {"bucketName": "my-bucket", "acl": "private"},
"responseElements": {
"location": "http://my-bucket.s3.amazonaws.com"
},
}
),
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
assert result is not None
# Verify field names are request_data/response_data (not request_parameters/response_elements)
assert "request_data" in result
assert "response_data" in result
assert "request_parameters" not in result
assert "response_elements" not in result
# Verify the data is correctly mapped
assert result["request_data"] == {"bucketName": "my-bucket", "acl": "private"}
assert result["response_data"] == {
"location": "http://my-bucket.s3.amazonaws.com"
}
def test_parse_event_missing_actor_type(self, mock_session):
"""Test parsing event where userIdentity has no type field."""
event = {
"EventId": "no-actor-type-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "RunInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
# No "type" field
"arn": "arn:aws:iam::123456789012:user/admin",
"userName": "admin",
},
"sourceIPAddress": "203.0.113.1",
}
),
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
assert result is not None
assert result["event_name"] == "RunInstances"
assert result["actor"] == "user/admin"
# actor_type should be None when not present in userIdentity
assert result["actor_type"] is None
def test_parse_event_empty_request_response(self, mock_session):
"""Test parsing event with no requestParameters or responseElements."""
event = {
"EventId": "no-params-id",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "DescribeInstances",
"EventSource": "ec2.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {"type": "IAMUser", "userName": "reader"},
# No requestParameters or responseElements
}
),
}
timeline = CloudTrailTimeline(session=mock_session)
result = timeline._parse_event(event)
assert result is not None
assert result["request_data"] is None
assert result["response_data"] is None
class TestClientCaching:
def test_client_cached_per_region(self):
mock_session = MagicMock()
mock_client = MagicMock()
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
# Get client twice for same region
client1 = timeline._get_client("us-east-1")
client2 = timeline._get_client("us-east-1")
# Should only create client once
assert mock_session.client.call_count == 1
assert client1 is client2
def test_different_clients_per_region(self):
mock_session = MagicMock()
timeline = CloudTrailTimeline(session=mock_session)
timeline._get_client("us-east-1")
timeline._get_client("eu-west-1")
# Should create client for each region
assert mock_session.client.call_count == 2
class TestIsReadOnlyEvent:
"""Tests for _is_read_only_event method."""
@pytest.fixture
def mock_session(self):
return MagicMock()
@pytest.mark.parametrize(
"event_name",
[
"DescribeSecurityGroups",
"GetBucketPolicy",
"ListBuckets",
"HeadObject",
"CheckAccessNotGranted",
"LookupEvents",
"SearchResources",
"ScanOnDemand",
"QueryObjects",
"BatchGetItem",
"SelectObjectContent",
],
)
def test_read_only_events_detected(self, mock_session, event_name):
"""Verify various read-only event prefixes are correctly identified."""
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._is_read_only_event(event_name) is True
@pytest.mark.parametrize(
"event_name",
[
"CreateSecurityGroup",
"DeleteSecurityGroup",
"ModifySecurityGroupRules",
"PutBucketPolicy",
"RunInstances",
"TerminateInstances",
"UpdateFunction",
"AttachRolePolicy",
"AuthorizeSecurityGroupIngress",
],
)
def test_write_events_not_filtered(self, mock_session, event_name):
"""Verify write events are not marked as read-only."""
timeline = CloudTrailTimeline(session=mock_session)
assert timeline._is_read_only_event(event_name) is False
class TestExtractShortName:
"""Tests for _extract_short_name static method."""
@pytest.mark.parametrize(
"identifier,expected",
[
("arn:aws:s3:::my-bucket", "my-bucket"),
("arn:aws:iam::123456789012:user/alice", "alice"),
("arn:aws:iam::123456789012:role/MyRole", "MyRole"),
(
"arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234",
"i-0abc1234",
),
(
"arn:aws:lambda:us-east-1:123456789012:function:my-func",
"my-func",
),
("arn:aws:rds:us-east-1:123456789012:db:mydb", "mydb"),
("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable", "MyTable"),
(
"arn:aws:kms:us-east-1:123456789012:key/abcd-efgh",
"abcd-efgh",
),
("i-0abc1234", "i-0abc1234"),
("my-bucket", "my-bucket"),
("", ""),
],
)
def test_extract_short_name(self, identifier, expected):
assert CloudTrailTimeline._extract_short_name(identifier) == expected
class TestLookupEventsFallback:
"""Tests for the ARN-to-short-name fallback in _lookup_events."""
@pytest.fixture
def mock_session(self):
return MagicMock()
@pytest.fixture
def sample_event(self):
return {
"EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
"EventName": "CreateBucket",
"EventSource": "s3.amazonaws.com",
"CloudTrailEvent": json.dumps(
{
"userIdentity": {
"type": "IAMUser",
"arn": "arn:aws:iam::123456789012:user/admin",
"userName": "admin",
}
}
),
}
def test_no_fallback_when_arn_returns_events(self, mock_session, sample_event):
"""When the ARN lookup returns events, we do not retry with the short name."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": [sample_event]}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1",
resource_uid="arn:aws:kms:us-east-1:123456789012:key/abcd-efgh",
)
assert len(result) == 1
assert mock_client.lookup_events.call_count == 1
call = mock_client.lookup_events.call_args
assert (
call.kwargs["LookupAttributes"][0]["AttributeValue"]
== "arn:aws:kms:us-east-1:123456789012:key/abcd-efgh"
)
def test_fallback_to_short_name_when_arn_returns_empty(
self, mock_session, sample_event
):
"""When the ARN lookup returns nothing, we retry with the short name."""
mock_client = MagicMock()
mock_client.lookup_events.side_effect = [
{"Events": []},
{"Events": [sample_event]},
]
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_uid="arn:aws:s3:::my-bucket"
)
assert len(result) == 1
assert mock_client.lookup_events.call_count == 2
first_call, second_call = mock_client.lookup_events.call_args_list
assert (
first_call.kwargs["LookupAttributes"][0]["AttributeValue"]
== "arn:aws:s3:::my-bucket"
)
assert (
second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "my-bucket"
)
def test_no_fallback_when_identifier_has_no_short_name(self, mock_session):
"""A non-ARN identifier collapses to itself; no retry should fire."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="i-0abc1234"
)
assert result == []
assert mock_client.lookup_events.call_count == 1
def test_no_fallback_when_identifier_is_not_arn(self, mock_session):
"""A non-ARN identifier with / or : must not trigger the retry."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1", resource_id="some-prefix/weird:value"
)
assert result == []
assert mock_client.lookup_events.call_count == 1
def test_both_lookups_empty_returns_empty_list(self, mock_session):
"""If both the ARN and short-name lookups return empty, we return []."""
mock_client = MagicMock()
mock_client.lookup_events.return_value = {"Events": []}
mock_session.client.return_value = mock_client
timeline = CloudTrailTimeline(session=mock_session)
result = timeline.get_resource_timeline(
region="us-east-1",
resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234",
)
assert result == []
assert mock_client.lookup_events.call_count == 2
first_call, second_call = mock_client.lookup_events.call_args_list
assert (
first_call.kwargs["LookupAttributes"][0]["AttributeValue"]
== "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234"
)
assert (
second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "i-0abc1234"
)