use base64::Engine;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
const NO_VALUE_SENTINEL_KEY: &str = "__fakecloud_aws_no_value__";
#[derive(Debug, Clone, Default)]
pub struct ParsedTemplate {
pub description: Option<String>,
pub resources: Vec<ResourceDefinition>,
pub outputs: Vec<TemplateOutput>,
}
#[derive(Debug, Clone)]
pub struct TemplateOutput {
pub logical_id: String,
pub value: String,
pub description: Option<String>,
pub export_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResourceDefinition {
pub logical_id: String,
pub resource_type: String,
pub properties: Value,
}
const PSEUDO_REFS: &[&str] = &[
"AWS::AccountId",
"AWS::NotificationARNs",
"AWS::NoValue",
"AWS::Partition",
"AWS::Region",
"AWS::StackId",
"AWS::StackName",
"AWS::URLSuffix",
];
type Mappings = BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>;
pub(crate) fn partition_for_region(region: &str) -> &'static str {
if region.starts_with("cn-") {
"aws-cn"
} else if region.starts_with("us-gov-") {
"aws-us-gov"
} else {
"aws"
}
}
pub(crate) fn url_suffix_for_region(region: &str) -> &'static str {
if region.starts_with("cn-") {
"amazonaws.com.cn"
} else {
"amazonaws.com"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_json_template() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "test-queue"
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(parsed.resources.len(), 1);
assert_eq!(parsed.resources[0].logical_id, "MyQueue");
assert_eq!(parsed.resources[0].resource_type, "AWS::SQS::Queue");
}
#[test]
fn parse_yaml_template() {
let template = r#"
Resources:
MyTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: test-topic
"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(parsed.resources.len(), 1);
assert_eq!(parsed.resources[0].logical_id, "MyTopic");
assert_eq!(parsed.resources[0].resource_type, "AWS::SNS::Topic");
}
#[test]
fn resolve_ref_parameters() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": { "Ref": "QueueNameParam" }
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("QueueNameParam".to_string(), "resolved-queue".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("resolved-queue".to_string())
);
}
#[test]
fn ref_resolves_physical_id_over_logical_id() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MySub": {
"Type": "AWS::SNS::Subscription",
"Properties": {
"TopicArn": { "Ref": "MyTopic" },
"Protocol": "sqs",
"Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
}
}
}
}"#;
let mut physical_ids = BTreeMap::new();
physical_ids.insert(
"MyTopic".to_string(),
"arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
);
let parsed =
parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
let sub = parsed
.resources
.iter()
.find(|r| r.logical_id == "MySub")
.unwrap();
assert_eq!(
sub.properties["TopicArn"],
Value::String("arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
);
}
#[test]
fn ref_without_physical_id_returns_logical_id_for_known_resource() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MySub": {
"Type": "AWS::SNS::Subscription",
"Properties": {
"TopicArn": { "Ref": "MyTopic" },
"Protocol": "sqs",
"Endpoint": "arn:aws:sqs:us-east-1:123456789012:q"
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let sub = parsed
.resources
.iter()
.find(|r| r.logical_id == "MySub")
.unwrap();
assert_eq!(
sub.properties["TopicArn"],
Value::String("MyTopic".to_string())
);
}
#[test]
fn pseudo_ref_substitutes_when_param_provided() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueArn": {
"Fn::Join": ["", [
"arn:", {"Ref": "AWS::Partition"}, ":sqs:",
{"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"},
":", {"Ref": "AWS::StackName"}, "-q"
]]
}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-west-2".to_string());
params.insert("AWS::AccountId".to_string(), "111122223333".to_string());
params.insert("AWS::Partition".to_string(), "aws".to_string());
params.insert("AWS::StackName".to_string(), "demo".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueArn"],
Value::String("arn:aws:sqs:us-west-2:111122223333:demo-q".to_string())
);
}
#[test]
fn pseudo_ref_partition_default_when_unset() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Partition": {"Ref": "AWS::Partition"},
"Suffix": {"Ref": "AWS::URLSuffix"}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["Partition"],
Value::String("aws".to_string())
);
assert_eq!(
parsed.resources[0].properties["Suffix"],
Value::String("amazonaws.com".to_string())
);
}
#[test]
fn pseudo_ref_passes_through() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": { "Ref": "AWS::StackName" }
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("AWS::StackName".to_string())
);
}
#[test]
fn bb6_ref_aws_region_returns_seeded_region() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Region": {"Ref": "AWS::Region"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Region"],
Value::String("us-east-1".to_string())
);
}
#[test]
fn bb6_fn_sub_substitutes_aws_account_id() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Owner": {"Fn::Sub": "owner-${AWS::AccountId}"}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::AccountId".to_string(), "123456789012".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Owner"],
Value::String("owner-123456789012".to_string())
);
}
#[test]
fn bb6_partition_for_china_region_is_aws_cn() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"P": {"Ref": "AWS::Partition"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["P"],
Value::String("aws-cn".to_string())
);
}
#[test]
fn bb6_partition_for_govcloud_region_is_aws_us_gov() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"P": {"Ref": "AWS::Partition"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-gov-west-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["P"],
Value::String("aws-us-gov".to_string())
);
}
#[test]
fn bb6_url_suffix_for_china_is_amazonaws_com_cn() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"S": {"Ref": "AWS::URLSuffix"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "cn-north-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["S"],
Value::String("amazonaws.com.cn".to_string())
);
}
#[test]
fn bb6_url_suffix_for_govcloud_stays_amazonaws_com() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"S": {"Ref": "AWS::URLSuffix"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "us-gov-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["S"],
Value::String("amazonaws.com".to_string())
);
}
#[test]
fn bb6_no_value_omits_property_from_resource_input() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"OptionalProp": {"Ref": "AWS::NoValue"}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let props = parsed.resources[0].properties.as_object().unwrap();
assert!(
!props.contains_key("OptionalProp"),
"OptionalProp should be omitted, got: {props:?}"
);
assert_eq!(
props.get("QueueName"),
Some(&Value::String("q".to_string()))
);
}
#[test]
fn bb6_notification_arns_returns_seeded_array() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert(
"AWS::NotificationARNs".to_string(),
r#"["arn:aws:sns:us-east-1:111122223333:topic"]"#.to_string(),
);
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Targets"],
serde_json::json!(["arn:aws:sns:us-east-1:111122223333:topic"])
);
}
#[test]
fn bb6_notification_arns_defaults_to_empty_array() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"Targets": {"Ref": "AWS::NotificationARNs"}}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["Targets"],
serde_json::json!([])
);
}
#[test]
fn bb6_fn_sub_array_form_substitutes_extra_vars() {
let template = r#"{
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Path": {"Fn::Sub": ["${AWS::Region}/${Suffix}", {"Suffix": "tail"}]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("AWS::Region".to_string(), "eu-west-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Path"],
Value::String("eu-west-1/tail".to_string())
);
}
#[test]
fn bb6_partition_helper_classifies_regions() {
assert_eq!(partition_for_region("us-east-1"), "aws");
assert_eq!(partition_for_region("eu-central-1"), "aws");
assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
assert_eq!(partition_for_region("us-gov-west-1"), "aws-us-gov");
assert_eq!(partition_for_region("us-gov-east-1"), "aws-us-gov");
}
#[test]
fn bb6_url_suffix_helper_classifies_regions() {
assert_eq!(url_suffix_for_region("us-east-1"), "amazonaws.com");
assert_eq!(url_suffix_for_region("us-gov-west-1"), "amazonaws.com");
assert_eq!(url_suffix_for_region("cn-north-1"), "amazonaws.com.cn");
}
#[test]
fn fn_sub_resolves_physical_ids() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "my-topic"
}
},
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/topic",
"Type": "String",
"Value": { "Fn::Sub": "Topic is ${MyTopic}" }
}
}
}
}"#;
let mut physical_ids = BTreeMap::new();
physical_ids.insert(
"MyTopic".to_string(),
"arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
);
let parsed =
parse_template_with_physical_ids(template, &BTreeMap::new(), &physical_ids).unwrap();
let param = parsed
.resources
.iter()
.find(|r| r.logical_id == "MyParam")
.unwrap();
assert_eq!(
param.properties["Value"],
Value::String("Topic is arn:aws:sns:us-east-1:123456789012:my-topic".to_string())
);
}
#[test]
fn parse_template_invalid_json_errors() {
let params = BTreeMap::new();
let result = parse_template("{not-json}", ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_missing_resources_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Description":"no resources"}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_resources_not_object_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Resources": []}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn parse_template_missing_type_errors() {
let params = BTreeMap::new();
let result = parse_template(r#"{"Resources":{"R":{"Properties":{}}}}"#, ¶ms);
assert!(result.is_err());
}
#[test]
fn fn_getatt_resolves_attribute_in_array_form() {
let template = r#"{
"Resources": {
"MyQueue": {
"Type": "AWS::SQS::Queue",
"Properties": { "QueueName": "q1" }
},
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "t1",
"DataProtectionPolicy": {
"Fn::GetAtt": ["MyQueue", "Arn"]
}
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
let topic = parsed
.resources
.iter()
.find(|r| r.logical_id == "MyTopic")
.unwrap();
assert_eq!(
topic.properties["DataProtectionPolicy"],
Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_getatt_resolves_attribute_in_short_string_form() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": "t1",
"PolicyArn": { "Fn::GetAtt": "MyQueue.Arn" }
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["PolicyArn"],
Value::String("arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_getatt_unknown_resource_returns_placeholder() {
let template = r#"{
"Resources": {
"MyTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": { "Fn::GetAtt": ["MyQueue", "Arn"] }
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["TopicName"],
Value::String("MyQueue.Arn".to_string())
);
}
#[test]
fn fn_getatt_inside_fn_join_resolves() {
let template = r#"{
"Resources": {
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/q",
"Type": "String",
"Value": {
"Fn::Join": [":", ["queue", { "Fn::GetAtt": ["MyQueue", "Arn"] }]]
}
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["Value"],
Value::String("queue:arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn fn_sub_resolves_getatt_style_substitution() {
let template = r#"{
"Resources": {
"MyParam": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": "/app/q",
"Type": "String",
"Value": { "Fn::Sub": "Queue arn is ${MyQueue.Arn}" }
}
}
}
}"#;
let mut attrs = BTreeMap::new();
let mut q_attrs = BTreeMap::new();
q_attrs.insert(
"Arn".to_string(),
"arn:aws:sqs:us-east-1:123456789012:q1".to_string(),
);
attrs.insert("MyQueue".to_string(), q_attrs);
let parsed =
parse_template_with_resolution(template, &BTreeMap::new(), &BTreeMap::new(), &attrs)
.unwrap();
assert_eq!(
parsed.resources[0].properties["Value"],
Value::String("Queue arn is arn:aws:sqs:us-east-1:123456789012:q1".to_string())
);
}
#[test]
fn parse_template_with_description() {
let params = BTreeMap::new();
let parsed = parse_template(
r#"{"Description":"My template","Resources":{"R":{"Type":"AWS::SQS::Queue"}}}"#,
¶ms,
)
.unwrap();
assert_eq!(parsed.description.as_deref(), Some("My template"));
assert_eq!(parsed.resources.len(), 1);
}
type EmptyCtx = (
BTreeMap<String, String>,
serde_json::Map<String, Value>,
BTreeMap<String, String>,
BTreeMap<String, BTreeMap<String, String>>,
);
fn empty() -> EmptyCtx {
(
BTreeMap::new(),
serde_json::Map::new(),
BTreeMap::new(),
BTreeMap::new(),
)
}
#[test]
fn fn_base64_encodes_string() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Base64": "hello"}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("aGVsbG8=".to_string()));
}
#[test]
fn ref_on_lambda_alias_resolves_to_alias_arn() {
let (p, r, mut ids, attrs) = empty();
let alias_arn = "arn:aws:lambda:us-east-1:123456789012:function:my-func:live";
ids.insert("Alias".to_string(), alias_arn.to_string());
let v: Value = serde_json::from_str(r#"{"Ref": "Alias"}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String(alias_arn.to_string()));
}
#[test]
fn fn_split_emits_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "a,b,c"]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["a", "b", "c"]));
}
#[test]
fn fn_select_picks_index() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Select": [1, {"Fn::Split": [",", "a,b,c"]}]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("b".to_string()));
}
#[test]
fn fn_length_counts_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Length": [1,2,3,4]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(4.into()));
}
#[test]
fn fn_to_json_string_serializes() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::ToJsonString": {"a": 1, "b": [2, 3]}}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
let s = resolved.as_str().unwrap();
let parsed: Value = serde_json::from_str(s).unwrap();
assert_eq!(parsed["a"], serde_json::json!(1));
assert_eq!(parsed["b"], serde_json::json!([2, 3]));
}
#[test]
fn fn_cidr_carves_subnets() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 4, 8]}"#).unwrap();
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(
resolved,
serde_json::json!(["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",])
);
}
#[test]
fn condition_skips_resource_when_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"ProdQueue": {
"Type": "AWS::SQS::Queue",
"Condition": "IsProd",
"Properties": {"QueueName": "prod-q"}
},
"AlwaysQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "always-q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"AlwaysQueue"));
assert!(!names.contains(&"ProdQueue"));
}
#[test]
fn condition_includes_resource_when_true() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"ProdQueue": {
"Type": "AWS::SQS::Queue",
"Condition": "IsProd",
"Properties": {"QueueName": "prod-q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_if_picks_branch_based_on_condition() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::If": ["IsProd", "prod-q", "dev-q"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("dev-q".to_string())
);
}
#[test]
fn fn_and_or_not_combine_conditions() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}, "Region": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"IsUsEast": {"Fn::Equals": [{"Ref": "Region"}, "us-east-1"]},
"IsProdInUsEast": {"Fn::And": [{"Condition": "IsProd"}, {"Condition": "IsUsEast"}]},
"IsNotProd": {"Fn::Not": [{"Condition": "IsProd"}]},
"IsAny": {"Fn::Or": [{"Condition": "IsProd"}, {"Condition": "IsNotProd"}]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"P1": {"Fn::If": ["IsProdInUsEast", "yes", "no"]},
"P2": {"Fn::If": ["IsNotProd", "yes", "no"]},
"P3": {"Fn::If": ["IsAny", "yes", "no"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
params.insert("Region".to_string(), "us-east-1".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let p = &parsed.resources[0].properties;
assert_eq!(p["P1"], Value::String("yes".to_string()));
assert_eq!(p["P2"], Value::String("no".to_string()));
assert_eq!(p["P3"], Value::String("yes".to_string()));
}
#[test]
fn fn_find_in_map_resolves_leaf_value() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_resolves_keys_via_ref() {
let template = r#"{
"Parameters": {"Region": {"Type": "String"}},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "Region"}, "AMI"]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Region".to_string(), "us-west-2".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-west".to_string())
);
}
#[test]
fn fn_find_in_map_unknown_keys_returns_error() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_four_arg_returns_default_when_missing() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"ap-south-1",
"AMI",
{"DefaultValue": "ami-fallback"}
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-fallback".to_string())
);
}
#[test]
fn fn_find_in_map_four_arg_prefers_match_over_default() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"us-east-1",
"AMI",
{"DefaultValue": "ami-fallback"}
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_default_value_is_resolved_intrinsic() {
let template = r#"{
"Parameters": {"Fallback": {"Type": "String"}},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
"ap-south-1",
"AMI",
{"DefaultValue": {"Ref": "Fallback"}}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Fallback".to_string(), "ami-default".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-default".to_string())
);
}
#[test]
fn fn_find_in_map_unknown_map_name_errors() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": ["DoesNotExist", "us-east-1", "AMI"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Unable to get mapping for DoesNotExist::us-east-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_wrong_arg_count_errors() {
let template = r#"{
"Mappings": {"M": {"a": {"b": "c"}}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::FindInMap": ["M", "a"]}
}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(
err.contains("Fn::FindInMap requires 3 or 4 arguments"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_resolves_via_pseudo_region() {
let template = r#"{
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"},
"us-west-2": {"AMI": "ami-west"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::FindInMap": [
"RegionMap",
{"Ref": "AWS::Region"},
"AMI"
]}
}
}
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_in_unused_if_branch_does_not_error() {
let template = r#"{
"Parameters": {"WantAlt": {"Type": "String"}},
"Conditions": {
"UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::If": [
"UseAlt",
{"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
{"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantAlt".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["ImageId"],
Value::String("ami-east".to_string())
);
}
#[test]
fn fn_find_in_map_in_active_if_branch_still_errors_on_miss() {
let template = r#"{
"Parameters": {"WantAlt": {"Type": "String"}},
"Conditions": {
"UseAlt": {"Fn::Equals": [{"Ref": "WantAlt"}, "yes"]}
},
"Mappings": {
"RegionMap": {
"us-east-1": {"AMI": "ami-east"}
}
},
"Resources": {
"Inst": {
"Type": "AWS::EC2::Instance",
"Properties": {
"ImageId": {"Fn::If": [
"UseAlt",
{"Fn::FindInMap": ["RegionMap", "ap-south-1", "AMI"]},
{"Fn::FindInMap": ["RegionMap", "us-east-1", "AMI"]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantAlt".to_string(), "yes".to_string());
let err = parse_template(template, ¶ms).unwrap_err();
assert!(
err.contains("Unable to get mapping for RegionMap::ap-south-1::AMI"),
"got: {err}"
);
}
#[test]
fn fn_find_in_map_alongside_ref_and_sub_still_resolve() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Mappings": {
"EnvMap": {
"prod": {"Suffix": "live"},
"dev": {"Suffix": "test"}
}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::FindInMap": ["EnvMap", {"Ref": "Env"}, "Suffix"]},
"Tags": [
{"Key": "EnvRef", "Value": {"Ref": "Env"}},
{"Key": "Subbed", "Value": {"Fn::Sub": "env-${Env}"}}
]
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let p = &parsed.resources[0].properties;
assert_eq!(p["QueueName"], Value::String("live".to_string()));
assert_eq!(p["Tags"][0]["Value"], Value::String("prod".to_string()));
assert_eq!(p["Tags"][1]["Value"], Value::String("env-prod".to_string()));
}
#[test]
fn cyclic_conditions_self_reference_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "A"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Circular reference"), "got: {err}");
assert!(err.contains("'A'"), "got: {err}");
}
#[test]
fn cyclic_conditions_two_step_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "B"},
"B": {"Condition": "A"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Circular reference"), "got: {err}");
}
#[test]
fn condition_referencing_undefined_name_errors() {
let template = r#"{
"Conditions": {
"A": {"Condition": "DoesNotExist"}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "A",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("DoesNotExist"), "got: {err}");
}
#[test]
fn fn_if_no_value_removes_property_from_parent_map() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let props = parsed.resources[0].properties.as_object().unwrap();
assert!(
!props.contains_key("Tags"),
"Tags should be omitted when AWS::NoValue picked, got: {props:?}"
);
assert_eq!(
props.get("QueueName"),
Some(&Value::String("q".to_string()))
);
}
#[test]
fn fn_if_no_value_keeps_property_when_branch_concrete() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "yes".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let tags = &parsed.resources[0].properties["Tags"];
assert_eq!(
tags,
&serde_json::json!([{"Key": "a", "Value": "b"}]),
"tags should be the true branch's array"
);
}
#[test]
fn fn_if_no_value_in_array_drops_element() {
let template = r#"{
"Parameters": {"Extra": {"Type": "String"}},
"Conditions": {
"HasExtra": {"Fn::Equals": [{"Ref": "Extra"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"Items": [
"first",
{"Fn::If": ["HasExtra", "second", {"Ref": "AWS::NoValue"}]},
"third"
]
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Extra".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["Items"],
serde_json::json!(["first", "third"])
);
}
#[test]
fn condition_skips_output_when_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "q"}
}
},
"Outputs": {
"ProdName": {
"Condition": "IsProd",
"Value": "prod-only"
},
"Always": {
"Value": "shown"
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.outputs
.iter()
.map(|o| o.logical_id.as_str())
.collect();
assert!(names.contains(&"Always"));
assert!(!names.contains(&"ProdName"));
}
#[test]
fn fn_and_short_circuits_on_false() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"Combined": {"Fn::And": [
{"Condition": "IsProd"},
{"Fn::Equals": [{"Ref": "Env"}, "prod"]}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Combined",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "dev".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 0);
}
#[test]
fn fn_or_short_circuits_on_true() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"AnyEnv": {"Fn::Or": [
{"Condition": "IsProd"},
{"Fn::Equals": [{"Ref": "Env"}, "dev"]},
{"Fn::Equals": [{"Ref": "Env"}, "stage"]}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "AnyEnv",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "stage".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_and_rejects_arity_outside_1_to_10() {
let template = r#"{
"Conditions": {
"Empty": {"Fn::And": []}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Empty",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::And"), "got: {err}");
}
#[test]
fn condition_evaluation_memoizes_complex_expression() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"Inner": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"OuterA": {"Fn::And": [{"Condition": "Inner"}, {"Condition": "Inner"}]},
"OuterB": {"Fn::Or": [{"Condition": "Inner"}, {"Condition": "OuterA"}]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "OuterB",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(parsed.resources.len(), 1);
}
#[test]
fn fn_not_rejects_multiple_arguments() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Conditions": {
"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]},
"Bad": {"Fn::Not": [
{"Condition": "IsProd"},
{"Condition": "IsProd"}
]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Bad",
"Properties": {"QueueName": "q"}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let err = parse_template(template, ¶ms).unwrap_err();
assert!(err.contains("Fn::Not"), "got: {err}");
}
#[test]
fn fn_not_rejects_zero_arguments() {
let template = r#"{
"Conditions": {
"Bad": {"Fn::Not": []}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Condition": "Bad",
"Properties": {"QueueName": "q"}
}
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::Not"), "got: {err}");
}
#[test]
fn resolve_resource_properties_strips_no_value_at_provision_time() {
let template = r#"{
"Parameters": {"WantTags": {"Type": "String"}},
"Conditions": {
"HasTags": {"Fn::Equals": [{"Ref": "WantTags"}, "yes"]}
},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "q",
"Tags": {"Fn::If": [
"HasTags",
[{"Key": "a", "Value": "b"}],
{"Ref": "AWS::NoValue"}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("WantTags".to_string(), "no".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let resource = parsed
.resources
.iter()
.find(|r| r.logical_id == "Q")
.unwrap();
assert!(!resource
.properties
.as_object()
.unwrap()
.contains_key("Tags"));
let reresolved = resolve_resource_properties_with_attrs(
resource,
template,
¶ms,
&BTreeMap::new(),
&BTreeMap::new(),
)
.unwrap();
let props = reresolved.properties.as_object().unwrap();
assert!(
!props.contains_key("Tags"),
"Tags should be stripped on re-resolve, got: {props:?}"
);
let serialized = serde_json::to_string(&reresolved.properties).unwrap();
assert!(
!serialized.contains(NO_VALUE_SENTINEL_KEY),
"sentinel leaked: {serialized}"
);
}
#[test]
fn fn_select_string_index_resolves() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Select": ["2", ["a", "b", "c", "d"]]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("c".to_string()));
}
#[test]
fn fn_select_out_of_range_returns_null() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Select": [10, ["a", "b"]]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Null);
}
#[test]
fn fn_select_resolves_ref_inside_list() {
let template = r#"{
"Parameters": {"AZs": {"Type": "CommaDelimitedList"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Select": [0, {"Fn::Split": [",", {"Ref": "AZs"}]}]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert(
"AZs".to_string(),
"us-east-1a,us-east-1b,us-east-1c".to_string(),
);
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("us-east-1a".to_string())
);
}
#[test]
fn fn_split_empty_delimiter_returns_full_string_split_per_char() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Split": ["", "abc"]}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert!(resolved.is_array());
}
#[test]
fn fn_split_no_match_returns_single_element_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Split": [",", "no-commas-here"]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["no-commas-here"]));
}
#[test]
fn fn_base64_encodes_unicode() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Base64": "héllo"}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String("aMOpbGxv".to_string()));
}
#[test]
fn fn_base64_resolves_nested_intrinsic() {
let template = r#"{
"Parameters": {"Greeting": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Base64": {"Ref": "Greeting"}}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Greeting".to_string(), "hello".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("aGVsbG8=".to_string())
);
}
#[test]
fn fn_length_counts_string_chars() {
let (p, r, ids, attrs) = empty();
let v: Value =
serde_json::from_str(r#"{"Fn::Length": "héllo"}"#).expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(5.into()));
}
#[test]
fn fn_length_resolves_nested_split() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Length": {"Fn::Split": [",", "a,b,c,d,e"]}}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::Number(5.into()));
}
#[test]
fn fn_to_json_string_serializes_array() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::ToJsonString": ["a", "b", "c"]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, Value::String(r#"["a","b","c"]"#.to_string()));
}
#[test]
fn fn_to_json_string_resolves_inner_ref() {
let template = r#"{
"Parameters": {"Name": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {
"Fn::ToJsonString": {"k": {"Ref": "Name"}}
}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Name".to_string(), "abc".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String(r#"{"k":"abc"}"#.to_string())
);
}
#[test]
fn fn_cidr_count_matches_request() {
let (p, r, ids, attrs) = empty();
let v: Value = serde_json::from_str(r#"{"Fn::Cidr": ["10.0.0.0/16", 2, 8]}"#)
.expect("static fixture parses");
let resolved = resolve_refs(&v, &p, &r, &ids, &attrs);
assert_eq!(resolved, serde_json::json!(["10.0.0.0/24", "10.0.1.0/24"]));
}
#[test]
fn fn_cidr_resolves_via_ref() {
let template = r#"{
"Parameters": {"Vpc": {"Type": "String"}},
"Resources": {
"Q": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Select": [
0,
{"Fn::Cidr": [{"Ref": "Vpc"}, 4, 8]}
]}
}
}
}
}"#;
let mut params = BTreeMap::new();
params.insert("Vpc".to_string(), "172.16.0.0/16".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
assert_eq!(
parsed.resources[0].properties["QueueName"],
Value::String("172.16.0.0/24".to_string())
);
}
#[test]
fn fn_for_each_expands_resources() {
let template = r#"{
"Resources": {
"Fn::ForEach::TopicLoop": [
"TopicName",
["alpha", "beta", "gamma"],
{
"${TopicName}Topic": {
"Type": "AWS::SNS::Topic",
"Properties": {"TopicName": "${TopicName}-topic"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"alphaTopic"), "got: {names:?}");
assert!(names.contains(&"betaTopic"), "got: {names:?}");
assert!(names.contains(&"gammaTopic"), "got: {names:?}");
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaTopic")
.unwrap();
assert_eq!(
alpha.properties["TopicName"],
Value::String("alpha-topic".to_string())
);
}
#[test]
fn fn_for_each_substitutes_in_nested_values() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"QName",
["one", "two"],
{
"${QName}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": "${QName}",
"Tags": [
{"Key": "name", "Value": "${QName}"}
]
}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let one = parsed
.resources
.iter()
.find(|r| r.logical_id == "oneQueue")
.unwrap();
assert_eq!(
one.properties["QueueName"],
Value::String("one".to_string())
);
assert_eq!(
one.properties["Tags"][0]["Value"],
Value::String("one".to_string())
);
}
#[test]
fn fn_for_each_nested_loops_expand_cartesian() {
let template = r#"{
"Resources": {
"Fn::ForEach::Outer": [
"Env",
["dev", "prod"],
{
"Fn::ForEach::Inner": [
"Region",
["us-east-1", "eu-west-1"],
{
"${Env}${Region}Q": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${Env}-${Region}"}
}
}
]
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
for env in ["dev", "prod"] {
for region in ["us-east-1", "eu-west-1"] {
let expected = format!("{env}{region}Q");
assert!(
names.contains(&expected.as_str()),
"missing {expected} in {names:?}"
);
}
}
let dev_us = parsed
.resources
.iter()
.find(|r| r.logical_id == "devus-east-1Q")
.unwrap();
assert_eq!(
dev_us.properties["QueueName"],
Value::String("dev-us-east-1".to_string())
);
}
#[test]
fn fn_for_each_keeps_other_resources_untouched() {
let template = r#"{
"Resources": {
"Static": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "static-q"}
},
"Fn::ForEach::Loop": [
"I",
["a", "b"],
{
"${I}Topic": {
"Type": "AWS::SNS::Topic",
"Properties": {"TopicName": "${I}"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"Static"));
assert!(names.contains(&"aTopic"));
assert!(names.contains(&"bTopic"));
assert_eq!(parsed.resources.len(), 3);
}
#[test]
fn fn_for_each_invalid_arity_errors() {
let template = r#"{
"Resources": {
"Fn::ForEach::Bad": [
"Var",
["a"]
]
}
}"#;
let err = parse_template(template, &BTreeMap::new()).unwrap_err();
assert!(err.contains("Fn::ForEach"), "got: {err}");
}
#[test]
fn fn_for_each_resolves_intrinsics_in_emitted_resources() {
let template = r#"{
"Parameters": {"Env": {"Type": "String"}},
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha", "beta"],
{
"${Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {
"QueueName": {"Fn::Sub": "${Env}-${Name}"}
}
}
}
]
}
}"#;
let mut params = BTreeMap::new();
params.insert("Env".to_string(), "prod".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
assert_eq!(
alpha.properties["QueueName"],
Value::String("prod-alpha".to_string())
);
}
#[test]
fn fn_for_each_re_resolves_at_provision_time() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha"],
{
"${Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${Name}-q"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let resource = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
let reresolved = resolve_resource_properties_with_attrs(
resource,
template,
&BTreeMap::new(),
&BTreeMap::new(),
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
reresolved.properties["QueueName"],
Value::String("alpha-q".to_string())
);
}
#[test]
fn fn_for_each_resolves_ref_to_comma_delimited_list_param() {
let template = r#"{
"Parameters": {"Names": {"Type": "CommaDelimitedList"}},
"Resources": {
"Fn::ForEach::Q": [
"N",
{"Ref": "Names"},
{
"${N}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "${N}-q"}
}
}
]
}
}"#;
let mut params = BTreeMap::new();
params.insert("Names".to_string(), "alpha,beta,gamma".to_string());
let parsed = parse_template(template, ¶ms).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
for v in ["alphaQueue", "betaQueue", "gammaQueue"] {
assert!(names.contains(&v), "missing {v} in {names:?}");
}
}
#[test]
fn fn_for_each_ampersand_substitution_form() {
let template = r#"{
"Resources": {
"Fn::ForEach::Q": [
"Name",
["alpha", "beta"],
{
"&{Name}Queue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": "&{Name}"}
}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.resources
.iter()
.map(|r| r.logical_id.as_str())
.collect();
assert!(names.contains(&"alphaQueue"), "got: {names:?}");
assert!(names.contains(&"betaQueue"), "got: {names:?}");
let alpha = parsed
.resources
.iter()
.find(|r| r.logical_id == "alphaQueue")
.unwrap();
assert_eq!(
alpha.properties["QueueName"],
Value::String("alpha".to_string())
);
}
#[test]
fn fn_for_each_in_outputs_expands() {
let template = r#"{
"Resources": {
"Q": {"Type": "AWS::SQS::Queue", "Properties": {"QueueName": "q"}}
},
"Outputs": {
"Fn::ForEach::OutputLoop": [
"I",
["one", "two"],
{
"${I}Out": {"Value": "${I}-value"}
}
]
}
}"#;
let parsed = parse_template(template, &BTreeMap::new()).unwrap();
let names: Vec<&str> = parsed
.outputs
.iter()
.map(|o| o.logical_id.as_str())
.collect();
assert!(names.contains(&"oneOut"), "got: {names:?}");
assert!(names.contains(&"twoOut"), "got: {names:?}");
let one = parsed
.outputs
.iter()
.find(|o| o.logical_id == "oneOut")
.unwrap();
assert_eq!(one.value, "one-value");
}
}
mod conditions;
mod for_each;
mod intrinsics;
mod mappings;
mod parser;
mod resolution;
use conditions::*;
use for_each::*;
use intrinsics::*;
use mappings::*;
pub use parser::{
collect_import_value_names, parse_outputs, parse_template, parse_template_with_physical_ids,
parse_template_with_resolution,
};
pub use resolution::{
dependency_order, resolve_resource_properties, resolve_resource_properties_with_attrs,
};