Skip to content

Commit 46907cc

Browse files
committed
test(package): integration coverage for AWS::Include + SAR Metadata in LE templates
Three end-to-end tests that exercise the package_context._export-equivalent flow on language-extension templates: - test_le_template_with_top_level_aws_include_merges_location verifies AWS::Include in Outputs gets its Location rewritten to s3:// after the pre-LE pass. - test_le_template_with_serverless_repo_metadata_merges_license_url verifies SAR LicenseUrl/ReadmeUrl in Metadata get merged back. - TestPackageContextIssue9027 reproduces the user template from #9027 (Fn::ToJsonString over Fn::Transform: AWS::Include buried inside AWS::SSM::Parameter) and asserts the buried Location is rewritten to s3://. Locks down the regression.
1 parent bc46915 commit 46907cc

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

tests/unit/commands/package/test_package_context.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4459,3 +4459,84 @@ def test_apply_mappings_replaces_build_mappings(self):
44594459
codeuri = body["${FunctionName}Function"]["Properties"]["CodeUri"]
44604460
self.assertIn("Fn::FindInMap", codeuri)
44614461
self.assertEqual(codeuri["Fn::FindInMap"][0], "SAMCodeUriFunctions")
4462+
4463+
4464+
class TestPackageContextBuriedAWSInclude(TestCase):
4465+
"""A root-level template that uses Fn::ToJsonString (a language extension)
4466+
and contains AWS::Include buried inside AWS::SSM::Parameter must end up
4467+
with the include's Location rewritten to the s3:// URL the uploader
4468+
returned, matching the behavior of sam-cli 1.159.0.
4469+
4470+
Regression coverage for https://github.com/aws/aws-sam-cli/issues/9027.
4471+
Nested-stack coverage lives in tests/unit/lib/package/test_artifact_exporter.py.
4472+
"""
4473+
4474+
def setUp(self):
4475+
import os
4476+
import tempfile
4477+
4478+
from samcli.commands.package.package_context import PackageContext
4479+
from samcli.lib.package.uploaders import Destination, Uploaders
4480+
4481+
self.tmp = tempfile.TemporaryDirectory()
4482+
self.addCleanup(self.tmp.cleanup)
4483+
4484+
self.template_path = os.path.join(self.tmp.name, "template.yaml")
4485+
self.include_path = os.path.join(self.tmp.name, "export-events.json")
4486+
4487+
with open(self.include_path, "w") as f:
4488+
f.write('[{"event": "demo"}]')
4489+
4490+
with open(self.template_path, "w") as f:
4491+
f.write(
4492+
"Transform: AWS::LanguageExtensions\n"
4493+
"Resources:\n"
4494+
" Parameter:\n"
4495+
" Type: AWS::SSM::Parameter\n"
4496+
" Properties:\n"
4497+
" Type: String\n"
4498+
" DataType: text\n"
4499+
" Value:\n"
4500+
" Fn::ToJsonString:\n"
4501+
" Fn::Transform:\n"
4502+
" Name: AWS::Include\n"
4503+
" Parameters:\n"
4504+
" Location: export-events.json\n"
4505+
)
4506+
4507+
# Mock uploader with deterministic S3 URL.
4508+
self.s3_uploader = MagicMock()
4509+
self.s3_uploader.upload_with_dedup.side_effect = (
4510+
lambda local_path, **_: f"s3://my-bucket/{os.path.basename(local_path)}-md5"
4511+
)
4512+
self.s3_uploader.EXPORT_DESTINATION = Destination.S3
4513+
4514+
self.uploaders = MagicMock(spec=Uploaders)
4515+
self.uploaders.get.return_value = self.s3_uploader
4516+
4517+
# Bypass PackageContext.__init__ — wire only the attributes _export reads.
4518+
self.ctx = PackageContext.__new__(PackageContext)
4519+
self.ctx.template_file = self.template_path
4520+
self.ctx.uploaders = self.uploaders
4521+
self.ctx.code_signer = MagicMock()
4522+
self.ctx.parameter_overrides = {}
4523+
self.ctx._global_parameter_overrides = {}
4524+
4525+
def test_buried_aws_include_location_is_rewritten(self):
4526+
from samcli.yamlhelper import yaml_parse
4527+
4528+
exported_str = self.ctx._export(self.template_path, use_json=False)
4529+
4530+
exported = yaml_parse(exported_str)
4531+
4532+
location = exported["Resources"]["Parameter"]["Properties"]["Value"]["Fn::ToJsonString"]["Fn::Transform"][
4533+
"Parameters"
4534+
]["Location"]
4535+
self.assertTrue(
4536+
location.startswith("s3://"),
4537+
f"Issue #9027: AWS::Include Location must be an S3 URI after sam package, " f"got {location!r}.",
4538+
)
4539+
self.assertEqual(location, "s3://my-bucket/export-events.json-md5")
4540+
4541+
# Sanity: the LE Transform line is preserved on the root.
4542+
self.assertEqual(exported.get("Transform"), "AWS::LanguageExtensions")

