"""Round-26 (2026-05-18) AWS provider tests: ``OptInRequired`` and real auth failures must land in the right lists. Production incident parallel to GCP ``project:tidb-376913`` — tenants with ME / AFR / HK / AP-southeast-3 regions described as opt-in were paging IT admin every cron tick with ``OptInRequired × N`` in ``partial_failures``. The fix routes ``OptInRequired`` to ``skipped_legs`` (capability stays green; surfaced for admin visibility) and leaves real auth gaps in ``partial_failures`` with the enriched ``error_message`` field. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from app.domains.cmdb.providers.aws import AWSProvider from app.domains.cmdb.providers.base import ProviderCredentials def _provider() -> AWSProvider: creds = ProviderCredentials( provider_id="aws-test", provider_type="aws", account_alias="test", fields={ "access_key_id": "AKIA" + "X" * 16, "secret_access_key": "secret" + "X" * 32, }, ) return AWSProvider(creds) def _client_error(code: str, message: str = ""): """Build a botocore ClientError with the canonical shape.""" from botocore.exceptions import ClientError return ClientError( error_response={ "Error": {"Code": code, "Message": message or code}, "ResponseMetadata": {"HTTPStatusCode": 403}, }, operation_name="DescribeInstances", ) @pytest.mark.parametrize("opt_in_code", ["OptInRequired"]) def test_list_servers_opt_in_required_routes_to_skipped_legs(opt_in_code: str) -> None: """``OptInRequired`` MUST land in ``skipped_legs``, not ``partial_failures``. Otherwise a tenant with ME / AFR opt-in regions pages IT admin every cron tick.""" p = _provider() # Two regions; first errors with OptInRequired, second returns empty. regions = ["me-south-1", "us-east-1"] def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2" and region_name == "me-south-1": paginator = MagicMock() paginator.paginate.side_effect = _client_error(opt_in_code) client.get_paginator.return_value = paginator elif service == "ec2" and region_name == "us-east-1": paginator = MagicMock() paginator.paginate.return_value = iter([{"Reservations": []}]) client.get_paginator.return_value = paginator return client with patch.object(p, "_ec2_regions_sync", return_value=regions): with patch.object(p._session, "client", side_effect=_client_factory): servers = p._list_servers_sync() assert servers == [] # The opt-in region MUST land in skipped_legs (capability stays green). assert len(p.skipped_legs) == 1 skip = p.skipped_legs[0] assert skip["leg"] == "region:me-south-1" assert skip["capability"] == "servers" assert skip["reason"] == "region_not_opted_in" # And MUST NOT pollute partial_failures. assert p.partial_failures == [] def test_list_servers_real_unauthorized_operation_stays_in_partial_failures() -> None: """``UnauthorizedOperation`` is intentionally NOT routed to skipped_legs — it can mean a real IAM gap that the admin needs to see and fix. Stays in partial_failures with the redacted error message attached so admin can diagnose.""" p = _provider() regions = ["us-east-1"] def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2": paginator = MagicMock() paginator.paginate.side_effect = _client_error( "UnauthorizedOperation", "You are not authorized to perform this operation. " "Encoded authorization failure message: XYZ...", ) client.get_paginator.return_value = paginator return client with patch.object(p, "_ec2_regions_sync", return_value=regions): with patch.object(p._session, "client", side_effect=_client_factory): servers = p._list_servers_sync() assert servers == [] assert p.skipped_legs == [] assert len(p.partial_failures) == 1 leg = p.partial_failures[0] assert leg["leg"] == "region:us-east-1" assert leg["capability"] == "servers" assert leg["reason"] == "UnauthorizedOperation" # Round-26 enrichment: error_message present and informative. assert "error_message" in leg assert "not authorized" in leg["error_message"].lower() def test_list_servers_access_denied_attaches_error_message() -> None: """``AccessDenied`` (no opt-in marker) stays in partial_failures.""" p = _provider() regions = ["us-east-1"] def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2": paginator = MagicMock() paginator.paginate.side_effect = _client_error( "AccessDenied", "User: arn:aws:iam::123:user/x is not authorized " "to perform ec2:DescribeInstances", ) client.get_paginator.return_value = paginator return client with patch.object(p, "_ec2_regions_sync", return_value=regions): with patch.object(p._session, "client", side_effect=_client_factory): servers = p._list_servers_sync() assert servers == [] assert p.skipped_legs == [] assert len(p.partial_failures) == 1 leg = p.partial_failures[0] assert leg["reason"] == "AccessDenied" assert "error_message" in leg assert "ec2:DescribeInstances" in leg["error_message"] def test_list_servers_no_error_message_omits_field() -> None: """When boto3 returns a ClientError with no Message, the leg should NOT carry an empty ``error_message`` field — keeps the JSON column tight on the common no-body case.""" p = _provider() regions = ["us-east-1"] def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2": paginator = MagicMock() # ClientError without Message from botocore.exceptions import ClientError paginator.paginate.side_effect = ClientError( error_response={ "Error": {"Code": "RequestLimitExceeded"}, "ResponseMetadata": {"HTTPStatusCode": 403}, }, operation_name="DescribeInstances", ) client.get_paginator.return_value = paginator return client with patch.object(p, "_ec2_regions_sync", return_value=regions): with patch.object(p._session, "client", side_effect=_client_factory): p._list_servers_sync() assert len(p.partial_failures) == 1 leg = p.partial_failures[0] assert leg["reason"] == "RequestLimitExceeded" # ClientError without Message: error_message either absent OR # contains the code itself (botocore's fallback). Either is OK — # what we don't want is a None / empty string field. assert leg.get("error_message") in (None, "RequestLimitExceeded") or ( isinstance(leg.get("error_message"), str) and leg["error_message"].strip() ) def test_list_servers_auth_failure_still_raises_provider_auth_error() -> None: """``AuthFailure`` / ``InvalidClientTokenId`` are still raised as ``ProviderAuthError`` — credentials are globally broken and a per-region skip would hide that fact across the whole sync.""" from app.domains.cmdb.providers.exceptions import ProviderAuthError p = _provider() regions = ["us-east-1"] def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2": paginator = MagicMock() paginator.paginate.side_effect = _client_error("AuthFailure") client.get_paginator.return_value = paginator return client with patch.object(p, "_ec2_regions_sync", return_value=regions): with patch.object(p._session, "client", side_effect=_client_factory): with pytest.raises(ProviderAuthError): p._list_servers_sync() @pytest.mark.parametrize( "code", ["UnauthorizedOperation", "AccessDenied", "AccessDeniedException"] ) def test_region_discovery_permission_gap_raises_provider_auth_error(code: str) -> None: """A principal lacking ``ec2:DescribeRegions`` (common for an ACM-only / narrowly-scoped key driving the certificates capability) used to fall through to ``ProviderTransientError`` — the cron then looped forever marking the capability transient and paged the failure-notifier every tick. Region discovery is the gateway for every regional capability, so a permission gap there is structural: classify as ``ProviderAuthError`` so the sync lands a clean, actionable ``failed`` (grant ``ec2:DescribeRegions``), matching the lifecycle path. """ from app.domains.cmdb.providers.exceptions import ( ProviderAuthError, ProviderTransientError, ) p = _provider() def _client_factory(service: str, region_name: str): client = MagicMock() if service == "ec2": client.describe_regions.side_effect = _client_error(code) return client with patch.object(p._session, "client", side_effect=_client_factory): with pytest.raises(ProviderAuthError): p._ec2_regions_sync() # And specifically NOT the transient class that loops forever. try: p._ec2_regions_sync() except ProviderTransientError: # pragma: no cover - guard pytest.fail("permission gap must not be classified as transient") except ProviderAuthError: pass