Skip to main content

fakecloud_cloudfront/
dataplane.rs

1// crates/fakecloud-cloudfront/src/dataplane.rs
2//! In-process CloudFront data plane.
3//!
4//! Distributions are served on fakecloud's **main `--addr` listener**, routed by
5//! the request `Host` header, rather than on a per-distribution ephemeral port.
6//! [`CloudFrontDataPlane::serve`] is installed as an outer middleware on the main
7//! axum router: it matches the `Host` header against every enabled distribution's
8//! `DomainName` (`<id>.cloudfront.net`) or one of its alternate domain names
9//! (`Aliases`/CNAMEs). A match is served as viewer traffic; anything else (the AWS
10//! API, `/_fakecloud/*`, health) is handed straight back for normal dispatch.
11//!
12//! This is how real CloudFront works -- a distribution is reached by its domain,
13//! not a port -- and it means a distribution is reachable from outside a container
14//! whenever the main port is published (`-p`), with no second listener to expose.
15//! Clients discover which distributions are served, and the domain to send as
16//! `Host`, via `/_fakecloud/cloudfront/distributions`.
17//!
18//! Once a request is matched to a distribution, [`serve`](CloudFrontDataPlane::serve)
19//! selects a cache behavior by path pattern, resolves its origin, reverse-proxies
20//! to it, and applies CustomErrorResponses (e.g. the SPA `404 -> /index.html`
21//! served as `200`). There is no global edge network -- this is a single local
22//! origin-serving node, matching the ALB/API Gateway precedent. Deferred (not
23//! implemented): in-path CloudFront Functions / Lambda@Edge, TTL caching /
24//! invalidation, and OAC/SigV4 to private S3.
25
26use std::time::Duration;
27
28use axum::body::Body;
29use axum::extract::Request;
30use axum::response::Response;
31use bytes::Bytes;
32use http::{header, HeaderMap, Method, StatusCode};
33use tracing::{trace, warn};
34
35use crate::model::DistributionConfig;
36use crate::state::{CloudFrontAccounts, SharedCloudFrontState, StoredDistribution};
37
38const ENV_DISABLE: &str = "FAKECLOUD_CLOUDFRONT_DISABLE_DATAPLANE";
39
40/// Whether the data plane should serve viewer traffic. Disabled by setting
41/// `FAKECLOUD_CLOUDFRONT_DISABLE_DATAPLANE` to a truthy value (mirrors the ELBv2
42/// flag), for environments that only exercise the control plane. Also drives the
43/// `served` flag surfaced via `/_fakecloud/cloudfront/distributions`.
44pub fn dataplane_enabled() -> bool {
45    !matches!(
46        std::env::var(ENV_DISABLE).as_deref(),
47        Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
48    )
49}
50
51/// The CloudFront data plane: serves enabled distributions on the main listener,
52/// routed by `Host`. Constructed once at server startup and installed as an outer
53/// middleware; see [`CloudFrontDataPlane::serve`].
54pub struct CloudFrontDataPlane {
55    state: SharedCloudFrontState,
56    /// HTTP client used to fetch from origins (reverse-proxy).
57    upstream: reqwest::Client,
58    /// `host:port` of fakecloud's own server. An S3-website origin is served by
59    /// this same process on the main port, so those origins are reached here with
60    /// the website domain preserved in the `Host` header (real CloudFront likewise
61    /// treats an S3-website endpoint as an HTTP custom origin).
62    s3_endpoint: String,
63    /// Cached `dataplane_enabled()` at construction: when false, `serve` never
64    /// intercepts and every request falls through to normal AWS dispatch.
65    enabled: bool,
66}
67
68impl CloudFrontDataPlane {
69    /// Build the data plane. `server_port` is fakecloud's own listen port, used to
70    /// reach S3-website origins served by this same process. Returns an `Arc` for
71    /// sharing into the axum middleware layer. Cheap and infallible; if the tuned
72    /// reqwest client fails to build (should not happen), the plane declines to
73    /// serve (`enabled = false`) so requests still dispatch normally rather than
74    /// being proxied through a degraded client.
75    pub fn new(state: SharedCloudFrontState, server_port: u16) -> std::sync::Arc<Self> {
76        let mut enabled = dataplane_enabled();
77        let upstream = reqwest::Client::builder()
78            .danger_accept_invalid_certs(true)
79            .redirect(reqwest::redirect::Policy::none())
80            .timeout(Duration::from_secs(30))
81            .build()
82            .unwrap_or_else(|e| {
83                warn!(
84                    "CloudFront data plane: failed to build reqwest client: {e}; serving disabled"
85                );
86                // Decline to serve: a default client lacks the invalid-cert /
87                // no-redirect / timeout behavior the data plane relies on, so
88                // proxying through it would silently misbehave.
89                enabled = false;
90                reqwest::Client::new()
91            });
92        std::sync::Arc::new(Self {
93            state,
94            upstream,
95            s3_endpoint: format!("127.0.0.1:{server_port}"),
96            enabled,
97        })
98    }
99
100    /// Serve a request iff its `Host` matches an enabled distribution.
101    ///
102    /// - `Ok(resp)`  -- the request was viewer traffic for a distribution and was
103    ///   proxied to the resolved origin (the body has been consumed).
104    /// - `Err(req)`  -- the `Host` matches no distribution (or the plane is
105    ///   disabled); the request is returned untouched for normal AWS dispatch.
106    ///
107    /// The `Host` check happens on the request headers before the body is touched,
108    /// so pass-through traffic (all AWS API calls, `/_fakecloud/*`) is never
109    /// buffered.
110    pub async fn serve(&self, req: Request<Body>) -> Result<Response, Request<Body>> {
111        if !self.enabled {
112            return Err(req);
113        }
114        // Prefer the `Host` header (HTTP/1.1); fall back to the URI authority so
115        // HTTP/2 viewer requests (which carry the domain in `:authority` and may
116        // omit `Host`) still route to a distribution.
117        let host = req
118            .headers()
119            .get(header::HOST)
120            .and_then(|v| v.to_str().ok())
121            .map(|s| s.to_string())
122            .or_else(|| req.uri().host().map(|h| h.to_string()));
123        let Some(host) = host else {
124            return Err(req);
125        };
126
127        // Resolve the route under the read lock (owned snapshot so the guard drops
128        // at the end of the block). The outer `Option` distinguishes "no
129        // distribution serves this Host" (fall through) from "a distribution
130        // matched but has no usable origin" (serve a 502 -- it IS our traffic).
131        let matched: Option<Option<RouteResolution>> = {
132            let accs = self.state.read();
133            find_distribution_by_host(&accs, &host)
134                .map(|d| resolve_route(&d.config, req.uri().path(), &self.s3_endpoint))
135        };
136        let Some(route_opt) = matched else {
137            return Err(req);
138        };
139
140        // From here the request belongs to CloudFront: consume it and proxy.
141        let (parts, body) = req.into_parts();
142        // Apply the SAME buffered-body cap as direct (non-viewer) traffic
143        // (`FAKECLOUD_MAX_REQUEST_BODY_BYTES`, default 1 GiB) so a request isn't
144        // rejected merely because it went through a distribution.
145        let max_body = fakecloud_core::dispatch::max_request_body_bytes();
146        let body_bytes = match axum::body::to_bytes(body, max_body).await {
147            Ok(b) => b,
148            Err(_) => {
149                return Ok(canned(
150                    StatusCode::PAYLOAD_TOO_LARGE,
151                    "viewer request body too large",
152                ))
153            }
154        };
155        let Some(route) = route_opt else {
156            return Ok(canned(
157                StatusCode::BAD_GATEWAY,
158                "distribution has no matching origin",
159            ));
160        };
161
162        let path_and_query = parts
163            .uri
164            .path_and_query()
165            .map(|p| p.as_str())
166            .unwrap_or("/")
167            .to_string();
168        let url = format!("{}{path_and_query}", route.upstream.url_base);
169        trace!(%host, path = %parts.uri.path(), origin = %route.upstream.host_header, "CloudFront data plane: proxying");
170        let resp = self
171            .fetch_origin(
172                &parts.method,
173                &url,
174                &route.upstream.host_header,
175                &parts.headers,
176                &body_bytes,
177            )
178            .await;
179
180        // CustomErrorResponses: if the origin status matches a configured rule with
181        // a response page path, serve that page from the DEFAULT origin and return
182        // it with the rule's response code (the SPA deep-link fallback, e.g.
183        // 404 -> /index.html returned as 200).
184        if let Some(rule) = match_error_rule(&route.error_rules, resp.status().as_u16()) {
185            let origin_status = resp.status();
186            let url = format!("{}{}", route.default_upstream.url_base, rule.page_path);
187            let err_resp = self
188                .fetch_origin(
189                    &Method::GET,
190                    &url,
191                    &route.default_upstream.host_header,
192                    &HeaderMap::new(),
193                    &Bytes::new(),
194                )
195                .await;
196            // Only interpose the custom error page when the fallback fetch itself
197            // succeeded. If fetching the page failed (e.g. the default origin is
198            // down, or the page path 404s), returning it under the rule's success
199            // ResponseCode would mask an error body with a 200; keep the ORIGINAL
200            // origin response instead.
201            if err_resp.status().is_success() {
202                let mut err_resp = err_resp;
203                // Status = the rule's ResponseCode if set, else the ORIGINAL origin
204                // error status (AWS: an omitted ResponseCode keeps the origin's code).
205                let final_status = rule
206                    .response_code
207                    .and_then(|c| StatusCode::from_u16(c).ok())
208                    .unwrap_or(origin_status);
209                *err_resp.status_mut() = final_status;
210                return Ok(err_resp);
211            }
212            return Ok(resp);
213        }
214        Ok(resp)
215    }
216
217    /// Reverse-proxy the request to the resolved origin and copy the response back.
218    async fn fetch_origin(
219        &self,
220        method: &Method,
221        url: &str,
222        host_header: &str,
223        req_headers: &HeaderMap,
224        body: &Bytes,
225    ) -> Response {
226        let mut rb = self.upstream.request(reqwest_method(method), url);
227        for (k, v) in req_headers.iter() {
228            let n = k.as_str();
229            if is_hop_by_hop(n) || n.eq_ignore_ascii_case("host") {
230                continue;
231            }
232            rb = rb.header(k.as_str(), v.as_bytes());
233        }
234        rb = rb.header("host", host_header);
235        if !body.is_empty() {
236            rb = rb.body(body.to_vec());
237        }
238        match rb.send().await {
239            Ok(up) => {
240                let status = up.status();
241                let headers = up.headers().clone();
242                let bytes = up.bytes().await.unwrap_or_default();
243                let mut builder = Response::builder().status(status);
244                for (k, v) in headers.iter() {
245                    if !is_hop_by_hop(k.as_str()) {
246                        builder = builder.header(k, v);
247                    }
248                }
249                builder
250                    .body(Body::from(bytes))
251                    .unwrap_or_else(|_| canned(StatusCode::BAD_GATEWAY, "invalid origin response"))
252            }
253            Err(e) => canned(StatusCode::BAD_GATEWAY, &format!("origin error: {e}")),
254        }
255    }
256}
257
258/// Find the enabled distribution whose `DomainName` (`<id>.cloudfront.net`) or one
259/// of its alternate domain names (`Aliases`/CNAMEs) matches `host`. The port is
260/// stripped and matching is case-insensitive. Alternate domain names are exact in
261/// CloudFront (not wildcards), so this is an exact host compare, mirroring the
262/// route53 CloudFront resolver.
263pub(crate) fn find_distribution_by_host<'a>(
264    accs: &'a CloudFrontAccounts,
265    host: &str,
266) -> Option<&'a StoredDistribution> {
267    let host = host.split(':').next().unwrap_or(host).trim();
268    if host.is_empty() {
269        return None;
270    }
271    accs.all_distributions()
272        .map(|(_, d)| d)
273        .filter(|d| d.config.enabled)
274        .find(|d| {
275            d.domain_name.eq_ignore_ascii_case(host)
276                || d.config
277                    .aliases
278                    .as_ref()
279                    .and_then(|a| a.items.as_ref())
280                    .is_some_and(|it| it.cname.iter().any(|c| c.eq_ignore_ascii_case(host)))
281        })
282}
283
284/// Owned per-request routing snapshot (taken under the state read lock).
285struct RouteResolution {
286    /// Resolved upstream for the matched cache behavior.
287    upstream: UpstreamTarget,
288    /// Resolved upstream for the default cache behavior (where
289    /// CustomErrorResponse pages are fetched from).
290    default_upstream: UpstreamTarget,
291    /// CustomErrorResponses that have a response page path.
292    error_rules: Vec<ErrorRule>,
293}
294
295/// A resolved origin address: the scheme+authority to connect to and the `Host`
296/// header to send.
297#[derive(Clone)]
298struct UpstreamTarget {
299    /// `scheme://authority` (no trailing slash); the request path is appended.
300    url_base: String,
301    /// `Host` header sent upstream (the origin domain name).
302    host_header: String,
303}
304
305#[derive(Clone)]
306struct ErrorRule {
307    error_code: u16,
308    page_path: String,
309    response_code: Option<u16>,
310}
311
312/// Resolve the matched origin, the default origin, and the custom-error rules
313/// for a request path.
314fn resolve_route(
315    cfg: &DistributionConfig,
316    path: &str,
317    s3_endpoint: &str,
318) -> Option<RouteResolution> {
319    let items = cfg.origins.items.as_ref()?;
320    let target = select_target_origin(cfg, path);
321    let upstream = items
322        .origin
323        .iter()
324        .find(|o| o.id == target)
325        .map(|o| upstream_for(o, s3_endpoint))?;
326    let default_target = cfg.default_cache_behavior.target_origin_id.as_str();
327    let default_upstream = items
328        .origin
329        .iter()
330        .find(|o| o.id == default_target)
331        .map(|o| upstream_for(o, s3_endpoint))
332        .unwrap_or_else(|| upstream.clone());
333    let error_rules = cfg
334        .custom_error_responses
335        .as_ref()
336        .and_then(|c| c.items.as_ref())
337        .map(|it| {
338            it.custom_error_response
339                .iter()
340                .filter_map(|r| {
341                    r.response_page_path.as_ref().map(|p| ErrorRule {
342                        error_code: r.error_code as u16,
343                        page_path: p.clone(),
344                        response_code: r.response_code.as_ref().and_then(|s| s.parse().ok()),
345                    })
346                })
347                .collect()
348        })
349        .unwrap_or_default();
350    Some(RouteResolution {
351        upstream,
352        default_upstream,
353        error_rules,
354    })
355}
356
357/// First custom-error rule whose error code matches the origin status.
358fn match_error_rule(rules: &[ErrorRule], status: u16) -> Option<ErrorRule> {
359    rules.iter().find(|r| r.error_code == status).cloned()
360}
361
362fn select_target_origin<'a>(cfg: &'a DistributionConfig, path: &str) -> &'a str {
363    if let Some(cbs) = &cfg.cache_behaviors {
364        if let Some(items) = &cbs.items {
365            for cb in &items.cache_behavior {
366                if path_pattern_matches(&cb.path_pattern, path) {
367                    return &cb.target_origin_id;
368                }
369            }
370        }
371    }
372    &cfg.default_cache_behavior.target_origin_id
373}
374
375/// An S3 static-website endpoint (`bucket.s3-website-<region>.amazonaws.com` or
376/// `bucket.s3-website.<region>.amazonaws.com`). Matched precisely (`.s3-website`
377/// label plus the `.amazonaws.com` suffix) so a custom origin that merely
378/// contains the substring — e.g. `my.s3-website.example.com` — is NOT rerouted
379/// to the local fakecloud port.
380fn is_s3_website(domain: &str) -> bool {
381    domain.contains(".s3-website") && domain.ends_with(".amazonaws.com")
382}
383
384/// Resolve an [`crate::model::Origin`] to the upstream to connect to.
385///
386/// - S3-website origins are served by this same fakecloud process, so connect to
387///   its own port while preserving the website domain in `Host`.
388/// - Custom origins honor `CustomOriginConfig`: an `https-only` protocol policy
389///   is fetched over HTTPS (else HTTP), and the configured `HTTPPort`/`HTTPSPort`
390///   is appended UNLESS the `domain_name` already carries an explicit `:port`
391///   (as local test origins do) or the port is the scheme default.
392/// - Bare origins (no config) are reached over HTTP at their domain verbatim.
393fn upstream_for(origin: &crate::model::Origin, s3_endpoint: &str) -> UpstreamTarget {
394    let domain = &origin.domain_name;
395    if is_s3_website(domain) {
396        return UpstreamTarget {
397            url_base: format!("http://{s3_endpoint}"),
398            host_header: domain.clone(),
399        };
400    }
401    if let Some(cfg) = &origin.custom_origin_config {
402        let https = cfg
403            .origin_protocol_policy
404            .eq_ignore_ascii_case("https-only");
405        let (scheme, port) = if https {
406            ("https", cfg.https_port)
407        } else {
408            ("http", cfg.http_port)
409        };
410        // A domain that already encodes a port (host:port, as local origins do)
411        // wins over the config port; otherwise append a non-default port.
412        let has_explicit_port = domain.rsplit(':').next().is_some_and(|s| {
413            !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && domain.contains(':')
414        });
415        let default_port = (scheme == "http" && port == 80) || (scheme == "https" && port == 443);
416        let authority = if has_explicit_port || port <= 0 || default_port {
417            domain.clone()
418        } else {
419            format!("{domain}:{port}")
420        };
421        return UpstreamTarget {
422            url_base: format!("{scheme}://{authority}"),
423            host_header: domain.clone(),
424        };
425    }
426    UpstreamTarget {
427        url_base: format!("http://{domain}"),
428        host_header: domain.clone(),
429    }
430}
431
432/// Match a CloudFront cache-behavior path pattern (`*` = any sequence, `?` = one
433/// character) against a request path. AWS path patterns are relative (no leading
434/// slash, e.g. `api/*`); normalize both sides so a canonical `api/*` and a
435/// slash-prefixed `/api/*` both match a request path like `/api/orders`.
436fn path_pattern_matches(pattern: &str, path: &str) -> bool {
437    let pat = pattern.trim_start_matches('/');
438    let p = path.trim_start_matches('/');
439    glob_match(pat.as_bytes(), p.as_bytes())
440}
441
442fn glob_match(pat: &[u8], text: &[u8]) -> bool {
443    // Iterative glob with backtracking on `*`.
444    let (mut p, mut t) = (0usize, 0usize);
445    let (mut star, mut mark) = (None, 0usize);
446    while t < text.len() {
447        if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
448            p += 1;
449            t += 1;
450        } else if p < pat.len() && pat[p] == b'*' {
451            star = Some(p);
452            mark = t;
453            p += 1;
454        } else if let Some(sp) = star {
455            p = sp + 1;
456            mark += 1;
457            t = mark;
458        } else {
459            return false;
460        }
461    }
462    while p < pat.len() && pat[p] == b'*' {
463        p += 1;
464    }
465    p == pat.len()
466}
467
468fn canned(status: StatusCode, msg: &str) -> Response {
469    Response::builder()
470        .status(status)
471        .body(Body::from(msg.to_string()))
472        .expect("canned response builds")
473}
474
475fn reqwest_method(m: &Method) -> reqwest::Method {
476    reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
477}
478
479const HOP_BY_HOP: &[&str] = &[
480    "connection",
481    "keep-alive",
482    "proxy-authenticate",
483    "proxy-authorization",
484    "te",
485    "trailer",
486    "transfer-encoding",
487    "upgrade",
488];
489
490fn is_hop_by_hop(name: &str) -> bool {
491    HOP_BY_HOP.iter().any(|&h| h.eq_ignore_ascii_case(name))
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::model::{AliasItems, Aliases, CustomOriginConfig, Origin};
498    use crate::state::StoredDistribution;
499    use chrono::Utc;
500
501    fn origin(domain: &str, custom: Option<CustomOriginConfig>) -> Origin {
502        Origin {
503            id: "o".into(),
504            domain_name: domain.into(),
505            custom_origin_config: custom,
506            ..Default::default()
507        }
508    }
509
510    fn custom(policy: &str, http_port: i32, https_port: i32) -> CustomOriginConfig {
511        CustomOriginConfig {
512            http_port,
513            https_port,
514            origin_protocol_policy: policy.into(),
515            ..Default::default()
516        }
517    }
518
519    fn dist(id: &str, enabled: bool, aliases: &[&str]) -> StoredDistribution {
520        let mut config = DistributionConfig {
521            enabled,
522            ..Default::default()
523        };
524        if !aliases.is_empty() {
525            config.aliases = Some(Aliases {
526                quantity: aliases.len() as i32,
527                items: Some(AliasItems {
528                    cname: aliases.iter().map(|s| s.to_string()).collect(),
529                }),
530            });
531        }
532        StoredDistribution {
533            id: id.to_string(),
534            arn: format!("arn:aws:cloudfront::123456789012:distribution/{id}"),
535            status: "Deployed".into(),
536            last_modified_time: Utc::now(),
537            domain_name: format!("{}.cloudfront.net", id.to_lowercase()),
538            in_progress_invalidation_batches: 0,
539            etag: "E1".into(),
540            config,
541        }
542    }
543
544    fn accounts_with(dists: Vec<StoredDistribution>) -> CloudFrontAccounts {
545        let mut accs = CloudFrontAccounts::new();
546        let acct = accs.entry("123456789012");
547        for d in dists {
548            acct.distributions.insert(d.id.clone(), d);
549        }
550        accs
551    }
552
553    #[test]
554    fn find_by_domain_name() {
555        let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
556        let found = find_distribution_by_host(&accs, "e1abc.cloudfront.net").unwrap();
557        assert_eq!(found.id, "E1ABC");
558    }
559
560    #[test]
561    fn find_strips_port_and_is_case_insensitive() {
562        let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
563        assert!(find_distribution_by_host(&accs, "E1ABC.CloudFront.net:4566").is_some());
564    }
565
566    #[test]
567    fn find_by_alias_cname() {
568        let accs = accounts_with(vec![dist("E1ABC", true, &["cdn.example.com"])]);
569        let found = find_distribution_by_host(&accs, "cdn.example.com").unwrap();
570        assert_eq!(found.id, "E1ABC");
571    }
572
573    #[test]
574    fn disabled_distribution_is_not_matched() {
575        let accs = accounts_with(vec![dist("E1ABC", false, &["cdn.example.com"])]);
576        assert!(find_distribution_by_host(&accs, "e1abc.cloudfront.net").is_none());
577        assert!(find_distribution_by_host(&accs, "cdn.example.com").is_none());
578    }
579
580    #[test]
581    fn unknown_host_and_empty_host_return_none() {
582        let accs = accounts_with(vec![dist("E1ABC", true, &[])]);
583        assert!(find_distribution_by_host(&accs, "s3.amazonaws.com").is_none());
584        assert!(find_distribution_by_host(&accs, "").is_none());
585        assert!(find_distribution_by_host(&accs, ":4566").is_none());
586    }
587
588    #[test]
589    fn s3_website_detection_is_precise() {
590        assert!(is_s3_website("b.s3-website-us-east-1.amazonaws.com"));
591        assert!(is_s3_website("b.s3-website.us-east-1.amazonaws.com"));
592        // A custom origin that merely contains the substring must NOT match.
593        assert!(!is_s3_website("my.s3-website.example.com"));
594        assert!(!is_s3_website("api.example.com"));
595        assert!(!is_s3_website("127.0.0.1:8080"));
596    }
597
598    #[test]
599    fn s3_website_origin_routes_to_local_port() {
600        let up = upstream_for(
601            &origin("b.s3-website-us-east-1.amazonaws.com", None),
602            "127.0.0.1:4566",
603        );
604        assert_eq!(up.url_base, "http://127.0.0.1:4566");
605        assert_eq!(up.host_header, "b.s3-website-us-east-1.amazonaws.com");
606    }
607
608    #[test]
609    fn https_only_custom_origin_uses_https_and_port() {
610        let up = upstream_for(
611            &origin("api.example.com", Some(custom("https-only", 80, 8443))),
612            "127.0.0.1:4566",
613        );
614        assert_eq!(up.url_base, "https://api.example.com:8443");
615    }
616
617    #[test]
618    fn http_custom_origin_default_port_omits_port() {
619        let up = upstream_for(
620            &origin("api.example.com", Some(custom("http-only", 80, 443))),
621            "127.0.0.1:4566",
622        );
623        assert_eq!(up.url_base, "http://api.example.com");
624    }
625
626    #[test]
627    fn explicit_port_in_domain_wins_over_config_port() {
628        // Local origins encode the port in the domain; the config port (80) must
629        // not be appended on top of it.
630        let up = upstream_for(
631            &origin("127.0.0.1:52111", Some(custom("http-only", 80, 443))),
632            "127.0.0.1:4566",
633        );
634        assert_eq!(up.url_base, "http://127.0.0.1:52111");
635    }
636
637    #[test]
638    fn bare_origin_defaults_to_http() {
639        let up = upstream_for(&origin("origin.internal", None), "127.0.0.1:4566");
640        assert_eq!(up.url_base, "http://origin.internal");
641    }
642}