From 72a401e4671cce8df3a57ce9747bb3bb70a54cd6 Mon Sep 17 00:00:00 2001 From: James Price Date: Tue, 8 Sep 2026 11:40:07 +0100 Subject: [PATCH 1/3] fix(cloudfront): serve DefaultRootObject at the distribution root `DefaultRootObject` was parsed onto the model and never read, so a viewer request for the distribution root reached the origin as `/`. Against an S3 origin that returns the bucket's `ListBucketResult` XML instead of the SPA shell. Resolve it alongside the cache behavior and fetch it in place of the root path, preserving the query string. AWS applies the default root object to the distribution root ONLY, so a subdirectory request is never rewritten to `/`; a blank config value is ignored and a stray leading slash is normalized rather than proxied as `//index.html`. --- crates/fakecloud-cloudfront/src/dataplane.rs | 93 +++++++++++++++++-- .../tests/cloudfront_dataplane.rs | 65 +++++++++++++ 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/crates/fakecloud-cloudfront/src/dataplane.rs b/crates/fakecloud-cloudfront/src/dataplane.rs index fa191d47a..622158464 100644 --- a/crates/fakecloud-cloudfront/src/dataplane.rs +++ b/crates/fakecloud-cloudfront/src/dataplane.rs @@ -162,12 +162,20 @@ impl CloudFrontDataPlane { )); }; - let path_and_query = parts - .uri - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or("/") - .to_string(); + // A root request is fetched as the distribution's DefaultRootObject; the + // query string is preserved, as CloudFront does. + let path_and_query = match &route.root_object { + Some(object) => match parts.uri.query() { + Some(q) => format!("{object}?{q}"), + None => object.clone(), + }, + None => parts + .uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/") + .to_string(), + }; let url = format!("{}{path_and_query}", route.upstream.url_base); trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying"); let resp = self @@ -293,6 +301,9 @@ struct RouteResolution { default_upstream: UpstreamTarget, /// CustomErrorResponses that have a response page path. error_rules: Vec, + /// `DefaultRootObject` as an origin path (`/index.html`), set only when this + /// request is for the distribution root and the distribution configures one. + root_object: Option, } /// A resolved origin address: the scheme+authority to connect to and the `Host` @@ -350,10 +361,21 @@ fn resolve_route( .collect() }) .unwrap_or_default(); + // DefaultRootObject: a request for the distribution ROOT is served the named + // object from the origin. AWS applies this to the root only -- a request for a + // subdirectory is never rewritten to `/` -- so the rewrite is + // decided here, from the original viewer path, and the cache behavior is still + // selected on that original path. + let root_object = matches!(path, "" | "/") + .then(|| cfg.default_root_object.as_deref().map(str::trim)) + .flatten() + .filter(|o| !o.is_empty()) + .map(|o| format!("/{}", o.trim_start_matches('/'))); Some(RouteResolution { upstream, default_upstream, error_rules, + root_object, }) } @@ -497,7 +519,9 @@ fn is_hop_by_hop(name: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin}; + use crate::model::{ + AliasItems, Aliases, CustomOriginConfig, DefaultCacheBehavior, Origin, OriginItems, Origins, + }; use crate::state::StoredDistribution; use chrono::Utc; @@ -608,6 +632,61 @@ mod tests { assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com"); } + fn cfg_with_root(root: Option<&str>) -> DistributionConfig { + DistributionConfig { + default_root_object: root.map(Into::into), + origins: Origins { + quantity: 1, + items: Some(OriginItems { + origin: vec![origin("b.s3.us-east-1.amazonaws.com", None)], + }), + }, + default_cache_behavior: DefaultCacheBehavior { + target_origin_id: "o".into(), + ..Default::default() + }, + ..Default::default() + } + } + + fn root_object_for(cfg: &DistributionConfig, path: &str) -> Option { + resolve_route(cfg, path, "127.0.0.1:4566") + .expect("route resolves") + .root_object + } + + #[test] + fn default_root_object_applies_to_the_distribution_root() { + let cfg = cfg_with_root(Some("index.html")); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html")); + assert_eq!(root_object_for(&cfg, "").as_deref(), Some("/index.html")); + } + + #[test] + fn default_root_object_does_not_apply_below_the_root() { + // AWS serves the default root object for the distribution root ONLY; a + // subdirectory request is never rewritten to `/`. + let cfg = cfg_with_root(Some("index.html")); + assert_eq!(root_object_for(&cfg, "/about/"), None); + assert_eq!(root_object_for(&cfg, "/about"), None); + assert_eq!(root_object_for(&cfg, "/index.html"), None); + } + + #[test] + fn default_root_object_unset_or_blank_leaves_the_root_alone() { + for root in [None, Some(""), Some(" ")] { + assert_eq!(root_object_for(&cfg_with_root(root), "/"), None, "{root:?}"); + } + } + + #[test] + fn default_root_object_is_normalized_to_a_single_leading_slash() { + // AWS rejects a leading slash in the config, but accept one defensively + // rather than proxying a `//index.html` path to the origin. + let cfg = cfg_with_root(Some("/index.html")); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html")); + } + #[test] fn https_only_custom_origin_uses_https_and_port() { let up = upstream_for( diff --git a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs index d65d40c1f..1f89afb65 100644 --- a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs +++ b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs @@ -69,6 +69,33 @@ pub async fn make_spa_distribution( .clone() } +/// Create a SPA distribution that also sets `DefaultRootObject`. +pub async fn make_spa_distribution_with_root_object( + cf: &aws_sdk_cloudfront::Client, + default_origin_domain: &str, + root_object: &str, +) -> aws_sdk_cloudfront::types::Distribution { + let mut config = spa_config( + default_origin_domain, + None, + &format!("spa-{}", uuid_like()), + true, + true, + &[], + ); + config.default_root_object = Some(root_object.to_string()); + let create = cf + .create_distribution() + .distribution_config(config) + .send() + .await + .expect("create_distribution"); + create + .distribution() + .expect("distribution returned") + .clone() +} + /// Build the SPA distribution config. Shared by create (enabled=true) and the /// disable-via-update path (enabled=false) so both use an identical shape; the /// `caller_reference` must be preserved across an UpdateDistribution. @@ -644,3 +671,41 @@ async fn api_traffic_is_not_intercepted() { .await .expect("s3 list_buckets must pass through the viewer middleware"); } + +/// Regression: `DefaultRootObject` was stored on the model but never applied, so a +/// viewer request for the distribution root reached the bucket root and returned +/// S3's `ListBucketResult` XML instead of the SPA shell. +#[tokio::test] +async fn serves_default_root_object_at_the_distribution_root() { + let server = TestServer::start().await; + let s3 = server.s3_client().await; + s3.create_bucket() + .bucket("rootsite") + .send() + .await + .expect("create_bucket"); + put_object(&s3, "rootsite", "index.html", "text/html", b"SHELL").await; + put_object(&s3, "rootsite", "nested/index.html", "text/html", b"NESTED").await; + + let cf = server.cloudfront_client().await; + let dist = make_spa_distribution_with_root_object( + &cf, + "rootsite.s3-website-us-east-1.amazonaws.com", + "index.html", + ) + .await; + assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await); + let host = dist.domain_name(); + + // The root serves the default root object, not a bucket listing. + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); + + // A subdirectory is NOT rewritten to `nested/index.html`: AWS applies the + // default root object to the distribution root only. Here the miss falls + // through to the SPA CustomErrorResponse rule instead. + let r = viewer_get(&server, host, "/nested/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); +} From 78d2ea705163825ac802cb236d3a55cb2ad747ed Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 29 Sep 2026 21:08:29 -0300 Subject: [PATCH 2/3] fix(cloudfront): apply OriginPath and send DefaultRootObject verbatim - Append DefaultRootObject after / exactly as configured, as AWS does (a leading slash yields //index.html rather than being normalized), and treat only an empty value as unset; percent-encode it for the origin URL so #, ? and spaces address the key rather than a fragment or query. - Prefix every origin request with the origin's OriginPath, for custom, S3 REST and S3-website origins alike, including the default root object and custom error pages. - Unit tests for root-object resolution, encoding and OriginPath; e2e for query-string roots, UpdateDistribution, and an OriginPath origin. --- crates/fakecloud-cloudfront/src/dataplane.rs | 253 ++++++++++++++++-- .../tests/cloudfront_dataplane.rs | 119 ++++++++ 2 files changed, 348 insertions(+), 24 deletions(-) diff --git a/crates/fakecloud-cloudfront/src/dataplane.rs b/crates/fakecloud-cloudfront/src/dataplane.rs index 622158464..823a4edd3 100644 --- a/crates/fakecloud-cloudfront/src/dataplane.rs +++ b/crates/fakecloud-cloudfront/src/dataplane.rs @@ -301,7 +301,7 @@ struct RouteResolution { default_upstream: UpstreamTarget, /// CustomErrorResponses that have a response page path. error_rules: Vec, - /// `DefaultRootObject` as an origin path (`/index.html`), set only when this + /// `DefaultRootObject` as a percent-encoded path (`/index.html`), set only when this /// request is for the distribution root and the distribution configures one. root_object: Option, } @@ -310,7 +310,8 @@ struct RouteResolution { /// header to send. #[derive(Clone)] struct UpstreamTarget { - /// `scheme://authority` (no trailing slash); the request path is appended. + /// `scheme://authority` plus the origin's `OriginPath` (no trailing slash); + /// the request path is appended. url_base: String, /// `Host` header sent upstream (the origin domain name). host_header: String, @@ -336,13 +337,13 @@ fn resolve_route( .origin .iter() .find(|o| o.id == target) - .map(|o| upstream_for(o, s3_endpoint))?; + .map(|o| origin_target(o, s3_endpoint))?; let default_target = cfg.default_cache_behavior.target_origin_id.as_str(); let default_upstream = items .origin .iter() .find(|o| o.id == default_target) - .map(|o| upstream_for(o, s3_endpoint)) + .map(|o| origin_target(o, s3_endpoint)) .unwrap_or_else(|| upstream.clone()); let error_rules = cfg .custom_error_responses @@ -361,16 +362,7 @@ fn resolve_route( .collect() }) .unwrap_or_default(); - // DefaultRootObject: a request for the distribution ROOT is served the named - // object from the origin. AWS applies this to the root only -- a request for a - // subdirectory is never rewritten to `/` -- so the rewrite is - // decided here, from the original viewer path, and the cache behavior is still - // selected on that original path. - let root_object = matches!(path, "" | "/") - .then(|| cfg.default_root_object.as_deref().map(str::trim)) - .flatten() - .filter(|o| !o.is_empty()) - .map(|o| format!("/{}", o.trim_start_matches('/'))); + let root_object = root_object_path(cfg, path); Some(RouteResolution { upstream, default_upstream, @@ -379,6 +371,96 @@ fn resolve_route( }) } +/// The origin path to fetch in place of the viewer's `path` when it names the +/// distribution root and a `DefaultRootObject` is configured. +/// +/// AWS applies the default root object to the root only: a subdirectory request +/// (`/about/`) is never rewritten to `/`, and `///` is not treated as +/// the root either (the origin makes that comparison). The cache behavior is still +/// selected on the original viewer path: the substitution happens after behavior +/// selection, which is why a viewer-request function on the matched behavior still +/// sees `/` as the URI. +/// +/// An empty value is how the API clears the setting, so it means "none". Any +/// other value is appended verbatim after `/`: AWS does not strip a leading +/// slash, so `/index.html` is requested as `//index.html` (which is why AWS warns +/// that such a value "can lead to a 403 Access Denied error"). +fn root_object_path(cfg: &DistributionConfig, path: &str) -> Option { + // `""` only arises for a request-target with no path at all, which HTTP + // treats as `/`. + if !matches!(path, "" | "/") { + return None; + } + cfg.default_root_object + .as_deref() + .filter(|o| !o.is_empty()) + .map(|o| format!("/{}", encode_path(o))) +} + +/// Resolve `origin` to its upstream and apply its `OriginPath`. +/// +/// CloudFront prefixes every request it sends to an origin (viewer requests, +/// the default root object, custom error pages) with that origin's +/// `OriginPath`: OriginPath `/prod` + viewer `/img/a.png` fetches +/// `/prod/img/a.png`. It is folded into `url_base`, so every path appended to +/// it is prefixed. AWS requires the value to start with `/` and not end with +/// one; since only the leading `/` is structurally required to keep the value +/// out of the URL authority, it is added if missing and trailing slashes are +/// dropped so `/prod/` cannot produce `/prod//img`. +fn origin_target(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget { + let mut target = upstream_for(origin, s3_endpoint); + let prefix = origin + .origin_path + .as_deref() + .map(|p| p.trim_end_matches('/')) + .unwrap_or_default(); + if !prefix.is_empty() { + if !prefix.starts_with('/') { + target.url_base.push('/'); + } + target.url_base.push_str(&encode_path(prefix)); + } + target +} + +/// Percent-encode a configured path (a `DefaultRootObject` or `OriginPath`) for +/// use as a URL path. The value names an object literally, so every byte that +/// is not a valid path-segment character (RFC 3986 `pchar`) is escaped -- +/// including `#`, `?`, space, `%` and non-ASCII -- while `/` is kept so folder +/// values like `app/index.html` still address that folder. +fn encode_path(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for b in raw.bytes() { + let keep = b.is_ascii_alphanumeric() + || matches!( + b, + b'/' | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ); + if keep { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + /// First custom-error rule whose error code matches the origin status. fn match_error_rule(rules: &[ErrorRule], status: u16) -> Option { rules.iter().find(|r| r.error_code == status).cloned() @@ -665,26 +747,149 @@ mod tests { #[test] fn default_root_object_does_not_apply_below_the_root() { // AWS serves the default root object for the distribution root ONLY; a - // subdirectory request is never rewritten to `/`. + // subdirectory request is never rewritten to `/`, and extra + // slashes are left for the origin to interpret. let cfg = cfg_with_root(Some("index.html")); - assert_eq!(root_object_for(&cfg, "/about/"), None); - assert_eq!(root_object_for(&cfg, "/about"), None); - assert_eq!(root_object_for(&cfg, "/index.html"), None); + for path in ["/about/", "/about", "/index.html", "//", "///"] { + assert_eq!(root_object_for(&cfg, path), None, "{path}"); + } } #[test] - fn default_root_object_unset_or_blank_leaves_the_root_alone() { - for root in [None, Some(""), Some(" ")] { + fn default_root_object_unset_or_empty_leaves_the_root_alone() { + for root in [None, Some("")] { assert_eq!(root_object_for(&cfg_with_root(root), "/"), None, "{root:?}"); } } #[test] - fn default_root_object_is_normalized_to_a_single_leading_slash() { - // AWS rejects a leading slash in the config, but accept one defensively - // rather than proxying a `//index.html` path to the origin. + fn default_root_object_path_is_used_as_given() { + // A folder path is fetched from that folder. + let cfg = cfg_with_root(Some("app/index.html")); + assert_eq!( + root_object_for(&cfg, "/").as_deref(), + Some("/app/index.html") + ); + // AWS does not strip a leading slash: the origin is asked for + // `//index.html`, the documented cause of a 403 with such a value. let cfg = cfg_with_root(Some("/index.html")); - assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("/index.html")); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some("//index.html")); + } + + #[test] + fn root_request_still_selects_the_cache_behavior_on_the_viewer_path() { + // A behavior for the object's own path does not capture the root request. + let mut cfg = cfg_with_root(Some("index.html")); + cfg.origins.items.as_mut().unwrap().origin.push(Origin { + id: "other".into(), + ..origin("other.example.com", None) + }); + cfg.cache_behaviors = Some(crate::model::CacheBehaviors { + quantity: 1, + items: Some(crate::model::CacheBehaviorItems { + cache_behavior: vec![crate::model::CacheBehavior { + path_pattern: "index.html".into(), + target_origin_id: "other".into(), + ..Default::default() + }], + }), + }); + let route = resolve_route(&cfg, "/", "127.0.0.1:4566").expect("route resolves"); + assert_eq!(route.root_object.as_deref(), Some("/index.html")); + assert_eq!(route.upstream.host_header, "b.s3.us-east-1.amazonaws.com"); + } + + #[test] + fn default_root_object_is_percent_encoded_for_the_origin_url() { + // The value names an object literally: `#` and `?` must not become a + // fragment or query, and space / `%` / non-ASCII must be escaped. + for (root, want) in [ + ("report#v2.html", "/report%23v2.html"), + ("a?b.html", "/a%3Fb.html"), + ("my page.html", "/my%20page.html"), + ("100%.html", "/100%25.html"), + ("caf\u{e9}.html", "/caf%C3%A9.html"), + ("app/index.html", "/app/index.html"), + ("a-b_c.~!$&'()*+,;=:@.html", "/a-b_c.~!$&'()*+,;=:@.html"), + ] { + let cfg = cfg_with_root(Some(root)); + assert_eq!(root_object_for(&cfg, "/").as_deref(), Some(want), "{root}"); + } + } + + fn cfg_with_origin_path(origin_path: Option<&str>, domain: &str) -> DistributionConfig { + let mut cfg = cfg_with_root(Some("index.html")); + let o = &mut cfg.origins.items.as_mut().unwrap().origin[0]; + o.domain_name = domain.into(); + o.origin_path = origin_path.map(Into::into); + cfg + } + + #[test] + fn origin_path_prefixes_the_upstream_url_for_every_origin_kind() { + for (domain, custom, base) in [ + ( + "site.s3-website-us-east-1.amazonaws.com", + None, + "http://127.0.0.1:4566", + ), + ( + "b.s3.us-east-1.amazonaws.com", + None, + "http://b.s3.us-east-1.amazonaws.com", + ), + ( + "api.example.com", + Some(custom("https-only", 80, 8443)), + "https://api.example.com:8443", + ), + ] { + let mut cfg = cfg_with_origin_path(Some("/prod"), domain); + cfg.origins.items.as_mut().unwrap().origin[0].custom_origin_config = custom; + let route = resolve_route(&cfg, "/img/a.png", "127.0.0.1:4566").unwrap(); + assert_eq!(route.upstream.url_base, format!("{base}/prod"), "{domain}"); + assert_eq!(route.upstream.host_header, domain); + // Custom error pages come from the default origin, prefixed too. + assert_eq!(route.default_upstream.url_base, format!("{base}/prod")); + } + } + + #[test] + fn origin_path_composes_with_the_default_root_object() { + let cfg = cfg_with_origin_path(Some("/prod"), "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/", "127.0.0.1:4566").unwrap(); + let url = format!("{}{}", route.upstream.url_base, route.root_object.unwrap()); + assert_eq!(url, "http://b.s3.us-east-1.amazonaws.com/prod/index.html"); + } + + #[test] + fn origin_path_unset_or_empty_adds_nothing() { + for p in [None, Some(""), Some("/")] { + let cfg = cfg_with_origin_path(p, "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); + assert_eq!( + route.upstream.url_base, "http://b.s3.us-east-1.amazonaws.com", + "{p:?}" + ); + } + } + + #[test] + fn origin_path_is_normalized_and_encoded() { + for (p, want) in [ + ("/prod/", "/prod"), + ("prod", "/prod"), + ("/v 1/a#b", "/v%201/a%23b"), + ("/a/b", "/a/b"), + ] { + let cfg = cfg_with_origin_path(Some(p), "b.s3.us-east-1.amazonaws.com"); + let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); + assert_eq!( + route.upstream.url_base, + format!("http://b.s3.us-east-1.amazonaws.com{want}"), + "{p}" + ); + } } #[test] diff --git a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs index 1f89afb65..83b1ba548 100644 --- a/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs +++ b/crates/fakecloud-e2e/tests/cloudfront_dataplane.rs @@ -702,10 +702,129 @@ async fn serves_default_root_object_at_the_distribution_root() { assert_eq!(r.status(), 200); assert_eq!(r.text().await.unwrap(), "SHELL"); + // The query string does not stop the root from being the root. + let r = viewer_get(&server, host, "/?v=1").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "SHELL"); + // A subdirectory is NOT rewritten to `nested/index.html`: AWS applies the // default root object to the distribution root only. Here the miss falls // through to the SPA CustomErrorResponse rule instead. let r = viewer_get(&server, host, "/nested/").await; assert_eq!(r.status(), 200); assert_eq!(r.text().await.unwrap(), "SHELL"); + + // UpdateDistribution takes effect for the next request: a folder path is + // served from that folder. + let got = cf + .get_distribution_config() + .id(dist.id()) + .send() + .await + .expect("get_distribution_config"); + let etag = got.e_tag().expect("etag").to_string(); + let mut cfg = got.distribution_config().expect("config").clone(); + cfg.default_root_object = Some("nested/index.html".to_string()); + cf.update_distribution() + .id(dist.id()) + .if_match(etag) + .distribution_config(cfg) + .send() + .await + .expect("update_distribution"); + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "NESTED"); +} + +/// Regression: `OriginPath` was stored on the model but never applied, so every +/// request reached the bucket root instead of the configured prefix. CloudFront +/// prefixes each origin request with it, including the default root object and +/// custom error pages. Also covers a `DefaultRootObject` that needs +/// percent-encoding (a raw `#` would otherwise truncate the origin path). +#[tokio::test] +async fn origin_path_prefixes_every_origin_request() { + let server = TestServer::start().await; + let s3 = server.s3_client().await; + s3.create_bucket() + .bucket("prefixed") + .send() + .await + .expect("create_bucket"); + put_object(&s3, "prefixed", "index.html", "text/html", b"UNPREFIXED").await; + put_object(&s3, "prefixed", "prod/index.html", "text/html", b"PROD").await; + put_object(&s3, "prefixed", "prod/img/a.png", "image/png", b"PNG").await; + put_object(&s3, "prefixed", "img/a.png", "image/png", b"ROOTPNG").await; + put_object( + &s3, + "prefixed", + "prod/report#v2.html", + "text/html", + b"REPORT", + ) + .await; + + let cf = server.cloudfront_client().await; + let mut config = spa_config( + "prefixed.s3-website-us-east-1.amazonaws.com", + None, + &format!("prefixed-{}", uuid_like()), + true, + true, + &[], + ); + config.default_root_object = Some("index.html".to_string()); + config + .origins + .as_mut() + .map(|o| &mut o.items) + .expect("origins")[0] + .origin_path = Some("/prod".to_string()); + let dist = cf + .create_distribution() + .distribution_config(config) + .send() + .await + .expect("create_distribution") + .distribution() + .expect("distribution returned") + .clone(); + assert!(wait_for_served(&server, dist.id(), Duration::from_secs(10)).await); + let host = dist.domain_name(); + + // A viewer path is fetched under the origin path. + let r = viewer_get(&server, host, "/img/a.png").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PNG"); + + // The root is the default root object under the origin path. + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PROD"); + + // The custom error page (404 -> /index.html) is fetched under it too. + let r = viewer_get(&server, host, "/missing/route").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "PROD"); + + // A default root object with a `#` addresses that exact key. + let got = cf + .get_distribution_config() + .id(dist.id()) + .send() + .await + .expect("get_distribution_config"); + let etag = got.e_tag().expect("etag").to_string(); + let mut cfg = got.distribution_config().expect("config").clone(); + cfg.default_root_object = Some("report#v2.html".to_string()); + cf.update_distribution() + .id(dist.id()) + .if_match(etag) + .distribution_config(cfg) + .send() + .await + .expect("update_distribution"); + let r = viewer_get(&server, host, "/").await; + assert_eq!(r.status(), 200); + assert_eq!(r.text().await.unwrap(), "REPORT"); } From 8d123eefa9cc9bcbcd14f1dbf62a90cc625704c9 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 29 Sep 2026 22:22:39 -0300 Subject: [PATCH 3/3] test(cloudfront): expect S3 REST origins to resolve locally in OriginPath tests After merging main (which now routes S3 REST origins to this process), the OriginPath unit tests' expected upstream base is the local endpoint. --- crates/fakecloud-cloudfront/src/dataplane.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/fakecloud-cloudfront/src/dataplane.rs b/crates/fakecloud-cloudfront/src/dataplane.rs index d940f598f..32d09e5eb 100644 --- a/crates/fakecloud-cloudfront/src/dataplane.rs +++ b/crates/fakecloud-cloudfront/src/dataplane.rs @@ -860,7 +860,7 @@ mod tests { ( "b.s3.us-east-1.amazonaws.com", None, - "http://b.s3.us-east-1.amazonaws.com", + "http://127.0.0.1:4566", ), ( "api.example.com", @@ -883,7 +883,7 @@ mod tests { let cfg = cfg_with_origin_path(Some("/prod"), "b.s3.us-east-1.amazonaws.com"); let route = resolve_route(&cfg, "/", "127.0.0.1:4566").unwrap(); let url = format!("{}{}", route.upstream.url_base, route.root_object.unwrap()); - assert_eq!(url, "http://b.s3.us-east-1.amazonaws.com/prod/index.html"); + assert_eq!(url, "http://127.0.0.1:4566/prod/index.html"); } #[test] @@ -891,10 +891,7 @@ mod tests { for p in [None, Some(""), Some("/")] { let cfg = cfg_with_origin_path(p, "b.s3.us-east-1.amazonaws.com"); let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); - assert_eq!( - route.upstream.url_base, "http://b.s3.us-east-1.amazonaws.com", - "{p:?}" - ); + assert_eq!(route.upstream.url_base, "http://127.0.0.1:4566", "{p:?}"); } } @@ -910,7 +907,7 @@ mod tests { let route = resolve_route(&cfg, "/x", "127.0.0.1:4566").unwrap(); assert_eq!( route.upstream.url_base, - format!("http://b.s3.us-east-1.amazonaws.com{want}"), + format!("http://127.0.0.1:4566{want}"), "{p}" ); }