"""Regression: Aliyun ``_call`` must map "service not subscribed" error responses to ``CapabilityNotSupported`` rather than ``ProviderAuthError``. Bug report 2026-05-05: an Aliyun account had no CDN service subscription but the standard CDN ``DescribeUserDomains`` call from the periodic ``cmdb.resource_sync`` job returned HTTP 403 with body ``{"Code": "CdnServiceNotFound", "Message": "Your account does not open CDN service yet."}``. The old ``_call`` blindly mapped any 401/403 to ``ProviderAuthError``, so the scheduled-job history showed ``cmdb.resource_sync ... auth error: ...`` (red status, false alert that the AK was bad). The credential was actually fine — only CDN was unsubscribed. Post-fix: when the body's ``Code`` is in ``ALIYUN_SERVICE_NOT_SUBSCRIBED_CODES``, ``_call`` raises ``CapabilityNotSupported`` instead. The outer sync framework catches that as a ``skipped`` capability (vs ``failed``), so historical rows survive untouched and the scheduled-job stays green. """ from __future__ import annotations import json from unittest.mock import AsyncMock, MagicMock import httpx import pytest from app.domains.cmdb.providers.aliyun import ( ALIYUN_SERVICE_NOT_SUBSCRIBED_CODES, AliyunProvider, ) from app.domains.cmdb.providers.base import ProviderCredentials from app.domains.cmdb.providers.exceptions import ( CapabilityNotSupported, ProviderAuthError, ) def _creds() -> ProviderCredentials: return ProviderCredentials( provider_id="test-aliyun", provider_type="aliyun", account_alias="test", fields={ "access_key_id": "LTAI...", "access_key_secret": "SECRET", }, ) def _http_response( status_code: int, body: dict | str | None, ) -> httpx.Response: """Build a stand-in httpx.Response. We set ``_content`` directly so ``resp.json()`` and ``resp.text`` both round-trip without needing a live transport.""" if isinstance(body, dict): content = json.dumps(body).encode() headers = {"content-type": "application/json"} elif isinstance(body, str): content = body.encode() headers = {"content-type": "text/plain"} else: content = b"" headers = {} return httpx.Response( status_code=status_code, content=content, headers=headers, request=httpx.Request("GET", "https://cdn.aliyuncs.com/"), ) @pytest.mark.asyncio async def test_call_raises_capability_not_supported_on_cdn_403() -> None: """403 + ``Code: CdnServiceNotFound`` → CapabilityNotSupported. This is the canonical case from the production report: AWS account on Aliyun never opened CDN, gateway returns 403 (no permission against an unsubscribed service), body carries the ``CdnServiceNotFound`` code. """ provider = AliyunProvider(_creds()) response = _http_response(403, { "RequestId": "2E40432D-...", "HostId": "cdn.aliyuncs.com", "Code": "CdnServiceNotFound", "Message": "Your account does not open CDN service yet.", }) provider._client = MagicMock() provider._client.get = AsyncMock(return_value=response) with pytest.raises(CapabilityNotSupported) as exc_info: await provider._call( "https://cdn.aliyuncs.com/", {"Action": "DescribeUserDomains"}, ) assert "CdnServiceNotFound" in str(exc_info.value) @pytest.mark.asyncio async def test_call_raises_provider_auth_error_on_real_403() -> None: """403 with NON-SNF code (or no Code at all) MUST still raise ``ProviderAuthError``. Pin against the wrong-direction regression where a real bad-AK fault gets silently classified as ``skipped``.""" provider = AliyunProvider(_creds()) response = _http_response(403, { "RequestId": "deadbeef", "HostId": "ecs.aliyuncs.com", "Code": "InvalidAccessKeyId.NotFound", "Message": "The Access Key ID provided does not exist.", }) provider._client = MagicMock() provider._client.get = AsyncMock(return_value=response) with pytest.raises(ProviderAuthError): await provider._call( "https://ecs.aliyuncs.com/", {"Action": "DescribeInstances"}, ) @pytest.mark.asyncio async def test_call_raises_provider_auth_error_on_403_without_code() -> None: """403 with no JSON body (HTML 502 from a misrouted gateway, plain-text rate-limit page, etc.) falls through to the ProviderAuthError branch — we don't have a Code to match against, so we're conservative and call it auth.""" provider = AliyunProvider(_creds()) response = _http_response(403, "Forbidden") provider._client = MagicMock() provider._client.get = AsyncMock(return_value=response) with pytest.raises(ProviderAuthError): await provider._call( "https://ecs.aliyuncs.com/", {"Action": "DescribeInstances"}, ) @pytest.mark.asyncio async def test_call_raises_capability_not_supported_on_200_with_snf_code() -> None: """Some Aliyun services return HTTP 200 with an error JSON body containing ``Code``+``Message`` instead of a 4xx status. The fix must catch SNF in the body even on 2xx. """ provider = AliyunProvider(_creds()) response = _http_response(200, { "RequestId": "abc", "Code": "CdnService.NotFound", "Message": "CDN service not opened.", }) provider._client = MagicMock() provider._client.get = AsyncMock(return_value=response) with pytest.raises(CapabilityNotSupported) as exc_info: await provider._call( "https://cdn.aliyuncs.com/", {"Action": "DescribeUserDomains"}, ) assert "CdnService.NotFound" in str(exc_info.value) def test_snf_codes_includes_the_user_reported_one() -> None: """Pin the user-reported error code is in the SNF set so a future refactor that prunes the set without re-checking the bug report can't accidentally regress.""" assert "CdnServiceNotFound" in ALIYUN_SERVICE_NOT_SUBSCRIBED_CODES def test_snf_codes_only_contain_prod_observed_entries() -> None: """Tightened 2026-05-05: only Codes verbatim observed in real production failures live in the set. Pin the cardinality so adding a Code from a doc/SDK enum (without a matching prod report) trips this test and the author has to justify the addition. See ``aliyun.py`` block comment for the policy.""" assert ALIYUN_SERVICE_NOT_SUBSCRIBED_CODES == frozenset({ "CdnServiceNotFound", "CdnService.NotFound", })