From a951ab79b70f2031a4b5b3e2c4fc6c065ca52b79 Mon Sep 17 00:00:00 2001 From: James Price Date: Tue, 8 Sep 2026 16:04:04 +0100 Subject: [PATCH 1/2] fix(cloudformation): keep CloudFront CustomErrorResponses through provisioning Two fidelity mismatches on the same block, both of which left a CFN-provisioned SPA distribution with no deep-link fallback. Translating the CFN block ran each rule through `serde_json::from_value` into the wire struct and dropped anything that failed. CloudFormation types `ResponseCode` as an Integer while the CloudFront API carries it as a string, so every rule failed to deserialize and the distribution came out with `Quantity: 0`. Map the fields explicitly instead, coercing the number and accepting the quoted form YAML templates and resolved intrinsics produce. A rule missing the required `ErrorCode` is skipped rather than defaulted to a code that would match nothing. `ErrorCachingMinTTL` then still failed to round-trip: `rename_all = "PascalCase"` renders it `ErrorCachingMinTtl`, so the field was mis-named on the wire and dropped on parse from real SDK requests. Pin the AWS spelling, as `WebACLId` in the same file already does. --- .../src/resource_provisioner/cloudfront.rs | 120 +++++++++++++++++- crates/fakecloud-cloudfront/src/model.rs | 10 +- .../tests/cloudformation_cloudfront.rs | 91 +++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs index 86d0a0799..3ff38f5f4 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs @@ -87,13 +87,32 @@ impl ResourceProvisioner { } }); // CustomErrorResponses: flat [{ ErrorCode, ... }, ...]. + // + // Mapped field by field rather than through `serde_json::from_value`: + // CloudFormation types `ResponseCode` as an Integer while the CloudFront + // API carries it as a string, so deserializing the CFN shape into the + // wire struct fails and every rule was silently dropped -- a SPA + // distribution came out with `Quantity: 0` and no deep-link fallback. config.custom_error_responses = cfg .get("CustomErrorResponses") .and_then(|v| v.as_array()) .map(|arr| { let custom_error_response: Vec = arr .iter() - .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .filter_map(|v| { + Some(CustomErrorResponse { + // Required by CloudFormation. A rule without it is + // skipped rather than defaulted to a code that + // would match nothing. + error_code: cfn_i64(v.get("ErrorCode"))? as i32, + response_page_path: v + .get("ResponsePagePath") + .and_then(|p| p.as_str()) + .map(String::from), + response_code: cfn_number_as_string(v.get("ResponseCode")), + error_caching_min_ttl: cfn_i64(v.get("ErrorCachingMinTTL")), + }) + }) .collect(); CustomErrorResponses { quantity: custom_error_response.len() as i32, @@ -1224,3 +1243,102 @@ impl ResourceProvisioner { .with("Stage", "DEVELOPMENT")) } } + +/// Read a CloudFormation numeric property. Templates carry these as JSON +/// numbers, but YAML templates and resolved intrinsics quote them, and both are +/// valid CloudFormation. +fn cfn_i64(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(n) => n.as_i64(), + serde_json::Value::String(s) => s.parse().ok(), + _ => None, + } +} + +/// Read a CloudFormation numeric property that the AWS API carries as a string +/// (CloudFront's `ResponseCode`). +fn cfn_number_as_string(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::String(s) => Some(s.clone()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `CustomErrorResponses` block CDK synthesizes for a SPA distribution. + /// CloudFormation types `ResponseCode` and `ErrorCachingMinTTL` as Integer; + /// the CloudFront API carries `ResponseCode` as a string. + fn cdk_spa_config() -> serde_json::Value { + serde_json::json!({ + "CustomErrorResponses": [ + {"ErrorCode": 403, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300}, + {"ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300} + ] + }) + } + + #[test] + fn cfn_custom_error_responses_survive_translation() { + let mut config = DistributionConfig::default(); + ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cdk_spa_config()); + + let rules = config + .custom_error_responses + .expect("CustomErrorResponses translated"); + assert_eq!(rules.quantity, 2); + let items = rules.items.expect("items"); + let codes: Vec = items + .custom_error_response + .iter() + .map(|r| r.error_code) + .collect(); + assert_eq!(codes, vec![403, 404]); + for rule in &items.custom_error_response { + // The integer CFN gives must reach the wire model as a string, + // not be dropped for failing to deserialize into Option. + assert_eq!(rule.response_code.as_deref(), Some("200")); + assert_eq!(rule.response_page_path.as_deref(), Some("/index.html")); + assert_eq!(rule.error_caching_min_ttl, Some(300)); + } + } + + #[test] + fn cfn_custom_error_responses_accept_stringified_numbers() { + // YAML templates and `Fn::Sub` outputs quote numbers; both shapes are + // valid CloudFormation. + let cfg = serde_json::json!({ + "CustomErrorResponses": [ + {"ErrorCode": "404", "ResponseCode": "200", "ResponsePagePath": "/index.html"} + ] + }); + let mut config = DistributionConfig::default(); + ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cfg); + + let items = config + .custom_error_responses + .expect("translated") + .items + .expect("items"); + let rule = items.custom_error_response.first().expect("one rule"); + assert_eq!(rule.error_code, 404); + assert_eq!(rule.response_code.as_deref(), Some("200")); + } + + #[test] + fn a_rule_without_an_error_code_is_skipped_not_defaulted() { + // ErrorCode is required by CloudFormation; inventing a 0 would silently + // install a rule that matches nothing. + let cfg = serde_json::json!({ + "CustomErrorResponses": [{"ResponsePagePath": "/index.html"}] + }); + let mut config = DistributionConfig::default(); + ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cfg); + + let rules = config.custom_error_responses.expect("translated"); + assert_eq!(rules.quantity, 0); + } +} diff --git a/crates/fakecloud-cloudfront/src/model.rs b/crates/fakecloud-cloudfront/src/model.rs index 97cd19aa0..2bac4e208 100644 --- a/crates/fakecloud-cloudfront/src/model.rs +++ b/crates/fakecloud-cloudfront/src/model.rs @@ -555,7 +555,15 @@ pub struct CustomErrorResponse { pub response_page_path: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub response_code: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + // AWS spells this `ErrorCachingMinTTL` (upper-case TTL). The default + // PascalCase rule would emit `ErrorCachingMinTtl`, which drops the field on + // parse from real SDK requests and mis-names it on the wire. Pin the exact + // name, as `WebACLId` above does. + #[serde( + default, + rename = "ErrorCachingMinTTL", + skip_serializing_if = "skip_if_none" + )] pub error_caching_min_ttl: Option, } diff --git a/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs b/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs index b512f6e62..2526256d7 100644 --- a/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs +++ b/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs @@ -268,3 +268,94 @@ async fn cfn_provisions_cloudfront_distribution() { let after = cf.get_distribution().id(&dist_id).send().await; assert!(after.is_err(), "distribution should be gone"); } + +/// A SPA distribution as CDK synthesizes one: `CustomErrorResponses` mapping +/// 403/404 to `/index.html` with a 200. CloudFormation types `ResponseCode` as +/// an Integer while the CloudFront API carries it as a string, so translating +/// the CFN block through the wire struct dropped every rule and the +/// distribution came out with none — deep links 404'd instead of serving the +/// app shell. +const SPA_ERROR_TEMPLATE: &str = r#"{ + "Resources": { + "Dist": { + "Type": "AWS::CloudFront::Distribution", + "Properties": { + "DistributionConfig": { + "Comment": "spa error rules", + "Enabled": true, + "DefaultRootObject": "index.html", + "Origins": [ + {"Id": "origin-1", "DomainName": "origin.example.com", + "CustomOriginConfig": {"OriginProtocolPolicy": "http-only", "HTTPPort": 80, "HTTPSPort": 443}} + ], + "DefaultCacheBehavior": { + "TargetOriginId": "origin-1", + "ViewerProtocolPolicy": "allow-all" + }, + "CustomErrorResponses": [ + {"ErrorCode": 403, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300}, + {"ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300} + ] + } + } + } + }, + "Outputs": { + "DistId": {"Value": {"Ref": "Dist"}} + } +}"#; + +#[tokio::test] +async fn cfn_provisions_spa_custom_error_responses() { + let server = TestServer::start().await; + let cfn = server.cloudformation_client().await; + let cf = aws_sdk_cloudfront::Client::new(&server.aws_config().await); + + cfn.create_stack() + .stack_name("cf-spa-errors") + .template_body(SPA_ERROR_TEMPLATE) + .send() + .await + .expect("create_stack"); + + let described = cfn + .describe_stacks() + .stack_name("cf-spa-errors") + .send() + .await + .expect("describe_stacks"); + let stack = described.stacks().first().unwrap(); + assert_eq!(stack.stack_status().unwrap().as_str(), "CREATE_COMPLETE"); + + let dist_id = stack + .outputs() + .iter() + .find(|o| o.output_key() == Some("DistId")) + .and_then(|o| o.output_value()) + .map(|s| s.to_string()) + .expect("DistId"); + + let got = cf + .get_distribution() + .id(&dist_id) + .send() + .await + .expect("get_distribution"); + let dcfg = got + .distribution() + .and_then(|d| d.distribution_config()) + .expect("config"); + + let rules = dcfg + .custom_error_responses() + .expect("CustomErrorResponses provisioned"); + assert_eq!(rules.quantity(), 2, "both rules must survive translation"); + let mut codes: Vec = rules.items().iter().map(|r| r.error_code()).collect(); + codes.sort_unstable(); + assert_eq!(codes, vec![403, 404]); + for rule in rules.items() { + assert_eq!(rule.response_code(), Some("200")); + assert_eq!(rule.response_page_path(), Some("/index.html")); + assert_eq!(rule.error_caching_min_ttl(), Some(300)); + } +} From 57ccb8f45892529e1a313e70dbbf4293c83434b3 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 29 Sep 2026 19:49:19 -0300 Subject: [PATCH 2/2] fix(cloudformation): translate the whole CloudFront DistributionConfig explicitly - Map every DistributionConfig member from the CloudFormation shape instead of deserializing into the wire structs, which silently dropped CacheBehaviors, failed stacks on omitted HTTPPort/HTTPSPort and flat lists, and lost OriginSSLProtocols, OriginCustomHeaders, OriginGroups and more. Create and UpdateStack share the translation; legacy S3Origin/CustomOrigin and CNAMEs are honored; scalars coerce as CloudFormation does; required members, object types and integer ranges are enforced. - Run CreateDistribution/UpdateDistribution validation (now shared in validate.rs) on the translated config, so a stack fails with the API's error: NoSuchOrigin, invalid AllowedMethods/CachedMethods, incomplete function associations. - Spell acronym members as the wire does (MinTTL/DefaultTTL/MaxTTL, IsIPV6Enabled, ACMCertificateArn, IAMCertificateId, SSLSupportMethod, FunctionARN, LambdaFunctionARN) and add ViewerMtlsConfig, ConnectionFunctionAssociation, CacheTagConfig and OriginMtlsConfig. Snapshots move to schema v2 with a load-time key migration from v1, so old spellings stay off the wire. - Unit tests for the translator, validation and migration; e2e for a CDK-shaped create + update and for a rejected error response. --- .../src/resource_provisioner/cloudfront.rs | 383 +---- .../cloudfront/distribution_config.rs | 1323 +++++++++++++++++ .../src/resource_provisioner/mod.rs | 6 - crates/fakecloud-cloudfront/src/lib.rs | 6 +- crates/fakecloud-cloudfront/src/model.rs | 315 +++- crates/fakecloud-cloudfront/src/service.rs | 16 +- crates/fakecloud-cloudfront/src/state.rs | 203 ++- crates/fakecloud-cloudfront/src/validate.rs | 269 ++++ .../tests/cloudformation_cloudfront.rs | 235 ++- crates/fakecloud-e2e/tests/cloudfront.rs | 11 + crates/fakecloud-server/src/main.rs | 39 +- 11 files changed, 2378 insertions(+), 428 deletions(-) create mode 100644 crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront/distribution_config.rs create mode 100644 crates/fakecloud-cloudfront/src/validate.rs diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs index 0e48d72a1..6d7a9eb0c 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs @@ -4,6 +4,10 @@ use super::*; +mod distribution_config; + +use distribution_config::cfn_distribution_config; + impl ResourceProvisioner { pub(crate) fn create_cf_origin_access_identity( &self, @@ -53,134 +57,9 @@ impl ResourceProvisioner { Ok(()) } - /// Translate the CFN-flat `DistributionConfig` members that the create / - /// update paths would otherwise drop -- Aliases, CacheBehaviors, - /// CustomErrorResponses, Logging, Restrictions -- into the CloudFront wire - /// shape and apply them. CFN spells these as flat lists / a bare object; - /// the service model nests them under Quantity+Items, mirroring the Origins - /// translation. Only members present in the template are set, so an absent - /// one stays `None` (create) / is cleared on update. - fn apply_cfn_distribution_extras(config: &mut DistributionConfig, cfg: &serde_json::Value) { - // Aliases: flat ["a.example.com", ...]. - config.aliases = cfg.get("Aliases").and_then(|v| v.as_array()).map(|arr| { - let cname: Vec = arr - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(); - Aliases { - quantity: cname.len() as i32, - items: Some(AliasItems { cname }), - } - }); - // CacheBehaviors: flat [{ PathPattern, ... }, ...]. - config.cache_behaviors = cfg - .get("CacheBehaviors") - .and_then(|v| v.as_array()) - .map(|arr| { - let cache_behavior: Vec = arr - .iter() - .filter_map(|v| serde_json::from_value(v.clone()).ok()) - .collect(); - CacheBehaviors { - quantity: cache_behavior.len() as i32, - items: Some(CacheBehaviorItems { cache_behavior }), - } - }); - // CustomErrorResponses: flat [{ ErrorCode, ... }, ...]. - // - // Mapped field by field rather than through `serde_json::from_value`: - // CloudFormation types `ResponseCode` as an Integer while the CloudFront - // API carries it as a string, so deserializing the CFN shape into the - // wire struct fails and every rule was silently dropped -- a SPA - // distribution came out with `Quantity: 0` and no deep-link fallback. - config.custom_error_responses = cfg - .get("CustomErrorResponses") - .and_then(|v| v.as_array()) - .map(|arr| { - let custom_error_response: Vec = arr - .iter() - .filter_map(|v| { - Some(CustomErrorResponse { - // Required by CloudFormation. A rule without it is - // skipped rather than defaulted to a code that - // would match nothing. - error_code: cfn_i64(v.get("ErrorCode"))? as i32, - response_page_path: v - .get("ResponsePagePath") - .and_then(|p| p.as_str()) - .map(String::from), - response_code: cfn_number_as_string(v.get("ResponseCode")), - error_caching_min_ttl: cfn_i64(v.get("ErrorCachingMinTTL")), - }) - }) - .collect(); - CustomErrorResponses { - quantity: custom_error_response.len() as i32, - items: Some(CustomErrorResponseItems { - custom_error_response, - }), - } - }); - // Logging: { Bucket, IncludeCookies, Prefix } -- CFN has no Enabled, so - // presence of the block means logging is on. - config.logging = cfg - .get("Logging") - .filter(|v| v.is_object()) - .map(|log| LoggingConfig { - enabled: true, - include_cookies: log - .get("IncludeCookies") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - bucket: log - .get("Bucket") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - prefix: log - .get("Prefix") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - }); - // Restrictions: { GeoRestriction: { RestrictionType, Locations: [..] } }. - config.restrictions = cfg - .get("Restrictions") - .and_then(|v| v.get("GeoRestriction")) - .map(|geo| { - let location: Vec = geo - .get("Locations") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - Restrictions { - geo_restriction: GeoRestriction { - restriction_type: geo - .get("RestrictionType") - .and_then(|v| v.as_str()) - .unwrap_or("none") - .to_string(), - quantity: location.len() as i32, - items: if location.is_empty() { - None - } else { - Some(LocationList { location }) - }, - }, - } - }); - } - - /// Provision an `AWS::CloudFront::Distribution`. Reads - /// DistributionConfig.Origins/DefaultCacheBehavior/etc. and persists a - /// StoredDistribution in CloudFront state. CFN's Origins property is a flat - /// array, so we wrap it back into the wire shape with a quantity + - /// Items.Origin nesting; `apply_cfn_distribution_extras` does the same for - /// Aliases / CacheBehaviors / CustomErrorResponses / Logging / Restrictions. + /// Provision an `AWS::CloudFront::Distribution`: translate the CFN + /// `DistributionConfig` into the wire shape (see `distribution_config`) and + /// persist a StoredDistribution in CloudFront state. pub(crate) fn create_cf_distribution( &self, resource: &ResourceDefinition, @@ -190,85 +69,8 @@ impl ResourceProvisioner { .get("DistributionConfig") .ok_or_else(|| "DistributionConfig is required".to_string())?; - // CFN Origins is a flat JSON array; the wire shape is - // { Quantity, Items: { Origin: [...] } }. Translate. CustomOriginConfig - // uses AWS's HTTPPort/HTTPSPort casing, which the model now accepts - // natively (see CustomOriginConfig in fakecloud-cloudfront::model), so no - // field patching is needed here. - let origin_entries: Vec = cfg - .get("Origins") - .and_then(|v| v.as_array()) - .ok_or_else(|| "DistributionConfig.Origins is required".to_string())? - .iter() - .map(|o| { - serde_json::from_value::(o.clone()) - .map_err(|e| format!("Invalid Origin entry: {e}")) - }) - .collect::, _>>()?; - if origin_entries.is_empty() { - return Err("DistributionConfig.Origins must contain at least one origin".to_string()); - } - let origins = Origins { - quantity: origin_entries.len() as i32, - items: Some(OriginItems { - origin: origin_entries, - }), - }; - - let dcb_value = cfg - .get("DefaultCacheBehavior") - .ok_or_else(|| "DistributionConfig.DefaultCacheBehavior is required".to_string())?; - let default_cache_behavior: DefaultCacheBehavior = - serde_json::from_value(dcb_value.clone()) - .map_err(|e| format!("Invalid DefaultCacheBehavior: {e}"))?; - - let comment = cfg - .get("Comment") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let enabled = cfg.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true); - let price_class = cfg - .get("PriceClass") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let http_version = cfg - .get("HttpVersion") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let is_ipv6_enabled = cfg.get("IPV6Enabled").and_then(|v| v.as_bool()); - let default_root_object = cfg - .get("DefaultRootObject") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let web_acl_id = cfg - .get("WebACLId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let viewer_certificate: Option = cfg - .get("ViewerCertificate") - .map(|v| serde_json::from_value(v.clone())) - .transpose() - .map_err(|e| format!("Invalid ViewerCertificate: {e}"))?; - let caller_reference = format!("cfn-{}-{}", resource.logical_id, Uuid::new_v4().simple()); - - let mut config = DistributionConfig { - caller_reference, - comment, - enabled, - origins, - default_cache_behavior, - ..Default::default() - }; - config.price_class = price_class; - config.http_version = http_version; - config.is_ipv6_enabled = is_ipv6_enabled; - config.default_root_object = default_root_object; - config.web_acl_id = web_acl_id; - config.viewer_certificate = viewer_certificate; - Self::apply_cfn_distribution_extras(&mut config, cfg); + let config = cfn_distribution_config(cfg, caller_reference)?; // Mint distribution id + ARN + domain in the same shape the // CloudFront service uses. @@ -337,73 +139,6 @@ impl ResourceProvisioner { .get("DistributionConfig") .ok_or_else(|| "DistributionConfig is required".to_string())?; - let origin_entries: Vec = cfg - .get("Origins") - .and_then(|v| v.as_array()) - .ok_or_else(|| "DistributionConfig.Origins is required".to_string())? - .iter() - .map(|o| { - serde_json::from_value::(o.clone()) - .map_err(|e| format!("Invalid Origin entry: {e}")) - }) - .collect::, _>>()?; - if origin_entries.is_empty() { - return Err("DistributionConfig.Origins must contain at least one origin".to_string()); - } - let origins = Origins { - quantity: origin_entries.len() as i32, - items: Some(OriginItems { - origin: origin_entries, - }), - }; - - let dcb_value = cfg - .get("DefaultCacheBehavior") - .ok_or_else(|| "DistributionConfig.DefaultCacheBehavior is required".to_string())?; - let default_cache_behavior: DefaultCacheBehavior = - serde_json::from_value(dcb_value.clone()) - .map_err(|e| format!("Invalid DefaultCacheBehavior: {e}"))?; - - let comment = cfg - .get("Comment") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let enabled = cfg.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true); - let viewer_certificate: Option = cfg - .get("ViewerCertificate") - .map(|v| serde_json::from_value(v.clone())) - .transpose() - .map_err(|e| format!("Invalid ViewerCertificate: {e}"))?; - - let mut config = DistributionConfig { - caller_reference: String::new(), // preserved from the stored config below - comment, - enabled, - origins, - default_cache_behavior, - ..Default::default() - }; - config.price_class = cfg - .get("PriceClass") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - config.http_version = cfg - .get("HttpVersion") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - config.is_ipv6_enabled = cfg.get("IPV6Enabled").and_then(|v| v.as_bool()); - config.default_root_object = cfg - .get("DefaultRootObject") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - config.web_acl_id = cfg - .get("WebACLId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - config.viewer_certificate = viewer_certificate; - Self::apply_cfn_distribution_extras(&mut config, cfg); - let etag_suffix: String = Uuid::new_v4() .simple() .to_string() @@ -419,8 +154,7 @@ impl ResourceProvisioner { .get_mut(&existing.physical_id) .ok_or_else(|| format!("Distribution {} not yet provisioned", existing.physical_id))?; // CallerReference is immutable across an update; keep the stored one. - config.caller_reference = dist.config.caller_reference.clone(); - dist.config = config; + dist.config = cfn_distribution_config(cfg, dist.config.caller_reference.clone())?; dist.status = "InProgress".to_string(); dist.last_modified_time = Utc::now(); dist.etag = format!("E{etag_suffix}"); @@ -1243,102 +977,3 @@ impl ResourceProvisioner { .with("Stage", "DEVELOPMENT")) } } - -/// Read a CloudFormation numeric property. Templates carry these as JSON -/// numbers, but YAML templates and resolved intrinsics quote them, and both are -/// valid CloudFormation. -fn cfn_i64(value: Option<&serde_json::Value>) -> Option { - match value? { - serde_json::Value::Number(n) => n.as_i64(), - serde_json::Value::String(s) => s.parse().ok(), - _ => None, - } -} - -/// Read a CloudFormation numeric property that the AWS API carries as a string -/// (CloudFront's `ResponseCode`). -fn cfn_number_as_string(value: Option<&serde_json::Value>) -> Option { - match value? { - serde_json::Value::Number(n) => Some(n.to_string()), - serde_json::Value::String(s) => Some(s.clone()), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The `CustomErrorResponses` block CDK synthesizes for a SPA distribution. - /// CloudFormation types `ResponseCode` and `ErrorCachingMinTTL` as Integer; - /// the CloudFront API carries `ResponseCode` as a string. - fn cdk_spa_config() -> serde_json::Value { - serde_json::json!({ - "CustomErrorResponses": [ - {"ErrorCode": 403, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300}, - {"ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300} - ] - }) - } - - #[test] - fn cfn_custom_error_responses_survive_translation() { - let mut config = DistributionConfig::default(); - ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cdk_spa_config()); - - let rules = config - .custom_error_responses - .expect("CustomErrorResponses translated"); - assert_eq!(rules.quantity, 2); - let items = rules.items.expect("items"); - let codes: Vec = items - .custom_error_response - .iter() - .map(|r| r.error_code) - .collect(); - assert_eq!(codes, vec![403, 404]); - for rule in &items.custom_error_response { - // The integer CFN gives must reach the wire model as a string, - // not be dropped for failing to deserialize into Option. - assert_eq!(rule.response_code.as_deref(), Some("200")); - assert_eq!(rule.response_page_path.as_deref(), Some("/index.html")); - assert_eq!(rule.error_caching_min_ttl, Some(300)); - } - } - - #[test] - fn cfn_custom_error_responses_accept_stringified_numbers() { - // YAML templates and `Fn::Sub` outputs quote numbers; both shapes are - // valid CloudFormation. - let cfg = serde_json::json!({ - "CustomErrorResponses": [ - {"ErrorCode": "404", "ResponseCode": "200", "ResponsePagePath": "/index.html"} - ] - }); - let mut config = DistributionConfig::default(); - ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cfg); - - let items = config - .custom_error_responses - .expect("translated") - .items - .expect("items"); - let rule = items.custom_error_response.first().expect("one rule"); - assert_eq!(rule.error_code, 404); - assert_eq!(rule.response_code.as_deref(), Some("200")); - } - - #[test] - fn a_rule_without_an_error_code_is_skipped_not_defaulted() { - // ErrorCode is required by CloudFormation; inventing a 0 would silently - // install a rule that matches nothing. - let cfg = serde_json::json!({ - "CustomErrorResponses": [{"ResponsePagePath": "/index.html"}] - }); - let mut config = DistributionConfig::default(); - ResourceProvisioner::apply_cfn_distribution_extras(&mut config, &cfg); - - let rules = config.custom_error_responses.expect("translated"); - assert_eq!(rules.quantity, 0); - } -} diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront/distribution_config.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront/distribution_config.rs new file mode 100644 index 000000000..ac54cf556 --- /dev/null +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront/distribution_config.rs @@ -0,0 +1,1323 @@ +//! Translate an `AWS::CloudFront::Distribution` `DistributionConfig` property +//! into the CloudFront wire model. +//! +//! The CloudFormation shape is not the API shape, so nothing here goes through +//! `serde_json::from_value` into the wire structs: +//! +//! - lists are flat (`AllowedMethods: [..]`, `TrustedSigners: [..]`, +//! `CustomErrorResponses: [..]`) where the API nests them under +//! `Quantity` + `Items`; +//! - several members are named differently (`OriginCustomHeaders` vs +//! `CustomHeaders`, `OriginSSLProtocols` vs `OriginSslProtocols`, +//! `AcmCertificateArn` vs `ACMCertificateArn`, `IPV6Enabled` vs +//! `IsIPV6Enabled`, and `CachedMethods` sits beside `AllowedMethods` rather +//! than inside it); +//! - scalar types differ (`ResponseCode` is an Integer in CloudFormation and a +//! string in the API; the TTLs are Doubles in CloudFormation and Longs in the +//! API); +//! - YAML templates and resolved intrinsics (`Ref` to a parameter, `Fn::If`) +//! hand numbers and booleans over as strings, and CloudFormation stringifies +//! scalars given for String properties; +//! - the legacy `S3Origin` / `CustomOrigin` members describe a single origin +//! in place of `Origins`. +//! +//! Every member is mapped explicitly. A missing required member or a +//! wrongly-typed value fails the resource the way CloudFormation's schema +//! validation would, and the translated config then goes through the same +//! validation `CreateDistribution` / `UpdateDistribution` apply, so a config +//! the API would reject fails the resource with the API's error. + +use fakecloud_cloudfront::model::{ + AliasItems, Aliases, AllowedMethods, AwsAccountNumberList, CacheBehavior, CacheBehaviorItems, + CacheBehaviors, CacheTagConfig, CachedMethods, ConnectionFunctionAssociation, CookieNameList, + CookieNames, CookiePreference, CustomErrorResponse, CustomErrorResponseItems, + CustomErrorResponses, CustomHeaderItems, CustomHeaders, CustomOriginConfig, + DefaultCacheBehavior, DistributionConfig, ForwardedValues, FunctionAssociation, + FunctionAssociationItems, FunctionAssociations, GeoRestriction, GrpcConfig, HeaderList, + Headers, LambdaFunctionAssociation, LambdaFunctionAssociationItems, LambdaFunctionAssociations, + LocationList, LoggingConfig, MethodList, Origin, OriginCustomHeader, OriginGroup, + OriginGroupFailoverCriteria, OriginGroupItems, OriginGroupMember, OriginGroupMemberItems, + OriginGroupMembers, OriginGroups, OriginItems, OriginMtlsConfig, OriginShield, + OriginSslProtocols, Origins, ParameterDefinition, ParameterDefinitionSchema, + ParameterDefinitions, QueryStringCacheKeyList, QueryStringCacheKeys, Restrictions, + S3OriginConfig, SslProtocolItems, StatusCodeItems, StatusCodes, StringSchemaConfig, + TenantConfig, TrustStoreConfig, TrustedKeyGroupIdList, TrustedKeyGroups, TrustedSigners, + ViewerCertificate, ViewerMtlsConfig, VpcOriginConfig, +}; +use fakecloud_cloudfront::validate_distribution_config; +use serde_json::Value; + +type CfnResult = Result; + +/// Build the wire `DistributionConfig` from a CloudFormation +/// `DistributionConfig` and validate it as CloudFront would. Members absent +/// from the template stay `None`, so an update that drops one clears it. +pub(super) fn cfn_distribution_config( + cfg: &Value, + caller_reference: String, +) -> CfnResult { + let at = |path: &'static str| move |e: String| format!("DistributionConfig.{path}: {e}"); + + let default_cache_behavior = cfn_default_cache_behavior( + opt_obj(cfg, "DefaultCacheBehavior")? + .ok_or("DistributionConfig.DefaultCacheBehavior is required")?, + ) + .map_err(at("DefaultCacheBehavior"))?; + + let origin = cfn_origins(cfg, &default_cache_behavior.target_origin_id)?; + + let cache_behaviors = obj_items(cfg, "CacheBehaviors")? + .map(|items| { + let cache_behavior = items + .into_iter() + .enumerate() + .map(|(i, b)| { + cfn_cache_behavior(b) + .map_err(|e| format!("DistributionConfig.CacheBehaviors[{i}]: {e}")) + }) + .collect::>>()?; + let (quantity, items) = counted(cache_behavior, |cache_behavior| CacheBehaviorItems { + cache_behavior, + }); + Ok::<_, String>(CacheBehaviors { quantity, items }) + }) + .transpose()?; + + let custom_error_responses = obj_items(cfg, "CustomErrorResponses")? + .map(|items| { + let custom_error_response = items + .into_iter() + .enumerate() + .map(|(i, r)| { + cfn_custom_error_response(r) + .map_err(|e| format!("DistributionConfig.CustomErrorResponses[{i}]: {e}")) + }) + .collect::>>()?; + let (quantity, items) = counted(custom_error_response, |custom_error_response| { + CustomErrorResponseItems { + custom_error_response, + } + }); + Ok::<_, String>(CustomErrorResponses { quantity, items }) + }) + .transpose()?; + + // `CNAMEs` is the legacy spelling of `Aliases`; honor it when `Aliases` is + // absent. + let aliases = match str_list(cfg, "Aliases")? { + Some(cname) => Some(cname), + None => str_list(cfg, "CNAMEs")?, + } + .map(|cname| { + let (quantity, items) = counted(cname, |cname| AliasItems { cname }); + Aliases { quantity, items } + }); + + let config = DistributionConfig { + caller_reference, + aliases, + default_root_object: opt_str(cfg, "DefaultRootObject")?, + origins: Origins { + quantity: len(&origin), + items: Some(OriginItems { origin }), + }, + origin_groups: opt_obj(cfg, "OriginGroups")? + .map(cfn_origin_groups) + .transpose() + .map_err(at("OriginGroups"))?, + default_cache_behavior, + cache_behaviors, + custom_error_responses, + comment: opt_str(cfg, "Comment")?.unwrap_or_default(), + logging: opt_obj(cfg, "Logging")? + .map(cfn_logging) + .transpose() + .map_err(at("Logging"))?, + price_class: opt_str(cfg, "PriceClass")?, + enabled: opt_bool(cfg, "Enabled")?.ok_or("DistributionConfig.Enabled is required")?, + viewer_certificate: opt_obj(cfg, "ViewerCertificate")? + .map(cfn_viewer_certificate) + .transpose() + .map_err(at("ViewerCertificate"))?, + restrictions: opt_obj(cfg, "Restrictions")? + .map(cfn_restrictions) + .transpose() + .map_err(at("Restrictions"))?, + web_acl_id: opt_str(cfg, "WebACLId")?, + http_version: opt_str(cfg, "HttpVersion")?, + is_ipv6_enabled: opt_bool(cfg, "IPV6Enabled")?, + continuous_deployment_policy_id: opt_str(cfg, "ContinuousDeploymentPolicyId")?, + staging: opt_bool(cfg, "Staging")?, + anycast_ip_list_id: opt_str(cfg, "AnycastIpListId")?, + tenant_config: opt_obj(cfg, "TenantConfig")? + .map(cfn_tenant_config) + .transpose() + .map_err(at("TenantConfig"))?, + connection_mode: opt_str(cfg, "ConnectionMode")?, + viewer_mtls_config: opt_obj(cfg, "ViewerMtlsConfig")? + .map(cfn_viewer_mtls_config) + .transpose() + .map_err(at("ViewerMtlsConfig"))?, + connection_function_association: opt_obj(cfg, "ConnectionFunctionAssociation")? + .map(|a| { + Ok::<_, String>(ConnectionFunctionAssociation { + id: req_str(a, "Id")?, + }) + }) + .transpose() + .map_err(at("ConnectionFunctionAssociation"))?, + cache_tag_config: opt_obj(cfg, "CacheTagConfig")? + .map(|c| { + Ok::<_, String>(CacheTagConfig { + header_name: req_str(c, "HeaderName")?, + }) + }) + .transpose() + .map_err(at("CacheTagConfig"))?, + }; + + validate_distribution_config(&config).map_err(|e| format!("{}: {}", e.code(), e.message()))?; + Ok(config) +} + +/// The distribution's origins: `Origins`, or the single origin the legacy +/// `S3Origin` / `CustomOrigin` member describes. +/// +/// A legacy origin carries no `Id` of its own, so it takes the default cache +/// behavior's `TargetOriginId`, the one name the template already routes to it +/// by. +fn cfn_origins(cfg: &Value, default_target: &str) -> CfnResult> { + let origins = obj_items(cfg, "Origins")?; + let s3 = opt_obj(cfg, "S3Origin")?; + let custom = opt_obj(cfg, "CustomOrigin")?; + match (origins, s3, custom) { + (Some(items), None, None) => items + .into_iter() + .enumerate() + .map(|(i, o)| { + cfn_origin(o).map_err(|e| format!("DistributionConfig.Origins[{i}]: {e}")) + }) + .collect(), + (None, Some(s3), None) => Ok(vec![Origin { + id: default_target.to_string(), + domain_name: req_str(s3, "DNSName") + .map_err(|e| format!("DistributionConfig.S3Origin: {e}"))?, + s3_origin_config: Some(S3OriginConfig { + origin_access_identity: opt_str(s3, "OriginAccessIdentity")?.unwrap_or_default(), + origin_read_timeout: None, + }), + ..Default::default() + }]), + (None, None, Some(c)) => { + let at = |e: String| format!("DistributionConfig.CustomOrigin: {e}"); + let ssl_protocol = str_list(c, "OriginSSLProtocols") + .map_err(at)? + .ok_or_else(|| at("OriginSSLProtocols is required".to_string()))?; + Ok(vec![Origin { + id: default_target.to_string(), + domain_name: req_str(c, "DNSName").map_err(at)?, + custom_origin_config: Some(CustomOriginConfig { + http_port: opt_i32(c, "HTTPPort").map_err(at)?.unwrap_or(80), + https_port: opt_i32(c, "HTTPSPort").map_err(at)?.unwrap_or(443), + origin_protocol_policy: req_str(c, "OriginProtocolPolicy").map_err(at)?, + origin_ssl_protocols: Some(OriginSslProtocols { + quantity: len(&ssl_protocol), + items: SslProtocolItems { ssl_protocol }, + }), + ..Default::default() + }), + ..Default::default() + }]) + } + (None, None, None) => Err( + "DistributionConfig.Origins is required (or the legacy S3Origin / CustomOrigin)" + .to_string(), + ), + _ => Err( + "DistributionConfig: specify only one of Origins, S3Origin and CustomOrigin" + .to_string(), + ), + } +} + +fn cfn_origin(o: &Value) -> CfnResult { + let custom_headers = obj_items(o, "OriginCustomHeaders")? + .map(|items| { + let origin_custom_header = items + .into_iter() + .map(|h| { + Ok(OriginCustomHeader { + header_name: req_str(h, "HeaderName")?, + header_value: req_str(h, "HeaderValue")?, + }) + }) + .collect::>>() + .map_err(|e| format!("OriginCustomHeaders: {e}"))?; + let (quantity, items) = counted(origin_custom_header, |origin_custom_header| { + CustomHeaderItems { + origin_custom_header, + } + }); + Ok::<_, String>(CustomHeaders { quantity, items }) + }) + .transpose()?; + + Ok(Origin { + id: req_str(o, "Id")?, + domain_name: req_str(o, "DomainName")?, + origin_path: opt_str(o, "OriginPath")?, + custom_headers, + s3_origin_config: opt_obj(o, "S3OriginConfig")? + .map(|s| { + Ok::<_, String>(S3OriginConfig { + // Optional in CloudFormation (an origin using an origin + // access control carries an empty one); the API spells + // "no identity" as the empty string. + origin_access_identity: opt_str(s, "OriginAccessIdentity")?.unwrap_or_default(), + origin_read_timeout: opt_i32(s, "OriginReadTimeout")?, + }) + }) + .transpose() + .map_err(|e| format!("S3OriginConfig: {e}"))?, + custom_origin_config: opt_obj(o, "CustomOriginConfig")? + .map(cfn_custom_origin_config) + .transpose() + .map_err(|e| format!("CustomOriginConfig: {e}"))?, + vpc_origin_config: opt_obj(o, "VpcOriginConfig")? + .map(|v| { + Ok::<_, String>(VpcOriginConfig { + vpc_origin_id: req_str(v, "VpcOriginId")?, + owner_account_id: opt_str(v, "OwnerAccountId")?, + origin_read_timeout: opt_i32(v, "OriginReadTimeout")?, + origin_keepalive_timeout: opt_i32(v, "OriginKeepaliveTimeout")?, + }) + }) + .transpose() + .map_err(|e| format!("VpcOriginConfig: {e}"))?, + connection_attempts: opt_i32(o, "ConnectionAttempts")?, + connection_timeout: opt_i32(o, "ConnectionTimeout")?, + origin_shield: opt_obj(o, "OriginShield")? + .map(|s| { + Ok::<_, String>(OriginShield { + enabled: opt_bool(s, "Enabled")?.unwrap_or(false), + origin_shield_region: opt_str(s, "OriginShieldRegion")?, + }) + }) + .transpose() + .map_err(|e| format!("OriginShield: {e}"))?, + origin_access_control_id: opt_str(o, "OriginAccessControlId")?, + response_completion_timeout: opt_i32(o, "ResponseCompletionTimeout")?, + }) +} + +fn cfn_custom_origin_config(c: &Value) -> CfnResult { + Ok(CustomOriginConfig { + // CloudFormation defaults the ports; the API requires them. + http_port: opt_i32(c, "HTTPPort")?.unwrap_or(80), + https_port: opt_i32(c, "HTTPSPort")?.unwrap_or(443), + origin_protocol_policy: req_str(c, "OriginProtocolPolicy")?, + origin_ssl_protocols: str_list(c, "OriginSSLProtocols")?.map(|ssl_protocol| { + OriginSslProtocols { + quantity: len(&ssl_protocol), + items: SslProtocolItems { ssl_protocol }, + } + }), + origin_read_timeout: opt_i32(c, "OriginReadTimeout")?, + origin_keepalive_timeout: opt_i32(c, "OriginKeepaliveTimeout")?, + ip_address_type: opt_str(c, "IpAddressType")?, + origin_mtls_config: opt_obj(c, "OriginMtlsConfig")? + .map(|m| { + Ok::<_, String>(OriginMtlsConfig { + client_certificate_arn: req_str(m, "ClientCertificateArn")?, + }) + }) + .transpose() + .map_err(|e| format!("OriginMtlsConfig: {e}"))?, + }) +} + +fn cfn_origin_groups(og: &Value) -> CfnResult { + let origin_group = obj_items(og, "Items")? + .map(|items| { + items + .into_iter() + .enumerate() + .map(|(i, g)| cfn_origin_group(g).map_err(|e| format!("Items[{i}]: {e}"))) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + let (quantity, items) = counted(origin_group, |origin_group| OriginGroupItems { + origin_group, + }); + Ok(OriginGroups { quantity, items }) +} + +fn cfn_origin_group(g: &Value) -> CfnResult { + let status_codes = opt_obj( + opt_obj(g, "FailoverCriteria")?.ok_or("FailoverCriteria is required")?, + "StatusCodes", + ) + .map_err(|e| format!("FailoverCriteria.{e}"))? + .ok_or("FailoverCriteria.StatusCodes is required")?; + let status_code = obj_list(status_codes, "Items")? + .ok_or("FailoverCriteria.StatusCodes.Items is required")? + .iter() + .map(|v| { + cfn_integer(v) + .and_then(|n| i32::try_from(n).map_err(|_| "is out of range".to_string())) + .map_err(|e| format!("FailoverCriteria.StatusCodes.Items {e}")) + }) + .collect::>>()?; + let origin_group_member = obj_items( + opt_obj(g, "Members")?.ok_or("Members is required")?, + "Items", + )? + .ok_or("Members.Items is required")? + .into_iter() + .map(|m| { + Ok(OriginGroupMember { + origin_id: req_str(m, "OriginId")?, + }) + }) + .collect::>>() + .map_err(|e| format!("Members.Items: {e}"))?; + Ok(OriginGroup { + id: req_str(g, "Id")?, + failover_criteria: OriginGroupFailoverCriteria { + status_codes: StatusCodes { + quantity: len(&status_code), + items: StatusCodeItems { status_code }, + }, + }, + members: OriginGroupMembers { + quantity: len(&origin_group_member), + items: OriginGroupMemberItems { + origin_group_member, + }, + }, + selection_criteria: opt_str(g, "SelectionCriteria")?, + }) +} + +/// `CacheBehavior` is `DefaultCacheBehavior` plus `PathPattern`. +fn cfn_cache_behavior(b: &Value) -> CfnResult { + let path_pattern = req_str(b, "PathPattern")?; + Ok(CacheBehavior::from_default( + path_pattern, + cfn_default_cache_behavior(b)?, + )) +} + +fn cfn_default_cache_behavior(b: &Value) -> CfnResult { + // CloudFormation carries `CachedMethods` beside `AllowedMethods`; the API + // nests it inside. Both default to GET/HEAD in CloudFormation, so a + // template that only narrows `CachedMethods` still gets the default + // allowed set to hang it on. + let allowed = str_list(b, "AllowedMethods")?; + let cached = str_list(b, "CachedMethods")?; + let allowed_methods = if allowed.is_none() && cached.is_none() { + None + } else { + let method = allowed.unwrap_or_else(|| vec!["HEAD".to_string(), "GET".to_string()]); + Some(AllowedMethods { + quantity: len(&method), + items: MethodList { method }, + cached_methods: cached.map(|method| CachedMethods { + quantity: len(&method), + items: MethodList { method }, + }), + }) + }; + + // Every member of an association is optional in CloudFormation's schema; + // an incomplete one is left for CloudFront's own validation to reject. + let lambda_function_associations = obj_items(b, "LambdaFunctionAssociations")? + .map(|items| { + let lambda_function_association = items + .into_iter() + .map(|a| { + Ok(LambdaFunctionAssociation { + lambda_function_arn: opt_str(a, "LambdaFunctionARN")?.unwrap_or_default(), + event_type: opt_str(a, "EventType")?.unwrap_or_default(), + include_body: opt_bool(a, "IncludeBody")?, + }) + }) + .collect::>>() + .map_err(|e| format!("LambdaFunctionAssociations: {e}"))?; + let (quantity, items) = + counted(lambda_function_association, |lambda_function_association| { + LambdaFunctionAssociationItems { + lambda_function_association, + } + }); + Ok::<_, String>(LambdaFunctionAssociations { quantity, items }) + }) + .transpose()?; + + let function_associations = obj_items(b, "FunctionAssociations")? + .map(|items| { + let function_association = items + .into_iter() + .map(|a| { + Ok(FunctionAssociation { + function_arn: opt_str(a, "FunctionARN")?.unwrap_or_default(), + event_type: opt_str(a, "EventType")?.unwrap_or_default(), + }) + }) + .collect::>>() + .map_err(|e| format!("FunctionAssociations: {e}"))?; + let (quantity, items) = counted(function_association, |function_association| { + FunctionAssociationItems { + function_association, + } + }); + Ok::<_, String>(FunctionAssociations { quantity, items }) + }) + .transpose()?; + + Ok(DefaultCacheBehavior { + target_origin_id: req_str(b, "TargetOriginId")?, + trusted_signers: str_list(b, "TrustedSigners")?.map(|ids| { + let enabled = !ids.is_empty(); + let (quantity, items) = counted(ids, |aws_account_number| AwsAccountNumberList { + aws_account_number, + }); + TrustedSigners { + enabled, + quantity, + items, + } + }), + trusted_key_groups: str_list(b, "TrustedKeyGroups")?.map(|ids| { + let enabled = !ids.is_empty(); + let (quantity, items) = counted(ids, |key_group| TrustedKeyGroupIdList { key_group }); + TrustedKeyGroups { + enabled, + quantity, + items, + } + }), + viewer_protocol_policy: req_str(b, "ViewerProtocolPolicy")?, + allowed_methods, + smooth_streaming: opt_bool(b, "SmoothStreaming")?, + compress: opt_bool(b, "Compress")?, + lambda_function_associations, + function_associations, + field_level_encryption_id: opt_str(b, "FieldLevelEncryptionId")?, + realtime_log_config_arn: opt_str(b, "RealtimeLogConfigArn")?, + cache_policy_id: opt_str(b, "CachePolicyId")?, + origin_request_policy_id: opt_str(b, "OriginRequestPolicyId")?, + response_headers_policy_id: opt_str(b, "ResponseHeadersPolicyId")?, + grpc_config: opt_obj(b, "GrpcConfig")? + .map(|g| { + Ok::<_, String>(GrpcConfig { + enabled: opt_bool(g, "Enabled")?.ok_or("Enabled is required")?, + }) + }) + .transpose() + .map_err(|e| format!("GrpcConfig: {e}"))?, + forwarded_values: opt_obj(b, "ForwardedValues")? + .map(cfn_forwarded_values) + .transpose() + .map_err(|e| format!("ForwardedValues: {e}"))?, + min_ttl: opt_seconds(b, "MinTTL")?, + default_ttl: opt_seconds(b, "DefaultTTL")?, + max_ttl: opt_seconds(b, "MaxTTL")?, + }) +} + +fn cfn_forwarded_values(fv: &Value) -> CfnResult { + let cookies = match opt_obj(fv, "Cookies")? { + Some(c) => CookiePreference { + forward: req_str(c, "Forward").map_err(|e| format!("Cookies: {e}"))?, + whitelisted_names: str_list(c, "WhitelistedNames")?.map(|name| { + let (quantity, items) = counted(name, |name| CookieNameList { name }); + CookieNames { quantity, items } + }), + }, + // Optional in CloudFormation (defaulting to `Forward: none`), required + // by the API. + None => CookiePreference { + forward: "none".to_string(), + whitelisted_names: None, + }, + }; + Ok(ForwardedValues { + query_string: opt_bool(fv, "QueryString")?.ok_or("QueryString is required")?, + cookies, + headers: str_list(fv, "Headers")?.map(|name| { + let (quantity, items) = counted(name, |name| HeaderList { name }); + Headers { quantity, items } + }), + query_string_cache_keys: str_list(fv, "QueryStringCacheKeys")?.map(|name| { + let (quantity, items) = counted(name, |name| QueryStringCacheKeyList { name }); + QueryStringCacheKeys { quantity, items } + }), + }) +} + +fn cfn_custom_error_response(r: &Value) -> CfnResult { + Ok(CustomErrorResponse { + error_code: opt_i32(r, "ErrorCode")?.ok_or("ErrorCode is required")?, + response_page_path: opt_str(r, "ResponsePagePath")?, + // An Integer in CloudFormation, a string in the API. + response_code: opt_i32(r, "ResponseCode")?.map(|n| n.to_string()), + error_caching_min_ttl: opt_seconds(r, "ErrorCachingMinTTL")?, + }) +} + +fn cfn_logging(log: &Value) -> CfnResult { + // CloudFormation has no `Enabled`: the presence of the block turns + // logging on. + Ok(LoggingConfig { + enabled: true, + include_cookies: opt_bool(log, "IncludeCookies")?.unwrap_or(false), + bucket: opt_str(log, "Bucket")?.unwrap_or_default(), + prefix: opt_str(log, "Prefix")?.unwrap_or_default(), + }) +} + +fn cfn_viewer_certificate(vc: &Value) -> CfnResult { + Ok(ViewerCertificate { + cloud_front_default_certificate: opt_bool(vc, "CloudFrontDefaultCertificate")?, + iam_certificate_id: opt_str(vc, "IamCertificateId")?, + acm_certificate_arn: opt_str(vc, "AcmCertificateArn")?, + ssl_support_method: opt_str(vc, "SslSupportMethod")?, + minimum_protocol_version: opt_str(vc, "MinimumProtocolVersion")?, + certificate: None, + certificate_source: None, + }) +} + +fn cfn_viewer_mtls_config(m: &Value) -> CfnResult { + Ok(ViewerMtlsConfig { + mode: opt_str(m, "Mode")?, + trust_store_config: opt_obj(m, "TrustStoreConfig")? + .map(|t| { + Ok::<_, String>(TrustStoreConfig { + trust_store_id: req_str(t, "TrustStoreId")?, + advertise_trust_store_ca_names: opt_bool(t, "AdvertiseTrustStoreCaNames")?, + ignore_certificate_expiry: opt_bool(t, "IgnoreCertificateExpiry")?, + }) + }) + .transpose() + .map_err(|e| format!("TrustStoreConfig: {e}"))?, + }) +} + +fn cfn_restrictions(r: &Value) -> CfnResult { + let geo = opt_obj(r, "GeoRestriction")?.ok_or("GeoRestriction is required")?; + let location = str_list(geo, "Locations")?.unwrap_or_default(); + let restriction_type = + req_str(geo, "RestrictionType").map_err(|e| format!("GeoRestriction: {e}"))?; + let (quantity, items) = counted(location, |location| LocationList { location }); + Ok(Restrictions { + geo_restriction: GeoRestriction { + restriction_type, + quantity, + items, + }, + }) +} + +fn cfn_tenant_config(t: &Value) -> CfnResult { + let parameter_definitions = obj_items(t, "ParameterDefinitions")? + .map(|items| { + let parameter_definition = items + .into_iter() + .map(cfn_parameter_definition) + .collect::>>() + .map_err(|e| format!("ParameterDefinitions: {e}"))?; + Ok::<_, String>(ParameterDefinitions { + parameter_definition, + }) + }) + .transpose()?; + Ok(TenantConfig { + parameter_definitions, + }) +} + +fn cfn_parameter_definition(p: &Value) -> CfnResult { + let definition = opt_obj(p, "Definition")?.ok_or("Definition is required")?; + Ok(ParameterDefinition { + name: req_str(p, "Name")?, + definition: ParameterDefinitionSchema { + string_schema: opt_obj(definition, "StringSchema")? + .map(|s| { + Ok::<_, String>(StringSchemaConfig { + required: opt_bool(s, "Required")?.ok_or("Required is required")?, + comment: opt_str(s, "Comment")?, + default_value: opt_str(s, "DefaultValue")?, + }) + }) + .transpose() + .map_err(|e| format!("Definition.StringSchema: {e}"))?, + }, + }) +} + +// --- Quantity/Items envelope ----------------------------------------------- + +fn len(list: &[T]) -> i32 { + i32::try_from(list.len()).unwrap_or(i32::MAX) +} + +/// The `Quantity` and optional `Items` of a CloudFront list. CloudFront leaves +/// `Items` out of an empty optional list, so an empty one maps to `None`. +fn counted(list: Vec, items: impl FnOnce(Vec) -> I) -> (i32, Option) { + let quantity = len(&list); + (quantity, (!list.is_empty()).then(|| items(list))) +} + +// --- CloudFormation scalar readers ----------------------------------------- + +/// A present, non-null member. +fn field<'a>(obj: &'a Value, key: &str) -> Option<&'a Value> { + obj.get(key).filter(|v| !v.is_null()) +} + +/// An object-typed member; anything else fails rather than being read as an +/// empty object. +fn opt_obj<'a>(obj: &'a Value, key: &str) -> CfnResult> { + match field(obj, key) { + None => Ok(None), + Some(v) if v.is_object() => Ok(Some(v)), + Some(_) => Err(format!("{key} must be an object")), + } +} + +/// A String-typed value. CloudFormation stringifies a number or boolean given +/// for a String property. +fn scalar_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +fn opt_str(obj: &Value, key: &str) -> CfnResult> { + field(obj, key) + .map(|v| scalar_string(v).ok_or_else(|| format!("{key} must be a string"))) + .transpose() +} + +fn req_str(obj: &Value, key: &str) -> CfnResult { + opt_str(obj, key)?.ok_or_else(|| format!("{key} is required")) +} + +/// A CloudFormation Integer. Templates carry these as JSON numbers, but YAML +/// templates and resolved intrinsics quote them. A fractional or out-of-range +/// value is rejected rather than truncated or saturated. +fn cfn_integer(v: &Value) -> CfnResult { + match v { + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(i) + } else if n.is_u64() { + Err("is out of range".to_string()) + } else { + Err("must be an integer".to_string()) + } + } + Value::String(s) => s.trim().parse::().map_err(|e| match e.kind() { + std::num::IntErrorKind::PosOverflow | std::num::IntErrorKind::NegOverflow => { + "is out of range".to_string() + } + _ => "must be an integer".to_string(), + }), + _ => Err("must be an integer".to_string()), + } +} + +/// A CloudFormation Double carrying whole seconds (the TTLs, which the API +/// takes as a Long). An integral double such as `86400.0` is accepted; a +/// fractional or out-of-range one is rejected. +fn cfn_seconds(v: &Value) -> CfnResult { + if let Ok(i) = cfn_integer(v) { + return Ok(i); + } + let f = match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.trim().parse::().ok(), + _ => None, + } + .ok_or("must be a number")?; + // i64::MAX is not exactly representable; 2^63 is the first double past it. + const LIMIT: f64 = 9_223_372_036_854_775_808.0; + if f.fract() != 0.0 || !f.is_finite() { + Err("must be a whole number of seconds".to_string()) + } else if !(-LIMIT..LIMIT).contains(&f) { + Err("is out of range".to_string()) + } else { + Ok(f as i64) + } +} + +fn opt_i64(obj: &Value, key: &str) -> CfnResult> { + field(obj, key) + .map(|v| cfn_integer(v).map_err(|e| format!("{key} {e}"))) + .transpose() +} + +fn opt_i32(obj: &Value, key: &str) -> CfnResult> { + opt_i64(obj, key)? + .map(|n| i32::try_from(n).map_err(|_| format!("{key} is out of range"))) + .transpose() +} + +fn opt_seconds(obj: &Value, key: &str) -> CfnResult> { + field(obj, key) + .map(|v| cfn_seconds(v).map_err(|e| format!("{key} {e}"))) + .transpose() +} + +/// A CloudFormation boolean, which a parameter `Ref` hands over as a string. +fn opt_bool(obj: &Value, key: &str) -> CfnResult> { + match field(obj, key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(Value::String(s)) if s.eq_ignore_ascii_case("true") => Ok(Some(true)), + Some(Value::String(s)) if s.eq_ignore_ascii_case("false") => Ok(Some(false)), + Some(_) => Err(format!("{key} must be a boolean")), + } +} + +fn obj_list<'a>(obj: &'a Value, key: &str) -> CfnResult>> { + match field(obj, key) { + None => Ok(None), + Some(Value::Array(arr)) => Ok(Some(arr)), + Some(_) => Err(format!("{key} must be a list")), + } +} + +/// A list of objects. +fn obj_items<'a>(obj: &'a Value, key: &str) -> CfnResult>> { + obj_list(obj, key)? + .map(|arr| { + arr.iter() + .enumerate() + .map(|(i, v)| { + if v.is_object() { + Ok(v) + } else { + Err(format!("{key}[{i}] must be an object")) + } + }) + .collect() + }) + .transpose() +} + +/// A list of strings, stringifying scalar entries as CloudFormation does. +fn str_list(obj: &Value, key: &str) -> CfnResult>> { + obj_list(obj, key)? + .map(|arr| { + arr.iter() + .map(|v| scalar_string(v).ok_or_else(|| format!("{key} must be a list of strings"))) + .collect() + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn base() -> Value { + json!({ + "Enabled": true, + "Origins": [{"Id": "o1", "DomainName": "origin.example.com", + "CustomOriginConfig": {"OriginProtocolPolicy": "https-only"}}], + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all"} + }) + } + + fn with(extra: Value) -> Value { + let mut cfg = base(); + for (k, v) in extra.as_object().unwrap() { + cfg[k] = v.clone(); + } + cfg + } + + fn translate(cfg: &Value) -> DistributionConfig { + cfn_distribution_config(cfg, "ref".to_string()).expect("translates") + } + + /// The `CustomErrorResponses` block CDK synthesizes for a SPA + /// distribution. CloudFormation types `ResponseCode` and + /// `ErrorCachingMinTTL` as Integer; the API carries `ResponseCode` as a + /// string. + #[test] + fn custom_error_responses_survive_translation() { + let config = translate(&with(json!({ + "CustomErrorResponses": [ + {"ErrorCode": 403, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300}, + {"ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 300} + ] + }))); + let rules = config.custom_error_responses.expect("translated"); + assert_eq!(rules.quantity, 2); + let items = rules.items.expect("items").custom_error_response; + assert_eq!( + items.iter().map(|r| r.error_code).collect::>(), + vec![403, 404] + ); + for rule in &items { + assert_eq!(rule.response_code.as_deref(), Some("200")); + assert_eq!(rule.response_page_path.as_deref(), Some("/index.html")); + assert_eq!(rule.error_caching_min_ttl, Some(300)); + } + } + + #[test] + fn stringified_numbers_and_booleans_are_accepted() { + // YAML templates and parameter `Ref`s hand these over as strings. + let config = translate(&with(json!({ + "Enabled": "false", + "IPV6Enabled": "true", + "CustomErrorResponses": [{"ErrorCode": "404", "ResponseCode": "200"}], + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "MinTTL": "0", "DefaultTTL": 86400.0, "MaxTTL": "31536000", + "Compress": "true"} + }))); + assert!(!config.enabled); + assert_eq!(config.is_ipv6_enabled, Some(true)); + let rule = &config + .custom_error_responses + .unwrap() + .items + .unwrap() + .custom_error_response[0]; + assert_eq!(rule.error_code, 404); + assert_eq!(rule.response_code.as_deref(), Some("200")); + let dcb = &config.default_cache_behavior; + assert_eq!( + (dcb.min_ttl, dcb.default_ttl, dcb.max_ttl), + (Some(0), Some(86400), Some(31_536_000)) + ); + assert_eq!(dcb.compress, Some(true)); + } + + #[test] + fn a_rule_without_an_error_code_fails_the_resource() { + // CloudFormation's schema validation rejects this; silently dropping + // the rule would leave the distribution without the fallback. + let err = cfn_distribution_config( + &with(json!({"CustomErrorResponses": [{"ResponsePagePath": "/index.html"}]})), + "ref".to_string(), + ) + .unwrap_err(); + assert!(err.contains("CustomErrorResponses[0]"), "{err}"); + assert!(err.contains("ErrorCode is required"), "{err}"); + } + + #[test] + fn cache_behaviors_translate_flat_lists_into_the_wire_shape() { + let config = translate(&with(json!({ + "CacheBehaviors": [{ + "PathPattern": "/api/*", + "TargetOriginId": "o1", + "ViewerProtocolPolicy": "https-only", + "AllowedMethods": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], + "CachedMethods": ["GET", "HEAD"], + "TrustedKeyGroups": ["kg-1"], + "FunctionAssociations": [{"EventType": "viewer-request", "FunctionARN": "arn:aws:cloudfront::123456789012:function/f"}], + "LambdaFunctionAssociations": [{"EventType": "origin-request", "LambdaFunctionARN": "arn:aws:lambda:us-east-1:123456789012:function:f:1", "IncludeBody": true}], + "ForwardedValues": {"QueryString": true, "Cookies": {"Forward": "whitelist", "WhitelistedNames": ["session"]}, + "Headers": ["Authorization"], "QueryStringCacheKeys": ["page"]}, + "MinTTL": 0, "DefaultTTL": 0, "MaxTTL": 0 + }] + }))); + let behaviors = config.cache_behaviors.expect("translated"); + assert_eq!(behaviors.quantity, 1, "the behavior must not be dropped"); + let b = &behaviors.items.unwrap().cache_behavior[0]; + assert_eq!(b.path_pattern, "/api/*"); + let allowed = b.allowed_methods.as_ref().unwrap(); + assert_eq!(allowed.quantity, 7); + let cached = allowed.cached_methods.as_ref().unwrap(); + assert_eq!(cached.items.method, vec!["GET", "HEAD"]); + let kg = b.trusted_key_groups.as_ref().unwrap(); + assert!(kg.enabled); + assert_eq!(kg.items.as_ref().unwrap().key_group, vec!["kg-1"]); + let fa = b.function_associations.as_ref().unwrap(); + assert_eq!(fa.quantity, 1); + assert_eq!( + fa.items.as_ref().unwrap().function_association[0].event_type, + "viewer-request" + ); + let la = b.lambda_function_associations.as_ref().unwrap(); + assert_eq!( + la.items.as_ref().unwrap().lambda_function_association[0].include_body, + Some(true) + ); + let fv = b.forwarded_values.as_ref().unwrap(); + assert!(fv.query_string); + assert_eq!(fv.cookies.forward, "whitelist"); + assert_eq!(fv.cookies.whitelisted_names.as_ref().unwrap().quantity, 1); + assert_eq!(fv.headers.as_ref().unwrap().quantity, 1); + assert_eq!(fv.query_string_cache_keys.as_ref().unwrap().quantity, 1); + assert_eq!( + (b.min_ttl, b.default_ttl, b.max_ttl), + (Some(0), Some(0), Some(0)) + ); + } + + #[test] + fn a_malformed_cache_behavior_fails_the_resource_instead_of_vanishing() { + let err = cfn_distribution_config( + &with(json!({"CacheBehaviors": [{"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all"}]})), + "ref".to_string(), + ) + .unwrap_err(); + assert!( + err.contains("CacheBehaviors[0]: PathPattern is required"), + "{err}" + ); + } + + #[test] + fn cached_methods_alone_hang_off_the_default_allowed_set() { + let config = translate(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "CachedMethods": ["GET", "HEAD"]} + }))); + let allowed = config.default_cache_behavior.allowed_methods.unwrap(); + assert_eq!(allowed.items.method, vec!["HEAD", "GET"]); + assert_eq!(allowed.cached_methods.unwrap().quantity, 2); + } + + #[test] + fn origins_translate_cfn_member_names_and_default_ports() { + let config = translate(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "web", "ViewerProtocolPolicy": "allow-all"}, + "Origins": [ + {"Id": "web", "DomainName": "web.example.com", + "OriginCustomHeaders": [{"HeaderName": "X-Secret", "HeaderValue": "s"}], + "OriginShield": {"Enabled": true, "OriginShieldRegion": "us-east-1"}, + "ConnectionAttempts": "3", + "CustomOriginConfig": {"OriginProtocolPolicy": "https-only", + "OriginSSLProtocols": ["TLSv1.2"], + "OriginReadTimeout": 30}}, + {"Id": "bucket", "DomainName": "b.s3.us-east-1.amazonaws.com", + "OriginAccessControlId": "E123", + "S3OriginConfig": {}} + ] + }))); + let origins = config.origins.items.unwrap().origin; + assert_eq!(origins.len(), 2); + let web = &origins[0]; + let custom = web.custom_origin_config.as_ref().unwrap(); + assert_eq!((custom.http_port, custom.https_port), (80, 443)); + assert_eq!( + custom + .origin_ssl_protocols + .as_ref() + .unwrap() + .items + .ssl_protocol, + vec!["TLSv1.2"] + ); + assert_eq!(custom.origin_read_timeout, Some(30)); + let headers = web.custom_headers.as_ref().unwrap(); + assert_eq!(headers.quantity, 1); + assert_eq!( + headers.items.as_ref().unwrap().origin_custom_header[0].header_name, + "X-Secret" + ); + assert!(web.origin_shield.as_ref().unwrap().enabled); + assert_eq!(web.connection_attempts, Some(3)); + let bucket = &origins[1]; + assert_eq!( + bucket + .s3_origin_config + .as_ref() + .unwrap() + .origin_access_identity, + "" + ); + assert_eq!(bucket.origin_access_control_id.as_deref(), Some("E123")); + } + + #[test] + fn viewer_certificate_reads_the_cfn_member_names() { + let config = translate(&with(json!({ + "ViewerCertificate": { + "AcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc", + "SslSupportMethod": "sni-only", + "MinimumProtocolVersion": "TLSv1.2_2021" + } + }))); + let vc = config.viewer_certificate.unwrap(); + assert_eq!( + vc.acm_certificate_arn.as_deref(), + Some("arn:aws:acm:us-east-1:123456789012:certificate/abc") + ); + assert_eq!(vc.ssl_support_method.as_deref(), Some("sni-only")); + assert_eq!(vc.minimum_protocol_version.as_deref(), Some("TLSv1.2_2021")); + } + + #[test] + fn origin_groups_and_scalar_extras_are_kept() { + let config = translate(&with(json!({ + "OriginGroups": {"Quantity": 1, "Items": [{ + "Id": "group", + "FailoverCriteria": {"StatusCodes": {"Quantity": 2, "Items": [500, "502"]}}, + "Members": {"Quantity": 1, "Items": [{"OriginId": "o1"}]} + }]}, + "ContinuousDeploymentPolicyId": "cdp-1", + "Staging": false, + "CNAMEs": ["legacy.example.com"] + }))); + let groups = config.origin_groups.unwrap(); + assert_eq!(groups.quantity, 1); + let group = &groups.items.unwrap().origin_group[0]; + assert_eq!( + group.failover_criteria.status_codes.items.status_code, + vec![500, 502] + ); + assert_eq!(group.members.items.origin_group_member[0].origin_id, "o1"); + assert_eq!( + config.continuous_deployment_policy_id.as_deref(), + Some("cdp-1") + ); + assert_eq!(config.staging, Some(false)); + assert_eq!( + config.aliases.unwrap().items.unwrap().cname, + vec!["legacy.example.com"] + ); + } + + fn translate_err(cfg: &Value) -> String { + cfn_distribution_config(cfg, "ref".to_string()).unwrap_err() + } + + #[test] + fn scalars_given_for_string_members_are_stringified() { + let config = translate(&with(json!({ + "Comment": 42, + "Aliases": ["a.example.com", 7], + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "ForwardedValues": {"QueryString": false, "Headers": [true]}} + }))); + assert_eq!(config.comment, "42"); + assert_eq!( + config.aliases.unwrap().items.unwrap().cname, + vec!["a.example.com", "7"] + ); + let fv = config.default_cache_behavior.forwarded_values.unwrap(); + assert_eq!(fv.headers.unwrap().items.unwrap().name, vec!["true"]); + } + + #[test] + fn object_members_given_a_scalar_fail_the_resource() { + let err = translate_err(&with(json!({"Logging": "s3://logs"}))); + assert!(err.contains("Logging must be an object"), "{err}"); + let err = translate_err(&with(json!({"CustomErrorResponses": ["404"]}))); + assert!( + err.contains("CustomErrorResponses[0] must be an object"), + "{err}" + ); + } + + #[test] + fn schema_required_members_are_not_defaulted() { + let mut no_enabled = base(); + no_enabled.as_object_mut().unwrap().remove("Enabled"); + assert!(translate_err(&no_enabled).contains("Enabled is required")); + + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "GrpcConfig": {}} + }))); + assert!(err.contains("GrpcConfig: Enabled is required"), "{err}"); + + let err = translate_err(&with(json!({ + "TenantConfig": {"ParameterDefinitions": [{"Name": "p"}]} + }))); + assert!(err.contains("Definition is required"), "{err}"); + + let err = translate_err(&with(json!({ + "TenantConfig": {"ParameterDefinitions": [{"Name": "p", "Definition": {"StringSchema": {}}}]} + }))); + assert!(err.contains("Required is required"), "{err}"); + } + + #[test] + fn incomplete_associations_fail_with_cloudfronts_error_not_a_schema_error() { + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "FunctionAssociations": [{"EventType": "viewer-request"}]} + }))); + assert!(err.starts_with("InvalidFunctionAssociation"), "{err}"); + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "LambdaFunctionAssociations": [{"LambdaFunctionARN": "arn:l"}]} + }))); + assert!(err.starts_with("InvalidLambdaFunctionAssociation"), "{err}"); + } + + #[test] + fn integers_are_neither_truncated_nor_saturated() { + let err = translate_err(&with( + json!({"CustomErrorResponses": [{"ErrorCode": 404.5}]}), + )); + assert!(err.contains("ErrorCode must be an integer"), "{err}"); + let err = translate_err(&with(json!({ + "Origins": [{"Id": "o1", "DomainName": "o.example.com", "ConnectionAttempts": u64::MAX}] + }))); + assert!(err.contains("ConnectionAttempts is out of range"), "{err}"); + let err = translate_err(&with(json!({ + "Origins": [{"Id": "o1", "DomainName": "o.example.com", "ConnectionAttempts": "99999999999999999999"}] + }))); + assert!(err.contains("ConnectionAttempts is out of range"), "{err}"); + } + + #[test] + fn ttls_accept_integral_doubles_only() { + let config = translate(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "DefaultTTL": 86400.0, "MaxTTL": "600.0"}, + "CustomErrorResponses": [{"ErrorCode": 404, "ErrorCachingMinTTL": 10.0}] + }))); + assert_eq!(config.default_cache_behavior.default_ttl, Some(86400)); + assert_eq!(config.default_cache_behavior.max_ttl, Some(600)); + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "MinTTL": 1.5} + }))); + assert!( + err.contains("MinTTL must be a whole number of seconds"), + "{err}" + ); + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "MaxTTL": 1e30} + }))); + assert!(err.contains("MaxTTL is out of range"), "{err}"); + } + + #[test] + fn legacy_s3_origin_becomes_the_default_behaviors_origin() { + let mut cfg = base(); + let obj = cfg.as_object_mut().unwrap(); + obj.remove("Origins"); + obj.insert( + "S3Origin".into(), + json!({"DNSName": "b.s3.amazonaws.com", "OriginAccessIdentity": "origin-access-identity/cloudfront/E1"}), + ); + let config = translate(&cfg); + let origins = config.origins.items.unwrap().origin; + assert_eq!(origins.len(), 1); + assert_eq!(origins[0].id, "o1"); + assert_eq!(origins[0].domain_name, "b.s3.amazonaws.com"); + assert_eq!( + origins[0] + .s3_origin_config + .as_ref() + .unwrap() + .origin_access_identity, + "origin-access-identity/cloudfront/E1" + ); + } + + #[test] + fn legacy_custom_origin_becomes_the_default_behaviors_origin() { + let mut cfg = base(); + let obj = cfg.as_object_mut().unwrap(); + obj.remove("Origins"); + obj.insert( + "CustomOrigin".into(), + json!({"DNSName": "api.example.com", "OriginProtocolPolicy": "https-only", + "OriginSSLProtocols": ["TLSv1.2"], "HTTPSPort": 8443}), + ); + let config = translate(&cfg); + let origin = &config.origins.items.unwrap().origin[0]; + assert_eq!(origin.id, "o1"); + let custom = origin.custom_origin_config.as_ref().unwrap(); + assert_eq!((custom.http_port, custom.https_port), (80, 8443)); + assert_eq!( + custom + .origin_ssl_protocols + .as_ref() + .unwrap() + .items + .ssl_protocol, + vec!["TLSv1.2"] + ); + } + + #[test] + fn origins_are_required_only_without_a_legacy_origin() { + let mut cfg = base(); + cfg.as_object_mut().unwrap().remove("Origins"); + assert!(translate_err(&cfg).contains("Origins is required")); + let err = translate_err(&with( + json!({"S3Origin": {"DNSName": "b.s3.amazonaws.com"}}), + )); + assert!( + err.contains("only one of Origins, S3Origin and CustomOrigin"), + "{err}" + ); + } + + #[test] + fn the_translated_config_goes_through_cloudfronts_validation() { + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "nowhere", "ViewerProtocolPolicy": "allow-all"} + }))); + assert!(err.starts_with("NoSuchOrigin"), "{err}"); + let err = translate_err(&with(json!({ + "DefaultCacheBehavior": {"TargetOriginId": "o1", "ViewerProtocolPolicy": "allow-all", + "AllowedMethods": ["GET", "HEAD"], + "CachedMethods": ["GET", "HEAD", "OPTIONS"]} + }))); + assert!(err.starts_with("InvalidArgument"), "{err}"); + } + + #[test] + fn mtls_connection_function_and_cache_tag_members_translate() { + let config = translate(&with(json!({ + "Origins": [{"Id": "o1", "DomainName": "o.example.com", + "CustomOriginConfig": {"OriginProtocolPolicy": "https-only", + "OriginMtlsConfig": {"ClientCertificateArn": "arn:cert"}}}], + "ViewerMtlsConfig": {"Mode": "required", + "TrustStoreConfig": {"TrustStoreId": "ts-1", "IgnoreCertificateExpiry": true}}, + "ConnectionFunctionAssociation": {"Id": "cf-1"}, + "CacheTagConfig": {"HeaderName": "Cache-Tag"} + }))); + let mtls = config.viewer_mtls_config.unwrap(); + assert_eq!(mtls.mode.as_deref(), Some("required")); + let ts = mtls.trust_store_config.unwrap(); + assert_eq!(ts.trust_store_id, "ts-1"); + assert_eq!(ts.ignore_certificate_expiry, Some(true)); + assert_eq!(config.connection_function_association.unwrap().id, "cf-1"); + assert_eq!(config.cache_tag_config.unwrap().header_name, "Cache-Tag"); + let origin = &config.origins.items.unwrap().origin[0]; + assert_eq!( + origin + .custom_origin_config + .as_ref() + .unwrap() + .origin_mtls_config + .as_ref() + .unwrap() + .client_certificate_arn, + "arn:cert" + ); + } + + #[test] + fn empty_optional_lists_omit_items() { + let config = translate(&with(json!({"Aliases": [], "CustomErrorResponses": []}))); + let aliases = config.aliases.unwrap(); + assert_eq!((aliases.quantity, aliases.items.is_none()), (0, true)); + let rules = config.custom_error_responses.unwrap(); + assert_eq!((rules.quantity, rules.items.is_none()), (0, true)); + } +} diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index a8ccbe58c..8e4725079 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -33,12 +33,6 @@ use fakecloud_cloudfront::{ PublicKeyConfig, StoredFunction, StoredKeyGroup, StoredOriginAccessIdentity, StoredPublicKey, }, - model::{ - AliasItems, Aliases, CacheBehavior, CacheBehaviorItems, CacheBehaviors, - CustomErrorResponse, CustomErrorResponseItems, CustomErrorResponses, DefaultCacheBehavior, - DistributionConfig, GeoRestriction, LocationList, LoggingConfig, Origin, OriginItems, - Origins, Restrictions, ViewerCertificate, - }, policies::{ CachePolicyConfig, OriginAccessControlConfig, OriginRequestPolicyConfig, OriginRequestPolicyCookiesConfig, OriginRequestPolicyHeadersConfig, diff --git a/crates/fakecloud-cloudfront/src/lib.rs b/crates/fakecloud-cloudfront/src/lib.rs index caae2ec60..4aa481fc9 100644 --- a/crates/fakecloud-cloudfront/src/lib.rs +++ b/crates/fakecloud-cloudfront/src/lib.rs @@ -27,6 +27,7 @@ pub mod streaming; pub mod streaming_service; pub mod tenants; pub mod tenants_service; +pub mod validate; pub mod xml_io; pub const API_VERSION: &str = "2020-05-31"; @@ -35,6 +36,7 @@ pub const NAMESPACE: &str = "http://cloudfront.amazonaws.com/doc/2020-05-31/"; pub use service::{cloudfront_arn, distribution_arn, CloudFrontService}; pub use state::{ - CloudFrontAccounts, CloudFrontSnapshot, SharedCloudFrontState, StoredDistribution, - CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION, + parse_cloudfront_snapshot, CloudFrontAccounts, CloudFrontSnapshot, SharedCloudFrontState, + StoredDistribution, CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION, }; +pub use validate::validate_distribution_config; diff --git a/crates/fakecloud-cloudfront/src/model.rs b/crates/fakecloud-cloudfront/src/model.rs index 2bac4e208..7fc5c1df9 100644 --- a/crates/fakecloud-cloudfront/src/model.rs +++ b/crates/fakecloud-cloudfront/src/model.rs @@ -48,7 +48,12 @@ pub struct DistributionConfig { pub web_acl_id: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub http_version: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + // AWS spells this `IsIPV6Enabled`; PascalCase would give `IsIpv6Enabled`. + #[serde( + default, + rename = "IsIPV6Enabled", + skip_serializing_if = "skip_if_none" + )] pub is_ipv6_enabled: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub continuous_deployment_policy_id: Option, @@ -60,6 +65,49 @@ pub struct DistributionConfig { pub tenant_config: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub connection_mode: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub viewer_mtls_config: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub connection_function_association: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub cache_tag_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct ViewerMtlsConfig { + #[serde(default, skip_serializing_if = "skip_if_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub trust_store_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct TrustStoreConfig { + pub trust_store_id: String, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub advertise_trust_store_ca_names: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub ignore_certificate_expiry: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct ConnectionFunctionAssociation { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct CacheTagConfig { + pub header_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct OriginMtlsConfig { + pub client_certificate_arn: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -145,6 +193,8 @@ pub struct OriginCustomHeader { #[serde(rename_all = "PascalCase")] pub struct S3OriginConfig { pub origin_access_identity: String, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub origin_read_timeout: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -164,6 +214,10 @@ pub struct CustomOriginConfig { pub origin_read_timeout: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub origin_keepalive_timeout: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub ip_address_type: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] + pub origin_mtls_config: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -185,6 +239,8 @@ pub struct SslProtocolItems { pub struct VpcOriginConfig { pub vpc_origin_id: String, #[serde(default, skip_serializing_if = "skip_if_none")] + pub owner_account_id: Option, + #[serde(default, skip_serializing_if = "skip_if_none")] pub origin_read_timeout: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub origin_keepalive_timeout: Option, @@ -296,11 +352,13 @@ pub struct DefaultCacheBehavior { pub grpc_config: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub forwarded_values: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + // AWS spells the TTLs `MinTTL` / `DefaultTTL` / `MaxTTL`; PascalCase would + // give `MinTtl`, dropping them from real SDK requests. + #[serde(default, rename = "MinTTL", skip_serializing_if = "skip_if_none")] pub min_ttl: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde(default, rename = "DefaultTTL", skip_serializing_if = "skip_if_none")] pub default_ttl: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde(default, rename = "MaxTTL", skip_serializing_if = "skip_if_none")] pub max_ttl: Option, } @@ -377,6 +435,7 @@ pub struct LambdaFunctionAssociationItems { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "PascalCase")] pub struct LambdaFunctionAssociation { + #[serde(rename = "LambdaFunctionARN")] pub lambda_function_arn: String, pub event_type: String, #[serde(default, skip_serializing_if = "skip_if_none")] @@ -401,6 +460,7 @@ pub struct FunctionAssociationItems { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "PascalCase")] pub struct FunctionAssociation { + #[serde(rename = "FunctionARN")] pub function_arn: String, pub event_type: String, } @@ -524,14 +584,65 @@ pub struct CacheBehavior { pub grpc_config: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub forwarded_values: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + // AWS spells the TTLs `MinTTL` / `DefaultTTL` / `MaxTTL`; PascalCase would + // give `MinTtl`, dropping them from real SDK requests. + #[serde(default, rename = "MinTTL", skip_serializing_if = "skip_if_none")] pub min_ttl: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde(default, rename = "DefaultTTL", skip_serializing_if = "skip_if_none")] pub default_ttl: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde(default, rename = "MaxTTL", skip_serializing_if = "skip_if_none")] pub max_ttl: Option, } +impl CacheBehavior { + /// A path-scoped behavior carrying the same settings as `default`. + pub fn from_default(path_pattern: String, default: DefaultCacheBehavior) -> Self { + let DefaultCacheBehavior { + target_origin_id, + trusted_signers, + trusted_key_groups, + viewer_protocol_policy, + allowed_methods, + smooth_streaming, + compress, + lambda_function_associations, + function_associations, + field_level_encryption_id, + realtime_log_config_arn, + cache_policy_id, + origin_request_policy_id, + response_headers_policy_id, + grpc_config, + forwarded_values, + min_ttl, + default_ttl, + max_ttl, + } = default; + Self { + path_pattern, + target_origin_id, + trusted_signers, + trusted_key_groups, + viewer_protocol_policy, + allowed_methods, + smooth_streaming, + compress, + lambda_function_associations, + function_associations, + field_level_encryption_id, + realtime_log_config_arn, + cache_policy_id, + origin_request_policy_id, + response_headers_policy_id, + grpc_config, + forwarded_values, + min_ttl, + default_ttl, + max_ttl, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "PascalCase")] pub struct CustomErrorResponses { @@ -581,11 +692,25 @@ pub struct LoggingConfig { pub struct ViewerCertificate { #[serde(default, skip_serializing_if = "skip_if_none")] pub cloud_front_default_certificate: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + // AWS spells these `IAMCertificateId` / `ACMCertificateArn` / + // `SSLSupportMethod`, not the PascalCase `IamCertificateId` etc. + #[serde( + default, + rename = "IAMCertificateId", + skip_serializing_if = "skip_if_none" + )] pub iam_certificate_id: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde( + default, + rename = "ACMCertificateArn", + skip_serializing_if = "skip_if_none" + )] pub acm_certificate_arn: Option, - #[serde(default, skip_serializing_if = "skip_if_none")] + #[serde( + default, + rename = "SSLSupportMethod", + skip_serializing_if = "skip_if_none" + )] pub ssl_support_method: Option, #[serde(default, skip_serializing_if = "skip_if_none")] pub minimum_protocol_version: Option, @@ -725,3 +850,173 @@ pub struct PathList { #[serde(default, rename = "Path")] pub path: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + /// A config whose acronym members PascalCase would mis-spell. + fn acronym_config_xml() -> &'static str { + r#" + ref + 0 + + o1 + allow-all + 1 + arn:fviewer-request + + 1 + arn:lorigin-request + + 123 + + 1 + 40410 + + + true + + iam-1 + arn:c + sni-only + + true +"# + } + + #[test] + fn acronym_members_parse_and_render_under_aws_spelling() { + let config: DistributionConfig = quick_xml::de::from_str(acronym_config_xml()).unwrap(); + let dcb = &config.default_cache_behavior; + assert_eq!( + (dcb.min_ttl, dcb.default_ttl, dcb.max_ttl), + (Some(1), Some(2), Some(3)) + ); + let fa = &dcb + .function_associations + .as_ref() + .unwrap() + .items + .as_ref() + .unwrap(); + assert_eq!(fa.function_association[0].function_arn, "arn:f"); + let la = &dcb.lambda_function_associations.as_ref().unwrap(); + assert_eq!( + la.items.as_ref().unwrap().lambda_function_association[0].lambda_function_arn, + "arn:l" + ); + let rule = &config.custom_error_responses.as_ref().unwrap(); + assert_eq!( + rule.items.as_ref().unwrap().custom_error_response[0].error_caching_min_ttl, + Some(10) + ); + let vc = config.viewer_certificate.as_ref().unwrap(); + assert_eq!(vc.iam_certificate_id.as_deref(), Some("iam-1")); + assert_eq!(vc.acm_certificate_arn.as_deref(), Some("arn:c")); + assert_eq!(vc.ssl_support_method.as_deref(), Some("sni-only")); + assert_eq!(config.is_ipv6_enabled, Some(true)); + + let xml = quick_xml::se::to_string_with_root("DistributionConfig", &config).unwrap(); + for tag in [ + "1", + "2", + "3", + "arn:f", + "arn:l", + "10", + "iam-1", + "arn:c", + "sni-only", + "true", + ] { + assert!(xml.contains(tag), "missing {tag} in {xml}"); + } + } + + #[test] + fn old_pascal_case_spellings_are_not_accepted_on_the_wire() { + // `MinTtl` is not a CloudFront member name; it must not be read as + // `MinTTL`. + let xml = acronym_config_xml() + .replace("1", "1") + .replace( + "arn:c", + "arn:c", + ); + let config: DistributionConfig = quick_xml::de::from_str(&xml).unwrap(); + assert_eq!(config.default_cache_behavior.min_ttl, None); + assert_eq!(config.viewer_certificate.unwrap().acm_certificate_arn, None); + } + + #[test] + fn mtls_connection_function_and_cache_tag_members_round_trip() { + let xml = r#" + ref + 1 + o1o.example.com + + 80443 + https-only + arn:cert + + + + o1 + allow-all + + + true + + required + + ts-1 + true + false + + + cf-1 + Cache-Tag +"#; + let config: DistributionConfig = quick_xml::de::from_str(xml).unwrap(); + let mtls = config.viewer_mtls_config.as_ref().unwrap(); + assert_eq!(mtls.mode.as_deref(), Some("required")); + let ts = mtls.trust_store_config.as_ref().unwrap(); + assert_eq!(ts.trust_store_id, "ts-1"); + assert_eq!(ts.advertise_trust_store_ca_names, Some(true)); + assert_eq!(ts.ignore_certificate_expiry, Some(false)); + assert_eq!( + config.connection_function_association.as_ref().unwrap().id, + "cf-1" + ); + assert_eq!( + config.cache_tag_config.as_ref().unwrap().header_name, + "Cache-Tag" + ); + let origin = &config.origins.items.as_ref().unwrap().origin[0]; + assert_eq!( + origin + .custom_origin_config + .as_ref() + .unwrap() + .origin_mtls_config + .as_ref() + .unwrap() + .client_certificate_arn, + "arn:cert" + ); + + let rendered = quick_xml::se::to_string_with_root("DistributionConfig", &config).unwrap(); + for tag in [ + "required", + "ts-1", + "true", + "false", + "cf-1", + "Cache-Tag", + "arn:cert", + ] { + assert!(rendered.contains(tag), "missing {tag} in {rendered}"); + } + } +} diff --git a/crates/fakecloud-cloudfront/src/service.rs b/crates/fakecloud-cloudfront/src/service.rs index c72f84125..e33a007be 100644 --- a/crates/fakecloud-cloudfront/src/service.rs +++ b/crates/fakecloud-cloudfront/src/service.rs @@ -23,6 +23,7 @@ use crate::state::{ CloudFrontAccounts, CloudFrontSnapshot, SharedCloudFrontState, StoredDistribution, StoredInvalidation, Tag, CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION, }; +use crate::validate::validate_distribution_config; use crate::xml_io; /// CloudFront mutating actions share these prefixes; everything else @@ -695,8 +696,7 @@ impl CloudFrontService { (parsed, Vec::new()) }; - validate_caller_reference(&config.caller_reference)?; - validate_origins(&config)?; + validate_distribution_config(&config)?; let mut state = self.state.write(); let account = state.entry(account_id(req)); @@ -936,8 +936,7 @@ impl CloudFrontService { .to_string(); let new_config: DistributionConfig = xml_io::from_xml_root(&req.body) .map_err(|e| invalid_argument(format!("invalid DistributionConfig XML: {e}")))?; - validate_caller_reference(&new_config.caller_reference)?; - validate_origins(&new_config)?; + validate_distribution_config(&new_config)?; let mut state = self.state.write(); let account = state @@ -2120,15 +2119,6 @@ fn validate_caller_reference(s: &str) -> Result<(), AwsServiceError> { Ok(()) } -fn validate_origins(config: &DistributionConfig) -> Result<(), AwsServiceError> { - if config.origins.quantity < 1 { - return Err(invalid_argument( - "DistributionConfig.Origins must contain at least one origin", - )); - } - Ok(()) -} - /// Compare two `DistributionConfig`s by serializing them to canonical XML /// and comparing bytes. Used by `UpdateDistribution` to detect no-op writes /// so the ETag stays stable when the caller PUTs the same config back. diff --git a/crates/fakecloud-cloudfront/src/state.rs b/crates/fakecloud-cloudfront/src/state.rs index b07aec951..8fa803744 100644 --- a/crates/fakecloud-cloudfront/src/state.rs +++ b/crates/fakecloud-cloudfront/src/state.rs @@ -41,7 +41,79 @@ pub struct CloudFrontSnapshot { pub accounts: Option, } -pub const CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION: u32 = 1; +/// v2: distribution config members AWS spells with an upper-case acronym +/// (`MinTTL`, `IsIPV6Enabled`, `ACMCertificateArn`, ...) are stored under that +/// spelling; v1 wrote the PascalCase form (`MinTtl`, ...). +pub const CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION: u32 = 2; + +/// Distribution config member names v1 snapshots wrote, paired with the AWS +/// spelling v2 uses. +const V1_DISTRIBUTION_CONFIG_RENAMES: &[(&str, &str)] = &[ + ("MinTtl", "MinTTL"), + ("DefaultTtl", "DefaultTTL"), + ("MaxTtl", "MaxTTL"), + ("ErrorCachingMinTtl", "ErrorCachingMinTTL"), + ("IsIpv6Enabled", "IsIPV6Enabled"), + ("IamCertificateId", "IAMCertificateId"), + ("AcmCertificateArn", "ACMCertificateArn"), + ("SslSupportMethod", "SSLSupportMethod"), + ("FunctionArn", "FunctionARN"), + ("LambdaFunctionArn", "LambdaFunctionARN"), +]; + +/// Parse an on-disk CloudFront snapshot, migrating older schema versions. +/// +/// The migration rewrites the stored JSON rather than teaching the model +/// structs the old names, so the old spellings never become accepted on the +/// XML wire. +pub fn parse_cloudfront_snapshot(bytes: &[u8]) -> Result { + let mut value: serde_json::Value = serde_json::from_slice(bytes)?; + let version = value + .get("schema_version") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + if version < 2 { + migrate_v1_distribution_configs(&mut value); + } + serde_json::from_value(value) +} + +fn migrate_v1_distribution_configs(snapshot: &mut serde_json::Value) { + let Some(accounts) = snapshot + .pointer_mut("/accounts/accounts") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + for account in accounts.values_mut() { + let Some(distributions) = account + .get_mut("distributions") + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + for distribution in distributions.values_mut() { + if let Some(config) = distribution.get_mut("config") { + rename_keys(config); + } + } + } +} + +fn rename_keys(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for (old, new) in V1_DISTRIBUTION_CONFIG_RENAMES { + if let Some(v) = map.remove(*old) { + map.insert((*new).to_string(), v); + } + } + map.values_mut().for_each(rename_keys); + } + serde_json::Value::Array(items) => items.iter_mut().for_each(rename_keys), + _ => {} + } +} impl CloudFrontAccounts { pub fn new() -> Self { @@ -143,3 +215,132 @@ pub struct Tag { pub key: String, pub value: Option, } + +#[cfg(test)] +mod snapshot_migration_tests { + use super::*; + + /// A config exercising every member v2 renamed. + fn config_with_renamed_members() -> serde_json::Value { + serde_json::json!({ + "CallerReference": "ref", + "Origins": {"Quantity": 0}, + "DefaultCacheBehavior": { + "TargetOriginId": "o1", + "ViewerProtocolPolicy": "allow-all", + "MinTtl": 1, "DefaultTtl": 2, "MaxTtl": 3, + "FunctionAssociations": {"Quantity": 1, "Items": {"FunctionAssociation": [ + {"FunctionArn": "arn:f", "EventType": "viewer-request"}]}}, + "LambdaFunctionAssociations": {"Quantity": 1, "Items": {"LambdaFunctionAssociation": [ + {"LambdaFunctionArn": "arn:l", "EventType": "origin-request"}]}} + }, + "CustomErrorResponses": {"Quantity": 1, "Items": {"CustomErrorResponse": [ + {"ErrorCode": 404, "ErrorCachingMinTtl": 10}]}}, + "Comment": "", + "Enabled": true, + "ViewerCertificate": {"IamCertificateId": "iam-1", "AcmCertificateArn": "arn:c", "SslSupportMethod": "sni-only"}, + "IsIpv6Enabled": false + }) + } + + /// A v1 snapshot: a current one with the distribution config swapped for + /// one written under the PascalCase names. + fn v1_snapshot_bytes() -> Vec { + let mut accounts = CloudFrontAccounts::new(); + accounts.entry("000000000000").distributions.insert( + "E1".to_string(), + StoredDistribution { + id: "E1".to_string(), + arn: "arn".to_string(), + status: "Deployed".to_string(), + last_modified_time: Utc::now(), + domain_name: "e1.cloudfront.net".to_string(), + in_progress_invalidation_batches: 0, + etag: "T".to_string(), + config: DistributionConfig::default(), + }, + ); + let mut value = serde_json::to_value(CloudFrontSnapshot { + schema_version: 1, + accounts: Some(accounts), + }) + .unwrap(); + *value + .pointer_mut("/accounts/accounts/000000000000/distributions/E1/config") + .unwrap() = config_with_renamed_members(); + serde_json::to_vec(&value).unwrap() + } + + #[test] + fn a_v1_snapshot_loads_with_its_pascal_case_member_names_migrated() { + let snapshot = parse_cloudfront_snapshot(&v1_snapshot_bytes()).expect("v1 snapshot loads"); + let accounts = snapshot.accounts.unwrap(); + let config = &accounts.get("000000000000").unwrap().distributions["E1"].config; + let dcb = &config.default_cache_behavior; + assert_eq!( + (dcb.min_ttl, dcb.default_ttl, dcb.max_ttl), + (Some(1), Some(2), Some(3)) + ); + let fa = dcb.function_associations.as_ref().unwrap(); + assert_eq!( + fa.items.as_ref().unwrap().function_association[0].function_arn, + "arn:f" + ); + let la = dcb.lambda_function_associations.as_ref().unwrap(); + assert_eq!( + la.items.as_ref().unwrap().lambda_function_association[0].lambda_function_arn, + "arn:l" + ); + let rules = config.custom_error_responses.as_ref().unwrap(); + assert_eq!( + rules.items.as_ref().unwrap().custom_error_response[0].error_caching_min_ttl, + Some(10) + ); + let vc = config.viewer_certificate.as_ref().unwrap(); + assert_eq!(vc.iam_certificate_id.as_deref(), Some("iam-1")); + assert_eq!(vc.acm_certificate_arn.as_deref(), Some("arn:c")); + assert_eq!(vc.ssl_support_method.as_deref(), Some("sni-only")); + assert_eq!(config.is_ipv6_enabled, Some(false)); + } + + #[test] + fn the_migration_only_runs_for_v1_snapshots() { + // A v2 snapshot is read as written: the PascalCase names are not + // CloudFront member names, so the required `FunctionARN` is missing. + let mut value: serde_json::Value = serde_json::from_slice(&v1_snapshot_bytes()).unwrap(); + value["schema_version"] = serde_json::json!(2); + let err = parse_cloudfront_snapshot(&serde_json::to_vec(&value).unwrap()) + .err() + .expect("not migrated"); + assert!(err.to_string().contains("FunctionARN"), "{err}"); + } + + #[test] + fn a_current_snapshot_round_trips_through_the_parser() { + let mut accounts = CloudFrontAccounts::new(); + let mut config = DistributionConfig::default(); + config.default_cache_behavior.min_ttl = Some(7); + accounts.entry("000000000000").distributions.insert( + "E2".to_string(), + StoredDistribution { + id: "E2".to_string(), + arn: "arn".to_string(), + status: "Deployed".to_string(), + last_modified_time: Utc::now(), + domain_name: "e2.cloudfront.net".to_string(), + in_progress_invalidation_batches: 0, + etag: "T".to_string(), + config, + }, + ); + let bytes = serde_json::to_vec(&CloudFrontSnapshot { + schema_version: CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION, + accounts: Some(accounts), + }) + .unwrap(); + let parsed = parse_cloudfront_snapshot(&bytes).unwrap(); + let accounts = parsed.accounts.unwrap(); + let dist = &accounts.get("000000000000").unwrap().distributions["E2"]; + assert_eq!(dist.config.default_cache_behavior.min_ttl, Some(7)); + } +} diff --git a/crates/fakecloud-cloudfront/src/validate.rs b/crates/fakecloud-cloudfront/src/validate.rs new file mode 100644 index 000000000..018a2ebed --- /dev/null +++ b/crates/fakecloud-cloudfront/src/validate.rs @@ -0,0 +1,269 @@ +//! `DistributionConfig` validation shared by `CreateDistribution`, +//! `UpdateDistribution` and the CloudFormation provisioner, so a config is +//! accepted or rejected the same way whichever door it comes in through. + +use std::collections::HashSet; + +use fakecloud_core::service::AwsServiceError; +use http::StatusCode; + +use crate::model::{ + AllowedMethods, DistributionConfig, FunctionAssociations, LambdaFunctionAssociations, +}; +use crate::service::{aws_error, invalid_argument}; + +/// The `EventType` values CloudFront accepts for Lambda@Edge and CloudFront +/// Functions associations. +const EVENT_TYPES: &[&str] = &[ + "viewer-request", + "viewer-response", + "origin-request", + "origin-response", +]; + +/// The method sets CloudFront accepts for `AllowedMethods`. +const ALLOWED_METHOD_SETS: &[&[&str]] = &[ + &["GET", "HEAD"], + &["GET", "HEAD", "OPTIONS"], + &["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"], +]; + +/// The method sets CloudFront accepts for `CachedMethods`. +const CACHED_METHOD_SETS: &[&[&str]] = &[&["GET", "HEAD"], &["GET", "HEAD", "OPTIONS"]]; + +/// Validate a `DistributionConfig` the way CloudFront does on +/// `CreateDistribution` / `UpdateDistribution`. +pub fn validate_distribution_config(config: &DistributionConfig) -> Result<(), AwsServiceError> { + if config.caller_reference.is_empty() { + return Err(invalid_argument("CallerReference is required")); + } + let origins = config + .origins + .items + .as_ref() + .map(|i| i.origin.as_slice()) + .unwrap_or_default(); + if config.origins.quantity < 1 || origins.is_empty() { + return Err(invalid_argument( + "DistributionConfig.Origins must contain at least one origin", + )); + } + + // A behavior may target an origin or an origin group. + let targets: HashSet<&str> = origins + .iter() + .map(|o| o.id.as_str()) + .chain( + config + .origin_groups + .iter() + .filter_map(|g| g.items.as_ref()) + .flat_map(|i| i.origin_group.iter().map(|g| g.id.as_str())), + ) + .collect(); + + let dcb = &config.default_cache_behavior; + validate_behavior( + &targets, + &dcb.target_origin_id, + dcb.allowed_methods.as_ref(), + dcb.lambda_function_associations.as_ref(), + dcb.function_associations.as_ref(), + )?; + for b in config + .cache_behaviors + .iter() + .filter_map(|b| b.items.as_ref()) + .flat_map(|i| i.cache_behavior.iter()) + { + validate_behavior( + &targets, + &b.target_origin_id, + b.allowed_methods.as_ref(), + b.lambda_function_associations.as_ref(), + b.function_associations.as_ref(), + )?; + } + Ok(()) +} + +fn validate_behavior( + targets: &HashSet<&str>, + target_origin_id: &str, + allowed_methods: Option<&AllowedMethods>, + lambda_associations: Option<&LambdaFunctionAssociations>, + function_associations: Option<&FunctionAssociations>, +) -> Result<(), AwsServiceError> { + if !targets.contains(target_origin_id) { + return Err(aws_error( + StatusCode::NOT_FOUND, + "NoSuchOrigin", + format!( + "One or more of your origins or origin groups do not exist: {target_origin_id}" + ), + )); + } + + if let Some(allowed) = allowed_methods { + let allowed_set = method_set(&allowed.items.method); + if !ALLOWED_METHOD_SETS.contains(&allowed_set.as_slice()) { + return Err(invalid_argument(format!( + "AllowedMethods must be GET,HEAD or GET,HEAD,OPTIONS or all seven methods, got {}", + allowed_set.join(",") + ))); + } + if let Some(cached) = &allowed.cached_methods { + let cached_set = method_set(&cached.items.method); + if !CACHED_METHOD_SETS.contains(&cached_set.as_slice()) + || !cached_set.iter().all(|m| allowed_set.contains(m)) + { + return Err(invalid_argument(format!( + "CachedMethods must be GET,HEAD or GET,HEAD,OPTIONS and a subset of AllowedMethods, got {}", + cached_set.join(",") + ))); + } + } + } + + for a in lambda_associations + .and_then(|l| l.items.as_ref()) + .map(|i| i.lambda_function_association.as_slice()) + .unwrap_or_default() + { + if a.lambda_function_arn.is_empty() || !EVENT_TYPES.contains(&a.event_type.as_str()) { + return Err(aws_error( + StatusCode::BAD_REQUEST, + "InvalidLambdaFunctionAssociation", + "A Lambda function association needs a LambdaFunctionARN and a valid EventType", + )); + } + } + for a in function_associations + .and_then(|f| f.items.as_ref()) + .map(|i| i.function_association.as_slice()) + .unwrap_or_default() + { + if a.function_arn.is_empty() || !EVENT_TYPES.contains(&a.event_type.as_str()) { + return Err(aws_error( + StatusCode::BAD_REQUEST, + "InvalidFunctionAssociation", + "A function association needs a FunctionARN and a valid EventType", + )); + } + } + Ok(()) +} + +/// Sorted, de-duplicated, upper-cased method names. +fn method_set(methods: &[String]) -> Vec<&'static str> { + let mut set: Vec<&'static str> = methods + .iter() + .filter_map(|m| { + ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + .into_iter() + .find(|known| known.eq_ignore_ascii_case(m)) + }) + .collect(); + // An unknown method never matches a valid set; keep it visible as such. + if set.len() != methods.len() { + set.push("?"); + } + set.sort_unstable(); + set.dedup(); + set +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + CachedMethods, FunctionAssociation, FunctionAssociationItems, MethodList, Origin, + OriginItems, Origins, + }; + + fn config() -> DistributionConfig { + let mut c = DistributionConfig { + caller_reference: "ref".into(), + origins: Origins { + quantity: 1, + items: Some(OriginItems { + origin: vec![Origin { + id: "o1".into(), + domain_name: "o.example.com".into(), + ..Default::default() + }], + }), + }, + ..Default::default() + }; + c.default_cache_behavior.target_origin_id = "o1".into(); + c.default_cache_behavior.viewer_protocol_policy = "allow-all".into(); + c + } + + fn methods(m: &[&str]) -> MethodList { + MethodList { + method: m.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn a_valid_config_passes() { + validate_distribution_config(&config()).unwrap(); + } + + #[test] + fn a_target_naming_no_origin_is_no_such_origin() { + let mut c = config(); + c.default_cache_behavior.target_origin_id = "missing".into(); + let err = validate_distribution_config(&c).unwrap_err(); + assert_eq!(err.code(), "NoSuchOrigin"); + } + + #[test] + fn cached_methods_outside_allowed_methods_are_rejected() { + let mut c = config(); + c.default_cache_behavior.allowed_methods = Some(AllowedMethods { + quantity: 2, + items: methods(&["GET", "HEAD"]), + cached_methods: Some(CachedMethods { + quantity: 3, + items: methods(&["GET", "HEAD", "OPTIONS"]), + }), + }); + let err = validate_distribution_config(&c).unwrap_err(); + assert_eq!(err.code(), "InvalidArgument"); + } + + #[test] + fn an_allowed_method_set_cloudfront_does_not_offer_is_rejected() { + let mut c = config(); + c.default_cache_behavior.allowed_methods = Some(AllowedMethods { + quantity: 3, + items: methods(&["GET", "HEAD", "POST"]), + cached_methods: None, + }); + assert_eq!( + validate_distribution_config(&c).unwrap_err().code(), + "InvalidArgument" + ); + } + + #[test] + fn a_function_association_without_an_arn_is_rejected() { + let mut c = config(); + c.default_cache_behavior.function_associations = Some(FunctionAssociations { + quantity: 1, + items: Some(FunctionAssociationItems { + function_association: vec![FunctionAssociation { + function_arn: String::new(), + event_type: "viewer-request".into(), + }], + }), + }); + assert_eq!( + validate_distribution_config(&c).unwrap_err().code(), + "InvalidFunctionAssociation" + ); + } +} diff --git a/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs b/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs index 2526256d7..8ffb15cfc 100644 --- a/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs +++ b/crates/fakecloud-e2e/tests/cloudformation_cloudfront.rs @@ -273,7 +273,7 @@ async fn cfn_provisions_cloudfront_distribution() { /// 403/404 to `/index.html` with a 200. CloudFormation types `ResponseCode` as /// an Integer while the CloudFront API carries it as a string, so translating /// the CFN block through the wire struct dropped every rule and the -/// distribution came out with none — deep links 404'd instead of serving the +/// distribution came out with none, so deep links 404'd instead of serving the /// app shell. const SPA_ERROR_TEMPLATE: &str = r#"{ "Resources": { @@ -359,3 +359,236 @@ async fn cfn_provisions_spa_custom_error_responses() { assert_eq!(rule.error_caching_min_ttl(), Some(300)); } } + +/// A distribution in the shape CDK synthesizes: flat `AllowedMethods` / +/// `CachedMethods` lists, `FunctionAssociations`, CFN member names +/// (`OriginSSLProtocols`, `OriginCustomHeaders`, `AcmCertificateArn`, +/// `IPV6Enabled`), a custom origin without explicit ports, an S3 origin with an +/// empty `S3OriginConfig`, and a boolean handed over as a string by a parameter +/// `Ref`. Each of these used to fail the stack or vanish in translation. +fn cdk_shaped_template(error_page: &str, api_max_ttl: u32) -> String { + format!( + r#"{{ + "Parameters": {{ + "Ipv6": {{"Type": "String", "Default": "true"}} + }}, + "Resources": {{ + "Dist": {{ + "Type": "AWS::CloudFront::Distribution", + "Properties": {{ + "DistributionConfig": {{ + "Enabled": true, + "IPV6Enabled": {{"Ref": "Ipv6"}}, + "HttpVersion": "http2and3", + "Origins": [ + {{"Id": "api", "DomainName": "api.example.com", + "OriginCustomHeaders": [{{"HeaderName": "X-Origin-Secret", "HeaderValue": "s3cr3t"}}], + "CustomOriginConfig": {{"OriginProtocolPolicy": "https-only", "OriginSSLProtocols": ["TLSv1.2"]}}}}, + {{"Id": "site", "DomainName": "site.s3.us-east-1.amazonaws.com", "S3OriginConfig": {{}}}} + ], + "DefaultCacheBehavior": {{ + "TargetOriginId": "site", + "ViewerProtocolPolicy": "redirect-to-https", + "Compress": true, + "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6", + "FunctionAssociations": [ + {{"EventType": "viewer-request", "FunctionARN": "arn:aws:cloudfront::123456789012:function/rewrite"}} + ] + }}, + "CacheBehaviors": [{{ + "PathPattern": "/api/*", + "TargetOriginId": "api", + "ViewerProtocolPolicy": "https-only", + "AllowedMethods": ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"], + "CachedMethods": ["GET", "HEAD"], + "ForwardedValues": {{"QueryString": true, "Headers": ["Authorization"]}}, + "MinTTL": 0, + "DefaultTTL": 0, + "MaxTTL": {api_max_ttl} + }}], + "CustomErrorResponses": [ + {{"ErrorCode": 404, "ResponseCode": 200, "ResponsePagePath": "{error_page}", "ErrorCachingMinTTL": 10}} + ], + "ViewerCertificate": {{ + "AcmCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc", + "SslSupportMethod": "sni-only", + "MinimumProtocolVersion": "TLSv1.2_2021" + }} + }} + }} + }} + }}, + "Outputs": {{ + "DistId": {{"Value": {{"Ref": "Dist"}}}} + }} +}}"# + ) +} + +#[tokio::test] +async fn cfn_provisions_and_updates_a_cdk_shaped_distribution() { + let server = TestServer::start().await; + let cfn = server.cloudformation_client().await; + let cf = aws_sdk_cloudfront::Client::new(&server.aws_config().await); + + cfn.create_stack() + .stack_name("cf-cdk-shaped") + .template_body(cdk_shaped_template("/index.html", 60)) + .send() + .await + .expect("create_stack"); + let described = cfn + .describe_stacks() + .stack_name("cf-cdk-shaped") + .send() + .await + .expect("describe_stacks"); + let stack = described.stacks().first().unwrap(); + assert_eq!( + stack.stack_status().unwrap().as_str(), + "CREATE_COMPLETE", + "{:?}", + stack.stack_status_reason() + ); + let dist_id = stack + .outputs() + .iter() + .find(|o| o.output_key() == Some("DistId")) + .and_then(|o| o.output_value()) + .map(|s| s.to_string()) + .expect("DistId"); + + let got = cf + .get_distribution_config() + .id(&dist_id) + .send() + .await + .expect("get_distribution_config"); + let dcfg = got.distribution_config().expect("config"); + + assert_eq!(dcfg.is_ipv6_enabled(), Some(true)); + + let origins = dcfg.origins().unwrap().items(); + let api = origins.iter().find(|o| o.id() == "api").unwrap(); + let custom = api.custom_origin_config().unwrap(); + assert_eq!((custom.http_port(), custom.https_port()), (80, 443)); + let ssl: Vec<&str> = custom + .origin_ssl_protocols() + .unwrap() + .items() + .iter() + .map(|p| p.as_str()) + .collect(); + assert_eq!(ssl, vec!["TLSv1.2"]); + let headers = api.custom_headers().unwrap(); + assert_eq!(headers.quantity(), 1); + assert_eq!(headers.items()[0].header_name(), "X-Origin-Secret"); + let site = origins.iter().find(|o| o.id() == "site").unwrap(); + assert_eq!( + site.s3_origin_config().unwrap().origin_access_identity(), + "" + ); + + let dcb = dcfg.default_cache_behavior().unwrap(); + assert_eq!(dcb.compress(), Some(true)); + let fa = dcb.function_associations().unwrap(); + assert_eq!(fa.quantity(), 1); + assert_eq!( + fa.items()[0].function_arn(), + "arn:aws:cloudfront::123456789012:function/rewrite" + ); + + let behaviors = dcfg.cache_behaviors().expect("cache behaviors kept"); + assert_eq!(behaviors.quantity(), 1); + let api_behavior = &behaviors.items()[0]; + assert_eq!(api_behavior.path_pattern(), "/api/*"); + let allowed = api_behavior.allowed_methods().unwrap(); + assert_eq!(allowed.quantity(), 7); + assert_eq!(allowed.cached_methods().unwrap().quantity(), 2); + #[allow(deprecated)] + { + assert_eq!(api_behavior.min_ttl(), Some(0)); + assert_eq!(api_behavior.max_ttl(), Some(60)); + assert!(api_behavior.forwarded_values().unwrap().query_string()); + } + + let vc = dcfg.viewer_certificate().unwrap(); + assert_eq!( + vc.acm_certificate_arn(), + Some("arn:aws:acm:us-east-1:123456789012:certificate/abc") + ); + assert_eq!( + vc.ssl_support_method().map(|m| m.as_str()), + Some("sni-only") + ); + + let rules = dcfg.custom_error_responses().unwrap(); + assert_eq!(rules.items()[0].response_page_path(), Some("/index.html")); + + // UpdateStack goes through the same translation. + cfn.update_stack() + .stack_name("cf-cdk-shaped") + .template_body(cdk_shaped_template("/fallback.html", 120)) + .send() + .await + .expect("update_stack"); + let described = cfn + .describe_stacks() + .stack_name("cf-cdk-shaped") + .send() + .await + .expect("describe_stacks after update"); + let stack = described.stacks().first().unwrap(); + assert_eq!( + stack.stack_status().unwrap().as_str(), + "UPDATE_COMPLETE", + "{:?}", + stack.stack_status_reason() + ); + + let got = cf + .get_distribution_config() + .id(&dist_id) + .send() + .await + .expect("get_distribution_config after update"); + let dcfg = got.distribution_config().expect("config"); + let rules = dcfg.custom_error_responses().unwrap(); + assert_eq!(rules.quantity(), 1); + assert_eq!(rules.items()[0].response_code(), Some("200")); + assert_eq!( + rules.items()[0].response_page_path(), + Some("/fallback.html") + ); + let api_behavior = &dcfg.cache_behaviors().unwrap().items()[0]; + #[allow(deprecated)] + { + assert_eq!(api_behavior.max_ttl(), Some(120)); + } +} + +/// A rule CloudFormation's schema would reject fails the resource rather than +/// silently producing a distribution without it. +#[tokio::test] +async fn cfn_rejects_a_custom_error_response_without_an_error_code() { + let server = TestServer::start().await; + let cfn = server.cloudformation_client().await; + let template = SPA_ERROR_TEMPLATE.replace(r#""ErrorCode": 404, "#, ""); + + cfn.create_stack() + .stack_name("cf-bad-errors") + .template_body(template) + .send() + .await + .expect("create_stack"); + let described = cfn + .describe_stacks() + .stack_name("cf-bad-errors") + .send() + .await + .expect("describe_stacks"); + let stack = described.stacks().first().unwrap(); + assert_eq!(stack.stack_status().unwrap().as_str(), "CREATE_FAILED"); + let reason = stack.stack_status_reason().unwrap_or_default(); + assert!(reason.contains("ErrorCode is required"), "reason: {reason}"); +} diff --git a/crates/fakecloud-e2e/tests/cloudfront.rs b/crates/fakecloud-e2e/tests/cloudfront.rs index aac3fc8b4..3e85395bb 100644 --- a/crates/fakecloud-e2e/tests/cloudfront.rs +++ b/crates/fakecloud-e2e/tests/cloudfront.rs @@ -406,4 +406,15 @@ async fn cloudfront_complex_config_roundtrips() { &GeoRestrictionType::Whitelist ); assert_eq!(cfg.price_class(), Some(&PriceClass::PriceClass100)); + // Members AWS spells with an upper-case acronym (MinTTL, IsIPV6Enabled, + // ErrorCachingMinTTL) must survive the parse and the render. + assert_eq!(cfg.is_ipv6_enabled(), Some(true)); + let dcb = cfg.default_cache_behavior().unwrap(); + assert_eq!(dcb.min_ttl(), Some(0)); + assert_eq!(dcb.default_ttl(), Some(86400)); + assert_eq!(dcb.max_ttl(), Some(31536000)); + assert_eq!( + cfg.custom_error_responses().unwrap().items()[0].error_caching_min_ttl(), + Some(10) + ); } diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index 30cf96162..619450057 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -3621,33 +3621,30 @@ async fn main() { let path = data_path.join("cloudfront").join("snapshot.json"); let store = fakecloud_persistence::DiskSnapshotStore::new(path); match fakecloud_persistence::SnapshotStore::load(&store) { - Ok(Some(bytes)) => { - match serde_json::from_slice::(&bytes) - { - Ok(snapshot) => { - if snapshot.schema_version - > fakecloud_cloudfront::CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION - { - fatal_exit(format_args!( + Ok(Some(bytes)) => match fakecloud_cloudfront::parse_cloudfront_snapshot(&bytes) { + Ok(snapshot) => { + if snapshot.schema_version + > fakecloud_cloudfront::CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION + { + fatal_exit(format_args!( "cloudfront persistence schema too new: on-disk={}, max supported={}", snapshot.schema_version, fakecloud_cloudfront::CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION, )); - } - if let Some(accounts) = snapshot.accounts { - let account_count = accounts.account_count(); - *cloudfront_state.write() = accounts; - tracing::info!( - accounts = account_count, - "loaded cloudfront persistence snapshot" - ); - } } - Err(err) => fatal_exit(format_args!( - "failed to parse cloudfront persistence snapshot: {err}" - )), + if let Some(accounts) = snapshot.accounts { + let account_count = accounts.account_count(); + *cloudfront_state.write() = accounts; + tracing::info!( + accounts = account_count, + "loaded cloudfront persistence snapshot" + ); + } } - } + Err(err) => fatal_exit(format_args!( + "failed to parse cloudfront persistence snapshot: {err}" + )), + }, Ok(None) => { tracing::info!("no cloudfront persistence snapshot found; starting empty"); }