diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py b/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py index f10feccd628..1ba006f76b6 100644 --- a/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_base64.py @@ -9,7 +9,9 @@ from typing import Any, Dict from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import ResolutionMode from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver +from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref class FnBase64Resolver(IntrinsicFunctionResolver): @@ -32,7 +34,7 @@ class FnBase64Resolver(IntrinsicFunctionResolver): FUNCTION_NAMES = ["Fn::Base64"] - def resolve(self, value: Dict[str, Any]) -> str: + def resolve(self, value: Dict[str, Any]) -> Any: """ Resolve the Fn::Base64 intrinsic function. @@ -46,7 +48,10 @@ def resolve(self, value: Dict[str, Any]) -> str: {"Fn::Base64": {"Ref": "MyStringParam"}} Returns: - The base64-encoded string. + The base64-encoded string. In PARTIAL resolution mode, if the + argument is an unresolved Ref to a declared template parameter or a + pseudo-parameter, the original Fn::Base64 call is returned verbatim + so CloudFormation can resolve it at deploy time. Raises: InvalidTemplateException: If the resolved value is not a string. @@ -64,6 +69,15 @@ def resolve(self, value: Dict[str, Any]) -> str: # If no parent resolver, use args as-is (for testing) resolved_args = args + # In PARTIAL mode, if the argument resolved to a deferred parameter Ref + # (a Ref to a declared parameter without a default/override, or to a + # pseudo-parameter), preserve the call so CloudFormation can resolve it + # at deploy time. Resource refs / GetAtt / etc. still raise. + if is_unresolved_param_or_pseudo_ref(resolved_args, self.context): + if self.context.resolution_mode == ResolutionMode.PARTIAL: + return {"Fn::Base64": resolved_args} + raise InvalidTemplateException("Fn::Base64 layout is incorrect") + # Validate that the resolved value is a string if not isinstance(resolved_args, str): raise InvalidTemplateException("Fn::Base64 layout is incorrect") diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py index 7a73056c40c..6d15e2623a4 100644 --- a/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py @@ -12,7 +12,9 @@ from typing import Any, Dict from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import ResolutionMode from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver +from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref class FnFindInMapResolver(IntrinsicFunctionResolver): @@ -95,12 +97,23 @@ def resolve(self, value: Dict[str, Any]) -> Any: top_key = top_key_arg second_key = second_key_arg - # Validate resolved keys are strings - if not isinstance(map_name, str): - raise InvalidTemplateException("Fn::FindInMap layout is incorrect") - if not isinstance(top_key, str): - raise InvalidTemplateException("Fn::FindInMap layout is incorrect") - if not isinstance(second_key, str): + # In PARTIAL mode, if a key didn't resolve to a string but did resolve + # to a deferred parameter/pseudo-parameter Ref (Ref to a declared + # parameter without a default value and no override, or to a + # pseudo-parameter without a provided value), preserve the call so + # CloudFormation can resolve it at deploy time. See GitHub issue #9004. + # Resource refs and other intrinsics (Fn::GetAtt, etc.) continue to + # raise because they can't be resolved by the template-time + # Fn::FindInMap function at any stage — matching Kotlin compat. + keys = (map_name, top_key, second_key) + if not all(isinstance(k, str) for k in keys): + if self.context.resolution_mode == ResolutionMode.PARTIAL and all( + isinstance(k, str) or is_unresolved_param_or_pseudo_ref(k, self.context) for k in keys + ): + preserved = [map_name, top_key, second_key] + if len(args) >= self._ARGS_WITH_DEFAULT: + preserved.append(args[3]) + return {"Fn::FindInMap": preserved} raise InvalidTemplateException("Fn::FindInMap layout is incorrect") # Check for DefaultValue option (4th argument) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_join.py b/samcli/lib/cfn_language_extensions/resolvers/fn_join.py index 0c824135ca9..f080a8fa0d5 100644 --- a/samcli/lib/cfn_language_extensions/resolvers/fn_join.py +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_join.py @@ -10,7 +10,9 @@ from typing import Any, Dict from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import ResolutionMode from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver +from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref class FnJoinResolver(IntrinsicFunctionResolver): @@ -37,7 +39,7 @@ class FnJoinResolver(IntrinsicFunctionResolver): _EXPECTED_ARGS = 2 - def resolve(self, value: Dict[str, Any]) -> str: + def resolve(self, value: Dict[str, Any]) -> Any: """ Resolve the Fn::Join intrinsic function. @@ -50,7 +52,9 @@ def resolve(self, value: Dict[str, Any]) -> str: E.g., {"Fn::Join": [",", ["a", "b", "c"]]} Returns: - A string with all list elements joined by the delimiter. + A string with all list elements joined by the delimiter, or - in + PARTIAL mode when an arg is an unresolved parameter Ref - the + preserved Fn::Join call as a dict. Raises: InvalidTemplateException: If the layout is incorrect. @@ -70,14 +74,25 @@ def resolve(self, value: Dict[str, Any]) -> str: if self.parent is not None: delimiter = self.parent.resolve_value(delimiter) - # Validate delimiter is a string - if not isinstance(delimiter, str): - raise InvalidTemplateException("Fn::Join layout is incorrect") - # Resolve any nested intrinsic functions in the list if self.parent is not None: list_to_join = self.parent.resolve_value(list_to_join) + # In PARTIAL mode, preserve the call when either argument is still an + # unresolved Ref to a declared template parameter or a pseudo-parameter + # (i.e. CloudFormation will resolve it at deploy time). Resource refs + # and other intrinsics still raise — they aren't valid Fn::Join inputs. + delim_is_param_ref = is_unresolved_param_or_pseudo_ref(delimiter, self.context) + list_is_param_ref = is_unresolved_param_or_pseudo_ref(list_to_join, self.context) + if delim_is_param_ref or list_is_param_ref: + if self.context.resolution_mode == ResolutionMode.PARTIAL: + return {"Fn::Join": [delimiter, list_to_join]} + raise InvalidTemplateException("Fn::Join layout is incorrect") + + # Validate delimiter is a string + if not isinstance(delimiter, str): + raise InvalidTemplateException("Fn::Join layout is incorrect") + # Validate the list if not isinstance(list_to_join, list): raise InvalidTemplateException("Fn::Join layout is incorrect") diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_select.py b/samcli/lib/cfn_language_extensions/resolvers/fn_select.py index fa044e2e0ee..79299509b98 100644 --- a/samcli/lib/cfn_language_extensions/resolvers/fn_select.py +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_select.py @@ -10,7 +10,9 @@ from typing import Any, Dict from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.models import ResolutionMode from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver +from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref class FnSelectResolver(IntrinsicFunctionResolver): @@ -73,6 +75,18 @@ def resolve(self, value: Dict[str, Any]) -> Any: if self.parent is not None: index = self.parent.resolve_value(index) + # In PARTIAL mode, if the index resolved to a deferred parameter Ref, + # preserve the call. Resource refs / GetAtt / etc. still raise. + # We must still resolve the source list below in case it contains + # resolvable intrinsics — that way, partial expansion makes maximal + # progress. + if is_unresolved_param_or_pseudo_ref(index, self.context): + if self.context.resolution_mode != ResolutionMode.PARTIAL: + raise InvalidTemplateException("Fn::Select layout is incorrect") + if self.parent is not None: + source_list = self.parent.resolve_value(source_list) + return {"Fn::Select": [index, source_list]} + # Validate index is an integer (or can be converted to one) if isinstance(index, str): try: diff --git a/samcli/lib/cfn_language_extensions/utils.py b/samcli/lib/cfn_language_extensions/utils.py index cf4716383df..f54caf79c82 100644 --- a/samcli/lib/cfn_language_extensions/utils.py +++ b/samcli/lib/cfn_language_extensions/utils.py @@ -5,7 +5,10 @@ for working with templates that may contain Fn::ForEach blocks. """ -from typing import Dict, Iterator, Tuple +from typing import TYPE_CHECKING, Any, Dict, Iterator, Tuple + +if TYPE_CHECKING: + from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext FOREACH_PREFIX = "Fn::ForEach::" @@ -65,6 +68,31 @@ def is_intrinsic_key(key: str) -> bool: return key.startswith("Fn::") or key in _INTRINSIC_SINGLE_KEYS +def is_unresolved_param_or_pseudo_ref(value: Any, context: "TemplateProcessingContext") -> bool: + """Return True if *value* is ``{"Ref": }`` where ```` is a declared + template parameter or a pseudo-parameter — i.e. an unresolved reference that + CloudFormation will resolve at deploy time. + + Used by template-time intrinsic resolvers (Fn::FindInMap, Fn::Join, Fn::Select, + Fn::Base64) to decide, in PARTIAL resolution mode, whether to preserve the + enclosing call instead of raising. Resource refs and other intrinsics return + False so they continue to raise — matching Kotlin compatibility for + template-time intrinsics that genuinely cannot accept deploy-time inputs. + """ + if not isinstance(value, dict) or len(value) != 1: + return False + if "Ref" not in value: + return False + ref_target = value["Ref"] + if not isinstance(ref_target, str): + return False + if ref_target in PSEUDO_PARAMETERS: + return True + if context.parsed_template is not None and ref_target in context.parsed_template.parameters: + return True + return False + + # Mapping-name prefixes that SAM CLI emits for dynamic Fn::ForEach handling: # - SAM + + + [resource suffix] # (see language_extensions_packaging._compute_mapping_name) diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_base64.py b/tests/unit/lib/cfn_language_extensions/test_fn_base64.py index 830478061e8..18369a95dd4 100644 --- a/tests/unit/lib/cfn_language_extensions/test_fn_base64.py +++ b/tests/unit/lib/cfn_language_extensions/test_fn_base64.py @@ -25,6 +25,7 @@ IntrinsicResolver, ) from samcli.lib.cfn_language_extensions.resolvers.fn_base64 import FnBase64Resolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException @@ -478,6 +479,22 @@ def test_fn_base64_with_preserved_intrinsic(self, orchestrator: IntrinsicResolve "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, } + def test_unresolved_arg_is_preserved_in_partial_mode(self): + from samcli.lib.cfn_language_extensions.models import ParsedTemplate + + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=ParsedTemplate(parameters={"UserData": {"Type": "String"}}), + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnBase64Resolver) + + value = {"Fn::Base64": {"Ref": "UserData"}} + result = orch.resolve_value(value) + assert result == {"Fn::Base64": {"Ref": "UserData"}} + class TestFnBase64ResolverRealWorldScenarios: """Tests for real-world CloudFormation scenarios using Fn::Base64.""" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py index b334618305d..f65eb9a64f3 100644 --- a/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py +++ b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py @@ -38,6 +38,7 @@ IntrinsicResolver, ) from samcli.lib.cfn_language_extensions.resolvers.fn_find_in_map import FnFindInMapResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException # ============================================================================= @@ -2077,3 +2078,152 @@ def test_fallback_to_fragment_when_no_parsed_template(self): value = {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]} result = resolver.resolve(value) assert result == "ami-12345678" + + +class TestFnFindInMapResolverPartialModeWithUnresolvedRef: + """Tests for FnFindInMapResolver in PARTIAL resolution mode when keys are + unresolved Refs to declared template parameters or pseudo-parameters. + + Distinct from the earlier TestFnFindInMapResolverPartialMode class above — + these tests exercise the orchestrator with FnRefResolver registered, to + cover the unresolved-Ref-as-key paths. + """ + + @pytest.fixture + def partial_context(self) -> TemplateProcessingContext: + parsed = ParsedTemplate( + parameters={"Stage": {"Type": "String"}}, + mappings={"M": {"dev": {"k": "v"}}}, + ) + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=parsed, + ) + + @pytest.fixture + def orchestrator(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnRefResolver) + orchestrator.register_resolver(FnFindInMapResolver) + return orchestrator + + def test_unresolved_top_key_is_preserved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Issue #9004: top-level key is Ref to a parameter without default/override.""" + value = {"Fn::FindInMap": ["M", {"Ref": "Stage"}, "k"]} + result = orchestrator.resolve_value(value) + assert result == {"Fn::FindInMap": ["M", {"Ref": "Stage"}, "k"]} + + def test_unresolved_map_name_is_preserved_in_partial_mode(self, orchestrator: IntrinsicResolver): + value = {"Fn::FindInMap": [{"Ref": "Stage"}, "dev", "k"]} + result = orchestrator.resolve_value(value) + assert result == {"Fn::FindInMap": [{"Ref": "Stage"}, "dev", "k"]} + + def test_unresolved_second_key_is_preserved_in_partial_mode(self, orchestrator: IntrinsicResolver): + value = {"Fn::FindInMap": ["M", "dev", {"Ref": "Stage"}]} + result = orchestrator.resolve_value(value) + assert result == {"Fn::FindInMap": ["M", "dev", {"Ref": "Stage"}]} + + def test_unresolved_key_with_default_value_is_preserved_in_partial_mode(self, orchestrator: IntrinsicResolver): + """When DefaultValue is present, preserve the entire call including the options dict.""" + value = {"Fn::FindInMap": ["M", {"Ref": "Stage"}, "k", {"DefaultValue": "fallback"}]} + result = orchestrator.resolve_value(value) + assert result == {"Fn::FindInMap": ["M", {"Ref": "Stage"}, "k", {"DefaultValue": "fallback"}]} + + def test_resolved_keys_still_perform_lookup_in_partial_mode(self, orchestrator: IntrinsicResolver): + """Sanity: when keys do resolve, lookup still works in PARTIAL mode.""" + value = {"Fn::FindInMap": ["M", "dev", "k"]} + result = orchestrator.resolve_value(value) + assert result == "v" + + def test_unresolved_top_key_still_raises_in_full_mode(self): + """In FULL mode, an unresolvable Ref raises (regression-guard for the FULL path).""" + from samcli.lib.cfn_language_extensions.exceptions import UnresolvableReferenceError + + parsed = ParsedTemplate(mappings={"M": {"dev": {"k": "v"}}}) + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.FULL, + parsed_template=parsed, + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnFindInMapResolver) + with pytest.raises((InvalidTemplateException, UnresolvableReferenceError)): + orch.resolve_value({"Fn::FindInMap": ["M", {"Ref": "Missing"}, "k"]}) + + def test_resource_ref_as_key_still_raises_in_partial_mode(self): + """Kotlin compat: a Ref to a *resource* (not a parameter) is not a valid + Fn::FindInMap key and must raise even in PARTIAL mode. Without this guard, + the fix would over-broaden and break tests/.../compatibility/templates/ + fnFindInMapWithUnsupportedFunctionFnRef.json.""" + # No 'Queue' parameter declared — only a 'Queue' resource. + parsed = ParsedTemplate( + parameters={}, + mappings={"M": {"dev": {"k": "v"}}}, + ) + ctx = TemplateProcessingContext( + fragment={"Resources": {"Queue": {"Type": "AWS::SQS::Queue"}}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=parsed, + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnFindInMapResolver) + with pytest.raises(InvalidTemplateException): + orch.resolve_value({"Fn::FindInMap": ["M", {"Ref": "Queue"}, "k"]}) + + def test_getatt_as_key_still_raises_in_partial_mode(self): + """Kotlin compat: Fn::GetAtt as a key must raise even in PARTIAL mode.""" + parsed = ParsedTemplate(mappings={"M": {"dev": {"k": "v"}}}) + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=parsed, + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnFindInMapResolver) + with pytest.raises(InvalidTemplateException): + orch.resolve_value({"Fn::FindInMap": ["M", {"Fn::GetAtt": ["Q", "Arn"]}, "k"]}) + + +class TestFnFindInMapEndToEndWithUnresolvedRef: + """End-to-end regression test driven through process_template. + + Repro: a template with AWS::LanguageExtensions transform, a parameter that + has no Default and no override, and Fn::FindInMap using !Ref to that + parameter as a key. Before the fix, this raised "Fn::FindInMap layout is + incorrect" during sam build. After the fix, the call is preserved verbatim + in PARTIAL mode and CloudFormation resolves it at deploy time. + """ + + def test_template_with_unresolved_ref_in_findinmap_processes_in_partial_mode(self): + from samcli.lib.cfn_language_extensions.api import process_template + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "Stage": {"Type": "String"}, + }, + "Mappings": { + "EnvConfig": { + "dev": {"BucketName": "dev-bucket"}, + "prod": {"BucketName": "prod-bucket"}, + }, + }, + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": {"BucketName": {"Fn::FindInMap": ["EnvConfig", {"Ref": "Stage"}, "BucketName"]}}, + }, + }, + } + + # In PARTIAL mode (sam build's mode), processing must not raise. + result = process_template(template, resolution_mode=ResolutionMode.PARTIAL) + + # The Fn::FindInMap call is preserved unchanged for CloudFormation. + bucket_name = result["Resources"]["MyBucket"]["Properties"]["BucketName"] + assert bucket_name == {"Fn::FindInMap": ["EnvConfig", {"Ref": "Stage"}, "BucketName"]} diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_join.py b/tests/unit/lib/cfn_language_extensions/test_fn_join.py index 08a1121d2ee..f5dfc7562d7 100644 --- a/tests/unit/lib/cfn_language_extensions/test_fn_join.py +++ b/tests/unit/lib/cfn_language_extensions/test_fn_join.py @@ -410,6 +410,38 @@ def test_fn_join_with_preserved_intrinsic(self, orchestrator: IntrinsicResolver) "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, } + def test_unresolved_delimiter_is_preserved_in_partial_mode(self): + from samcli.lib.cfn_language_extensions.models import ParsedTemplate + + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=ParsedTemplate(parameters={"Sep": {"Type": "String"}}), + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnJoinResolver) + + value = {"Fn::Join": [{"Ref": "Sep"}, ["a", "b"]]} + result = orch.resolve_value(value) + assert result == {"Fn::Join": [{"Ref": "Sep"}, ["a", "b"]]} + + def test_unresolved_list_is_preserved_in_partial_mode(self): + from samcli.lib.cfn_language_extensions.models import ParsedTemplate + + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=ParsedTemplate(parameters={"L": {"Type": "CommaDelimitedList"}}), + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnJoinResolver) + + value = {"Fn::Join": [",", {"Ref": "L"}]} + result = orch.resolve_value(value) + assert result == {"Fn::Join": [",", {"Ref": "L"}]} + class TestFnJoinResolverRealWorldExamples: """Tests for Fn::Join with real-world CloudFormation patterns.""" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_select.py b/tests/unit/lib/cfn_language_extensions/test_fn_select.py index 086f7e48ee6..768e48cbab8 100644 --- a/tests/unit/lib/cfn_language_extensions/test_fn_select.py +++ b/tests/unit/lib/cfn_language_extensions/test_fn_select.py @@ -516,6 +516,22 @@ def test_fn_select_with_preserved_intrinsic(self, orchestrator: IntrinsicResolve "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, } + def test_unresolved_index_is_preserved_in_partial_mode(self): + from samcli.lib.cfn_language_extensions.models import ParsedTemplate + + ctx = TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=ParsedTemplate(parameters={"Idx": {"Type": "Number"}}), + ) + orch = IntrinsicResolver(ctx) + orch.register_resolver(FnRefResolver) + orch.register_resolver(FnSelectResolver) + + value = {"Fn::Select": [{"Ref": "Idx"}, ["a", "b", "c"]]} + result = orch.resolve_value(value) + assert result == {"Fn::Select": [{"Ref": "Idx"}, ["a", "b", "c"]]} + class TestFnSelectResolverRealWorldExamples: """Tests for Fn::Select with real-world CloudFormation patterns.""" diff --git a/tests/unit/lib/cfn_language_extensions/test_utils.py b/tests/unit/lib/cfn_language_extensions/test_utils.py index 328d84da7a7..d64f46f0392 100644 --- a/tests/unit/lib/cfn_language_extensions/test_utils.py +++ b/tests/unit/lib/cfn_language_extensions/test_utils.py @@ -2,11 +2,13 @@ from unittest import TestCase +from samcli.lib.cfn_language_extensions.models import ParsedTemplate, ResolutionMode, TemplateProcessingContext from samcli.lib.cfn_language_extensions.utils import ( derive_partition, derive_url_suffix, is_foreach_key, is_sam_generated_mapping, + is_unresolved_param_or_pseudo_ref, iter_regular_resources, ) @@ -73,3 +75,47 @@ def test_layers_prefix_matches(self): def test_layers_digit_matches(self): self.assertTrue(is_sam_generated_mapping("SAMLayers1stBatch")) + + +class TestIsUnresolvedParamOrPseudoRef(TestCase): + def _ctx(self, parameters=None) -> TemplateProcessingContext: + return TemplateProcessingContext( + fragment={"Resources": {}}, + resolution_mode=ResolutionMode.PARTIAL, + parsed_template=ParsedTemplate(parameters=parameters or {}), + ) + + def test_ref_to_declared_parameter(self): + ctx = self._ctx(parameters={"Stage": {"Type": "String"}}) + self.assertTrue(is_unresolved_param_or_pseudo_ref({"Ref": "Stage"}, ctx)) + + def test_ref_to_pseudo_parameter(self): + ctx = self._ctx() + self.assertTrue(is_unresolved_param_or_pseudo_ref({"Ref": "AWS::Region"}, ctx)) + + def test_ref_to_resource_returns_false(self): + ctx = self._ctx() # No "Queue" parameter declared. + self.assertFalse(is_unresolved_param_or_pseudo_ref({"Ref": "Queue"}, ctx)) + + def test_getatt_returns_false(self): + ctx = self._ctx() + self.assertFalse(is_unresolved_param_or_pseudo_ref({"Fn::GetAtt": ["Q", "Arn"]}, ctx)) + + def test_non_dict_returns_false(self): + ctx = self._ctx() + self.assertFalse(is_unresolved_param_or_pseudo_ref("Stage", ctx)) + self.assertFalse(is_unresolved_param_or_pseudo_ref(["Ref", "Stage"], ctx)) + self.assertFalse(is_unresolved_param_or_pseudo_ref(None, ctx)) + + def test_multi_key_dict_returns_false(self): + ctx = self._ctx(parameters={"Stage": {"Type": "String"}}) + self.assertFalse(is_unresolved_param_or_pseudo_ref({"Ref": "Stage", "extra": 1}, ctx)) + + def test_non_string_ref_target_returns_false(self): + ctx = self._ctx() + self.assertFalse(is_unresolved_param_or_pseudo_ref({"Ref": 123}, ctx)) + + def test_no_parsed_template_only_pseudo_matches(self): + ctx = TemplateProcessingContext(fragment={"Resources": {}}, resolution_mode=ResolutionMode.PARTIAL) + self.assertTrue(is_unresolved_param_or_pseudo_ref({"Ref": "AWS::Region"}, ctx)) + self.assertFalse(is_unresolved_param_or_pseudo_ref({"Ref": "Stage"}, ctx))