tests/unit/lib/package/test_artifact_exporter.py

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1989,6 +1989,215 @@ def test_include_transform_export_handler_non_include_transform(self, is_local_f
19891989
self.s3_uploader_mock.upload_with_dedup.assert_not_called()
19901990
self.assertEqual(handler_output, {"Name": "AWS::OtherTransform", "Parameters": {"Location": "foo.yaml"}})
19911991

1992+
def test_le_template_with_top_level_aws_include_merges_location(self):
1993+
"""End-to-end regression: an LE template with a top-level AWS::Include
1994+
in Outputs (outside any Fn::ForEach body) must end up with its
1995+
Location rewritten to the uploader's S3 URL after the
1996+
package_context._export-equivalent flow:
1997+
1998+
_export_global_artifacts_pass(template_dict, ...)
1999+
-> expand_language_extensions(...)
2000+
-> Template(template_dict=expanded).export()
2001+
-> merge_language_extensions_s3_uris(original, exported, dynamic)
2002+
2003+
The pre-LE global-transform pass is what now rewrites AWS::Include
2004+
Locations; the merge pass only handles resource artifact properties
2005+
and Metadata. See aws/aws-sam-cli#9027.
2006+
"""
2007+
import copy as _copy
2008+
2009+
from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions
2010+
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
2011+
from samcli.lib.package.artifact_exporter import _export_global_artifacts_pass
2012+
from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris
2013+
2014+
# Predictable upload URL keyed on basename so we can assert on it later.
2015+
# upload_with_dedup is invoked with absolute paths (for AWS::Include) and
2016+
# with absolute paths to temp zips (for ServerlessFunctionResource folder
2017+
# uploads); both forms have a basename we can use.
2018+
self.s3_uploader_mock.upload_with_dedup.side_effect = (
2019+
lambda local_path, **_: f"s3://bucket/{os.path.basename(local_path)}-md5"
2020+
)
2021+
2022+
with tempfile.TemporaryDirectory() as tmpdir:
2023+
# Real files on disk so is_local_file / is_local_folder return True
2024+
# without needing to mock them. extra.yaml is the AWS::Include target;
2025+
# code/ is the CodeUri folder for the (post-expansion) functions.
2026+
extra_yaml_path = os.path.join(tmpdir, "extra.yaml")
2027+
with open(extra_yaml_path, "w", encoding="utf-8") as f:
2028+
f.write("# stub include target\n")
2029+
code_dir = os.path.join(tmpdir, "code")
2030+
os.makedirs(code_dir)
2031+
with open(os.path.join(code_dir, "index.py"), "w", encoding="utf-8") as f:
2032+
f.write("def handler(event, context):\n return event\n")
2033+
2034+
template_dict = {
2035+
"Transform": "AWS::LanguageExtensions",
2036+
"Resources": {
2037+
"Fn::ForEach::Services": [
2038+
"Name",
2039+
["A", "B"],
2040+
{
2041+
"${Name}Function": {
2042+
"Type": "AWS::Serverless::Function",
2043+
"Properties": {
2044+
"CodeUri": "./code",
2045+
"Handler": "index.handler",
2046+
"Runtime": "python3.11",
2047+
},
2048+
}
2049+
},
2050+
]
2051+
},
2052+
"Outputs": {
2053+
"Extra": {
2054+
"Value": {
2055+
"Fn::Transform": {
2056+
"Name": "AWS::Include",
2057+
"Parameters": {"Location": "./extra.yaml"},
2058+
}
2059+
}
2060+
}
2061+
},
2062+
}
2063+
2064+
# Mirror PackageContext._export's flow: run the pre-LE
2065+
# global-transform pass on the original template before
2066+
# language-extension expansion.
2067+
_export_global_artifacts_pass(template_dict, self.s3_uploader_mock, tmpdir)
2068+
2069+
parameter_values = dict(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES)
2070+
2071+
result = expand_language_extensions(template_dict, parameter_values)
2072+
self.assertTrue(result.had_language_extensions)
2073+
2074+
template = Template(
2075+
template_path="template.yaml",
2076+
parent_dir=tmpdir,
2077+
uploaders=self.uploaders_mock,
2078+
code_signer=self.code_signer_mock,
2079+
normalize_template=True,
2080+
normalize_parameters=True,
2081+
template_dict=_copy.deepcopy(result.expanded_template),
2082+
parameter_values=parameter_values,
2083+
)
2084+
exported_template = template.export()
2085+
2086+
output = merge_language_extensions_s3_uris(
2087+
result.original_template, exported_template, result.dynamic_artifact_properties
2088+
)
2089+
2090+
# The original Fn::ForEach is preserved (not re-expanded) in the merged output.
2091+
self.assertIn("Fn::ForEach::Services", output["Resources"])
2092+
2093+
# The top-level AWS::Include Location is rewritten to the uploader's S3 URL.
2094+
out_location = output["Outputs"]["Extra"]["Value"]["Fn::Transform"]["Parameters"]["Location"]
2095+
self.assertEqual(out_location, "s3://bucket/extra.yaml-md5")
2096+
2097+
def test_le_template_with_serverless_repo_metadata_merges_license_url(self):
2098+
"""End-to-end regression: an LE template with
2099+
AWS::ServerlessRepo::Application metadata containing local
2100+
LicenseUrl/ReadmeUrl paths must end up with both rewritten to
2101+
S3 URLs after the package_context._export-equivalent flow:
2102+
2103+
expand_language_extensions(...)
2104+
-> Template(template_dict=expanded).export()
2105+
-> merge_language_extensions_s3_uris(original, exported, dynamic)
2106+
2107+
This covers the Metadata merge pass (Task 4) wired into the
2108+
artifact-exporter's export plumbing.
2109+
"""
2110+
import copy as _copy
2111+
2112+
from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions
2113+
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
2114+
from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris
2115+
2116+
# SAR exporters extend ResourceZip, whose do_export ultimately
2117+
# routes through upload_local_artifacts -> uploader.upload_with_dedup.
2118+
# Same lambda as Task 7: a predictable URL keyed on basename.
2119+
self.s3_uploader_mock.upload_with_dedup.side_effect = (
2120+
lambda local_path, **_: f"s3://bucket/{os.path.basename(local_path)}-md5"
2121+
)
2122+
2123+
with tempfile.TemporaryDirectory() as tmpdir:
2124+
# Real files on disk for the SAR exporters (LicenseUrl / ReadmeUrl)
2125+
# and for the Fn::ForEach-expanded SAM function's CodeUri.
2126+
license_path = os.path.join(tmpdir, "LICENSE.txt")
2127+
with open(license_path, "w", encoding="utf-8") as f:
2128+
f.write("MIT\n")
2129+
readme_path = os.path.join(tmpdir, "README.md")
2130+
with open(readme_path, "w", encoding="utf-8") as f:
2131+
f.write("# MyApp\n")
2132+
code_dir = os.path.join(tmpdir, "code")
2133+
os.makedirs(code_dir)
2134+
with open(os.path.join(code_dir, "index.py"), "w", encoding="utf-8") as f:
2135+
f.write("def handler(event, context):\n return event\n")
2136+
2137+
template_dict = {
2138+
"Transform": "AWS::LanguageExtensions",
2139+
"Metadata": {
2140+
"AWS::ServerlessRepo::Application": {
2141+
"Name": "MyApp",
2142+
"Description": "Test app",
2143+
"Author": "Test",
2144+
"SemanticVersion": "1.0.0",
2145+
"LicenseUrl": "./LICENSE.txt",
2146+
"ReadmeUrl": "./README.md",
2147+
}
2148+
},
2149+
"Resources": {
2150+
"Fn::ForEach::Services": [
2151+
"Name",
2152+
["A"],
2153+
{
2154+
"${Name}Function": {
2155+
"Type": "AWS::Serverless::Function",
2156+
"Properties": {
2157+
"CodeUri": "./code",
2158+
"Handler": "index.handler",
2159+
"Runtime": "python3.11",
2160+
},
2161+
}
2162+
},
2163+
]
2164+
},
2165+
}
2166+
2167+
parameter_values = dict(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES)
2168+
2169+
result = expand_language_extensions(template_dict, parameter_values)
2170+
self.assertTrue(result.had_language_extensions)
2171+
2172+
template = Template(
2173+
template_path="template.yaml",
2174+
parent_dir=tmpdir,
2175+
uploaders=self.uploaders_mock,
2176+
code_signer=self.code_signer_mock,
2177+
normalize_template=True,
2178+
normalize_parameters=True,
2179+
template_dict=_copy.deepcopy(result.expanded_template),
2180+
parameter_values=parameter_values,
2181+
)
2182+
exported_template = template.export()
2183+
2184+
output = merge_language_extensions_s3_uris(
2185+
result.original_template, exported_template, result.dynamic_artifact_properties
2186+
)
2187+
2188+
# SAR LicenseUrl and ReadmeUrl must both be rewritten to S3 URLs.
2189+
sar = output["Metadata"]["AWS::ServerlessRepo::Application"]
2190+
self.assertTrue(sar["LicenseUrl"].startswith("s3://"), f"LicenseUrl: {sar['LicenseUrl']!r}")
2191+
self.assertTrue(sar["ReadmeUrl"].startswith("s3://"), f"ReadmeUrl: {sar['ReadmeUrl']!r}")
2192+
# The basenames identify which file was uploaded to which property,
2193+
# confirming the SAR exporter fired once for License and once for Readme.
2194+
self.assertIn("LICENSE.txt", sar["LicenseUrl"])
2195+
self.assertIn("README.md", sar["ReadmeUrl"])
2196+
# Unrelated SAR metadata is preserved untouched.
2197+
self.assertEqual(sar["Name"], "MyApp")
2198+
self.assertEqual(sar["SemanticVersion"], "1.0.0")
2199+
# The original Fn::ForEach is preserved (not re-expanded) in the merged output.
2200+
self.assertIn("Fn::ForEach::Services", output["Resources"])
19922201

19932202
def test_template_export_path_be_folder(self):
19942203
template_path = "/path/foo"
@@ -2801,6 +3010,83 @@ def test_child_template_receives_parent_parameters(self):
28013010
shutil.rmtree(tmpdir, ignore_errors=True)
28023011

28033012

3013+
class TestCloudFormationStackResourceBuriedAWSInclude(unittest.TestCase):
3014+
"""Nested-stack child template containing AWS::Include buried inside an
3015+
LE function (e.g. Fn::ToJsonString) must have the include's Location
3016+
rewritten by the pre-LE global-transform pass in
3017+
CloudFormationStackResource.do_export, before LE expansion would have
3018+
collapsed the structural Fn::Transform into a JSON-string literal.
3019+
3020+
Regression coverage for https://github.com/aws/aws-sam-cli/issues/9027.
3021+
Root-flow coverage lives in tests/unit/commands/package/
3022+
test_package_context.py.
3023+
"""
3024+
3025+
def test_buried_aws_include_in_le_child_is_rewritten(self):
3026+
uploaders_mock = Mock()
3027+
s3_uploader_mock = Mock()
3028+
s3_uploader_mock.upload_with_dedup.side_effect = (
3029+
lambda local_path, **_: f"s3://bucket/{os.path.basename(local_path)}-md5"
3030+
)
3031+
# Template upload (the child template itself) — give it a deterministic URL.
3032+
s3_uploader_mock.upload.return_value = "s3://bucket/child-template-md5"
3033+
s3_uploader_mock.to_path_style_s3_url.return_value = "https://s3.amazonaws.com/bucket/child-template-md5"
3034+
uploaders_mock.get.return_value = s3_uploader_mock
3035+
3036+
code_signer_mock = Mock()
3037+
code_signer_mock.should_sign_package.return_value = False
3038+
3039+
stack_resource = CloudFormationStackResource(uploaders_mock, code_signer_mock)
3040+
3041+
# Child template mirrors the issue #9027 reporter's shape:
3042+
# Fn::ToJsonString over Fn::Transform: AWS::Include, buried inside
3043+
# AWS::SSM::Parameter.Properties.Value. The whole thing wrapped in
3044+
# Transform: AWS::LanguageExtensions so the LE branch is taken.
3045+
child_template = {
3046+
"AWSTemplateFormatVersion": "2010-09-09",
3047+
"Transform": "AWS::LanguageExtensions",
3048+
"Resources": {
3049+
"Parameter": {
3050+
"Type": "AWS::SSM::Parameter",
3051+
"Properties": {
3052+
"Type": "String",
3053+
"DataType": "text",
3054+
"Value": {
3055+
"Fn::ToJsonString": {
3056+
"Fn::Transform": {
3057+
"Name": "AWS::Include",
3058+
"Parameters": {"Location": "export-events.json"},
3059+
}
3060+
}
3061+
},
3062+
},
3063+
}
3064+
},
3065+
}
3066+
3067+
tmpdir = tempfile.mkdtemp()
3068+
try:
3069+
child_path = os.path.join(tmpdir, "child.yaml")
3070+
include_path = os.path.join(tmpdir, "export-events.json")
3071+
from samcli.yamlhelper import yaml_dump
3072+
3073+
with open(child_path, "w", encoding="utf-8") as f:
3074+
f.write(yaml_dump(child_template))
3075+
with open(include_path, "w", encoding="utf-8") as f:
3076+
f.write('[{"event": "demo"}]')
3077+
3078+
resource_dict = {"TemplateURL": child_path}
3079+
stack_resource.do_export("ChildStack", resource_dict, tmpdir)
3080+
3081+
# Walk every upload_with_dedup call to confirm the include was uploaded.
3082+
uploaded_files = [call_args[0][0] for call_args in s3_uploader_mock.upload_with_dedup.call_args_list]
3083+
self.assertIn(include_path, uploaded_files)
3084+
finally:
3085+
import shutil
3086+
3087+
shutil.rmtree(tmpdir, ignore_errors=True)
3088+
3089+
28043090
class TestCloudFormationStackResourceExpansionErrorHandling(unittest.TestCase):
28053091
"""Error-path behavior for language-extensions expansion in do_export.
28063092

0 commit comments

Comments
 (0)