@@ -2034,3 +2034,158 @@ async def test_description_states_savings_plans_data_lag():
20342034 )
20352035 assert 'an unknown SAVINGS_PLANS_TYPE matches nothing' in tool .description
20362036 assert 'report that no data is available for that window (not zero)' in tool .description
2037+
2038+
2039+ @pytest .mark .asyncio
2040+ async def test_ce_get_savings_plans_utilization_rejects_billing_view_arn (
2041+ mock_context , sample_billing_view_arn
2042+ ):
2043+ """GetSavingsPlansUtilization has no BillingViewArn, so the tool refuses the ARN.
2044+
2045+ Dropping it would return utilization for the primary view labelled as the caller's view.
2046+ """
2047+ ce_mod = _reload_ce_with_identity_decorator ()
2048+ real_fn = ce_mod .cost_explorer # type: ignore
2049+
2050+ with (
2051+ patch .object (ce_mod , 'create_aws_client' ) as mock_create_client ,
2052+ patch .object (ce_mod , 'get_savings_plans_utilization' , new_callable = AsyncMock ) as mock_impl ,
2053+ ):
2054+ mock_create_client .return_value = MagicMock ()
2055+
2056+ res = await real_fn ( # type: ignore
2057+ mock_context ,
2058+ operation = 'getSavingsPlansUtilization' ,
2059+ start_date = '2024-01-01' ,
2060+ end_date = '2024-02-01' ,
2061+ granularity = 'MONTHLY' ,
2062+ billing_view_arn = sample_billing_view_arn ,
2063+ )
2064+
2065+ assert res ['status' ] == 'error'
2066+ assert res ['error_type' ] == 'validation_error'
2067+ assert 'billing_view_arn' in res ['message' ]
2068+ assert 'getSavingsPlansUtilization' in res ['message' ]
2069+ assert 'billing_view_arn' in res ['hint' ]
2070+ mock_impl .assert_not_awaited ()
2071+
2072+
2073+ @pytest .mark .asyncio
2074+ async def test_ce_get_savings_plans_utilization_empty_billing_view_arn_is_absent (mock_context ):
2075+ """An empty billing_view_arn means no view, as for the operations that send it."""
2076+ ce_mod = _reload_ce_with_identity_decorator ()
2077+ real_fn = ce_mod .cost_explorer # type: ignore
2078+
2079+ with (
2080+ patch .object (ce_mod , 'create_aws_client' ) as mock_create_client ,
2081+ patch .object (ce_mod , 'get_savings_plans_utilization' , new_callable = AsyncMock ) as mock_impl ,
2082+ ):
2083+ mock_create_client .return_value = MagicMock ()
2084+ mock_impl .return_value = {'status' : 'success' , 'data' : {}}
2085+
2086+ res = await real_fn ( # type: ignore
2087+ mock_context ,
2088+ operation = 'getSavingsPlansUtilization' ,
2089+ start_date = '2024-01-01' ,
2090+ end_date = '2024-02-01' ,
2091+ billing_view_arn = '' ,
2092+ )
2093+
2094+ assert res ['status' ] == 'success'
2095+ mock_impl .assert_awaited_once ()
2096+
2097+
2098+ @pytest .mark .asyncio
2099+ async def test_description_names_billing_view_operations ():
2100+ """The description names which operations take billing_view_arn and which refuse it."""
2101+ tool = await cost_explorer_server .get_tool ('cost-explorer' )
2102+ assert tool is not None and tool .description
2103+ description = ' ' .join (tool .description .split ())
2104+ assert 'Most Cost Explorer operations support an optional billing_view_arn' not in description
2105+ assert (
2106+ 'billing_view_arn is accepted by getCostAndUsage, getCostAndUsageWithResources, '
2107+ 'getDimensionValues (COST_AND_USAGE context only), getCostForecast, getUsageForecast, '
2108+ 'getTagsOrValues and getCostCategories'
2109+ ) in description
2110+ assert (
2111+ 'getSavingsPlansUtilization (the AWS API has no billing view parameter) and '
2112+ 'getDimensionValues with context RESERVATIONS or SAVINGS_PLANS (AWS does not apply the '
2113+ 'view there) do not accept billing_view_arn'
2114+ ) in description
2115+
2116+
2117+ @pytest .mark .asyncio
2118+ @pytest .mark .parametrize ('context' , ['RESERVATIONS' , 'SAVINGS_PLANS' ])
2119+ async def test_ce_get_dimension_values_rejects_billing_view_arn_outside_cost_and_usage (
2120+ mock_context , sample_billing_view_arn , context
2121+ ):
2122+ """AWS does not apply a billing view to RESERVATIONS or SAVINGS_PLANS dimension values.
2123+
2124+ Sending the ARN would return unscoped values labelled as the caller's view.
2125+ """
2126+ ce_mod = _reload_ce_with_identity_decorator ()
2127+ real_fn = ce_mod .cost_explorer # type: ignore
2128+
2129+ with (
2130+ patch .object (ce_mod , 'create_aws_client' ) as mock_create_client ,
2131+ patch .object (ce_mod , 'get_dimension_values' , new_callable = AsyncMock ) as mock_impl ,
2132+ ):
2133+ mock_create_client .return_value = MagicMock ()
2134+
2135+ res = await real_fn ( # type: ignore
2136+ mock_context ,
2137+ operation = 'getDimensionValues' ,
2138+ dimension = 'LINKED_ACCOUNT' ,
2139+ context = context ,
2140+ billing_view_arn = sample_billing_view_arn ,
2141+ )
2142+
2143+ assert res ['status' ] == 'error'
2144+ assert res ['error_type' ] == 'validation_error'
2145+ assert 'billing_view_arn' in res ['message' ]
2146+ assert f'context={ context } ' in res ['message' ]
2147+ assert 'Do **NOT** retry with the same billing_view_arn' in res ['hint' ]
2148+ assert context not in res ['hint' ] # the hint is fixed text; the context is in message
2149+ mock_impl .assert_not_awaited ()
2150+
2151+
2152+ @pytest .mark .asyncio
2153+ @pytest .mark .parametrize (
2154+ 'context, billing_view_arn' ,
2155+ [
2156+ ('COST_AND_USAGE' , 'arn:aws:billing::123456789012:billingview/custom-abc' ),
2157+ ('SAVINGS_PLANS' , None ),
2158+ ('RESERVATIONS' , '' ),
2159+ ('savings_plans' , 'arn:aws:billing::123456789012:billingview/custom-abc' ),
2160+ ],
2161+ )
2162+ async def test_ce_get_dimension_values_billing_view_arn_allowed_cases (
2163+ mock_context , context , billing_view_arn
2164+ ):
2165+ """COST_AND_USAGE still sends the ARN; the other contexts run when no ARN is set.
2166+
2167+ A lowercase context goes to AWS unchanged, which rejects the context itself.
2168+ """
2169+ ce_mod = _reload_ce_with_identity_decorator ()
2170+ real_fn = ce_mod .cost_explorer # type: ignore
2171+
2172+ with (
2173+ patch .object (ce_mod , 'create_aws_client' ) as mock_create_client ,
2174+ patch .object (ce_mod , 'get_dimension_values' , new_callable = AsyncMock ) as mock_impl ,
2175+ ):
2176+ mock_create_client .return_value = MagicMock ()
2177+ mock_impl .return_value = {'status' : 'success' , 'data' : {'DimensionValues' : []}}
2178+
2179+ res = await real_fn ( # type: ignore
2180+ mock_context ,
2181+ operation = 'getDimensionValues' ,
2182+ dimension = 'LINKED_ACCOUNT' ,
2183+ context = context ,
2184+ billing_view_arn = billing_view_arn ,
2185+ )
2186+
2187+ assert res ['status' ] == 'success'
2188+ mock_impl .assert_awaited_once ()
2189+ await_args = mock_impl .await_args
2190+ assert await_args is not None
2191+ assert await_args .args [- 2 :] == (billing_view_arn , context )
0 commit comments