Skip to main content

churust_cors/
lib.rs

1//! Cross-Origin Resource Sharing (CORS) plugin for the [Churust] web framework.
2//!
3//! This crate provides a [`Cors`] plugin that intercepts every incoming HTTP
4//! request and attaches the appropriate `Access-Control-*` response headers.
5//! Preflight `OPTIONS` requests are short-circuited with an HTTP 204 response
6//! so they never reach your route handlers.
7//!
8//! # Quick start
9//!
10//! Install the plugin via [`churust_core::Churust::server`] before calling
11//! `.build()`.  Use [`Cors::permissive`] for development or use [`Cors::new`]
12//! to build a precise policy for production.
13//!
14//! ```
15//! use churust_core::{Churust, Call, TestClient};
16//! use churust_cors::Cors;
17//!
18//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
19//! let app = Churust::server()
20//!     .install(Cors::allow_any_origin_insecure())
21//!     .routing(|r| {
22//!         r.get("/api/data", |_c: Call| async { "hello" });
23//!     })
24//!     .build();
25//!
26//! // Actual cross-origin GET: response carries the CORS header.
27//! let res = TestClient::new(app)
28//!     .get("/api/data")
29//!     .header("origin", "https://example.com")
30//!     .send()
31//!     .await;
32//!
33//! assert_eq!(res.status().as_u16(), 200);
34//! assert_eq!(res.header("access-control-allow-origin"), Some("*"));
35//! # });
36//! ```
37//!
38//! [Churust]: churust_core::Churust
39
40#![deny(missing_docs)]
41
42use async_trait::async_trait;
43use churust_core::{AppBuilder, Call, Middleware, Next, Phase, Plugin, Response};
44use http::header::{
45    ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
46    ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_MAX_AGE, ACCESS_CONTROL_REQUEST_METHOD,
47};
48use http::{HeaderValue, Method, StatusCode};
49use std::sync::Arc;
50
51/// Which origins are allowed.
52#[derive(Debug, Clone)]
53enum AllowOrigin {
54    Any,
55    List(Vec<String>),
56}
57
58/// CORS configuration and plugin entry point.
59///
60/// `Cors` holds the policy that governs which origins, HTTP methods, and
61/// request headers are permitted for cross-origin requests.  It implements
62/// [`Plugin`], so you pass it directly to [`AppBuilder::install`] — the plugin
63/// system registers a [`Middleware`] that runs on every request.
64///
65/// # Choosing a constructor
66///
67/// | Situation | Constructor |
68/// |-----------|-------------|
69/// | Local development, all origins OK | [`Cors::permissive`] |
70/// | Staging / production, specific origins | [`Cors::new`] + builder methods |
71///
72/// # Example
73///
74/// ```
75/// use churust_core::{Churust, Call, TestClient};
76/// use churust_cors::Cors;
77/// use http::Method;
78///
79/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
80/// let app = Churust::server()
81///     .install(
82///         Cors::new()
83///             .allow_origin("https://app.example.com")
84///             .allow_methods(vec![Method::GET, Method::POST])
85///             .allow_headers(vec!["Content-Type".into(), "Authorization".into()])
86///             .allow_credentials(true)
87///             .max_age(3600),
88///     )
89///     .routing(|r| {
90///         r.get("/", |_c: Call| async { "ok" });
91///     })
92///     .build();
93///
94/// let res = TestClient::new(app)
95///     .get("/")
96///     .header("origin", "https://app.example.com")
97///     .send()
98///     .await;
99///
100/// assert_eq!(res.status().as_u16(), 200);
101/// assert_eq!(
102///     res.header("access-control-allow-origin"),
103///     Some("https://app.example.com")
104/// );
105/// # });
106/// ```
107#[derive(Debug, Clone)]
108pub struct Cors {
109    origin: AllowOrigin,
110    methods: Vec<Method>,
111    headers: Vec<String>,
112    credentials: bool,
113    max_age: Option<u64>,
114}
115
116impl Cors {
117    /// Creates a permissive CORS policy suitable for development and public APIs.
118    ///
119    /// The policy allows **any** origin (`*`), the six most common HTTP methods
120    /// (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`), any request header
121    /// (`*`), and caches the preflight response for 24 hours (86 400 seconds).
122    ///
123    /// > **Note:** Per the CORS specification, `Access-Control-Allow-Origin: *`
124    /// > cannot be combined with `Access-Control-Allow-Credentials: true`.
125    /// > Therefore `allow_any_origin_insecure()` intentionally leaves credentials **disabled**.
126    /// > If your application needs cookies or HTTP authentication on cross-origin
127    /// > requests, use [`Cors::new`] with an explicit origin list and call
128    /// > [`.allow_credentials(true)`](Cors::allow_credentials).
129    ///
130    /// # Example
131    ///
132    /// ```
133    /// use churust_core::{Churust, Call, TestClient};
134    /// use churust_cors::Cors;
135    ///
136    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
137    /// let app = Churust::server()
138    ///     .install(Cors::allow_any_origin_insecure())
139    ///     .routing(|r| {
140    ///         r.get("/", |_c: Call| async { "hello" });
141    ///     })
142    ///     .build();
143    ///
144    /// let res = TestClient::new(app)
145    ///     .get("/")
146    ///     .header("origin", "https://any-origin.example")
147    ///     .send()
148    ///     .await;
149    ///
150    /// assert_eq!(res.header("access-control-allow-origin"), Some("*"));
151    /// # });
152    /// ```
153    #[deprecated(
154        since = "0.3.0",
155        note = "renamed to `allow_any_origin_insecure`, which says what it does. \
156                This reflects every origin, so any site can read authenticated \
157                responses from your API when credentials are not required."
158    )]
159    pub fn permissive() -> Self {
160        Self::allow_any_origin_insecure()
161    }
162
163    /// Allow **every** origin, method and header.
164    ///
165    /// The name is deliberately uncomfortable. This reflects any origin, so if
166    /// your API answers requests based on an ambient credential — a cookie, a
167    /// bearer token a browser extension replays, an IP allowlist — any website
168    /// a user visits can read those responses.
169    ///
170    /// It is genuinely fine for a public, unauthenticated, read-only API, and
171    /// convenient in local development. It is not a default.
172    pub fn allow_any_origin_insecure() -> Self {
173        Self {
174            origin: AllowOrigin::Any,
175            methods: vec![
176                Method::GET,
177                Method::POST,
178                Method::PUT,
179                Method::DELETE,
180                Method::PATCH,
181                Method::OPTIONS,
182            ],
183            headers: vec!["*".to_string()],
184            credentials: false,
185            max_age: Some(86_400),
186        }
187    }
188
189    /// Creates a restrictive CORS policy with safe defaults.
190    ///
191    /// The initial policy allows **no origins**, permits only `GET` and `POST`,
192    /// exposes no extra headers, disables credentials, and sets no `max-age`.
193    /// Use the builder methods to refine the policy before passing it to
194    /// [`AppBuilder::install`].
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use churust_core::{Churust, Call, TestClient};
200    /// use churust_cors::Cors;
201    ///
202    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
203    /// // Without adding any allowed origins, cross-origin requests receive
204    /// // no CORS headers and the browser will block the response.
205    /// let app = Churust::server()
206    ///     .install(Cors::new().allow_origin("https://trusted.example.com"))
207    ///     .routing(|r| {
208    ///         r.get("/", |_c: Call| async { "ok" });
209    ///     })
210    ///     .build();
211    ///
212    /// // Unlisted origin → no CORS header.
213    /// let res = TestClient::new(app)
214    ///     .get("/")
215    ///     .header("origin", "https://untrusted.example.com")
216    ///     .send()
217    ///     .await;
218    ///
219    /// assert_eq!(res.header("access-control-allow-origin"), None);
220    /// # });
221    /// ```
222    pub fn new() -> Self {
223        Self {
224            origin: AllowOrigin::List(Vec::new()),
225            methods: vec![Method::GET, Method::POST],
226            headers: Vec::new(),
227            credentials: false,
228            max_age: None,
229        }
230    }
231
232    /// Adds a single origin that is allowed to make cross-origin requests.
233    ///
234    /// Call this method multiple times to whitelist several origins.  The value
235    /// should be a fully-qualified origin string such as
236    /// `"https://app.example.com"` (scheme + host + optional port, **no**
237    /// trailing slash).
238    ///
239    /// If the policy was previously set to [`Cors::permissive`] (wildcard
240    /// origin), calling `allow_origin` switches back to an explicit list
241    /// containing only the supplied origin.
242    ///
243    /// # Parameters
244    ///
245    /// - `origin` — any type that converts to [`String`], e.g. `&str` or
246    ///   `String`.  The value is compared verbatim against the `Origin` request
247    ///   header.
248    ///
249    /// # Example
250    ///
251    /// ```
252    /// use churust_core::{Churust, Call, TestClient};
253    /// use churust_cors::Cors;
254    ///
255    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
256    /// let app = Churust::server()
257    ///     .install(
258    ///         Cors::new()
259    ///             .allow_origin("https://frontend.example.com")
260    ///             .allow_origin("https://mobile.example.com"),
261    ///     )
262    ///     .routing(|r| {
263    ///         r.get("/", |_c: Call| async { "ok" });
264    ///     })
265    ///     .build();
266    ///
267    /// let res = TestClient::new(app)
268    ///     .get("/")
269    ///     .header("origin", "https://frontend.example.com")
270    ///     .send()
271    ///     .await;
272    ///
273    /// assert_eq!(
274    ///     res.header("access-control-allow-origin"),
275    ///     Some("https://frontend.example.com")
276    /// );
277    /// # });
278    /// ```
279    pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
280        match &mut self.origin {
281            AllowOrigin::List(v) => v.push(origin.into()),
282            AllowOrigin::Any => {
283                self.origin = AllowOrigin::List(vec![origin.into()]);
284            }
285        }
286        self
287    }
288
289    /// Replaces the list of HTTP methods advertised in preflight responses.
290    ///
291    /// The supplied `methods` are joined with `", "` and sent as the
292    /// `Access-Control-Allow-Methods` header in response to `OPTIONS` preflight
293    /// requests.  This call **replaces** the current list entirely — it does
294    /// not append.
295    ///
296    /// The default (from [`Cors::new`]) is `[GET, POST]`.
297    ///
298    /// # Example
299    ///
300    /// ```
301    /// use churust_core::{Churust, Call, TestClient};
302    /// use churust_cors::Cors;
303    /// use http::Method;
304    ///
305    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
306    /// let app = Churust::server()
307    ///     .install(
308    ///         Cors::new()
309    ///             .allow_origin("https://example.com")
310    ///             .allow_methods(vec![Method::GET, Method::POST, Method::DELETE]),
311    ///     )
312    ///     .routing(|r| {
313    ///         r.get("/", |_c: Call| async { "ok" });
314    ///     })
315    ///     .build();
316    ///
317    /// // Send a preflight for DELETE.
318    /// let res = TestClient::new(app)
319    ///     .request(Method::OPTIONS, "/")
320    ///     .header("origin", "https://example.com")
321    ///     .header("access-control-request-method", "DELETE")
322    ///     .send()
323    ///     .await;
324    ///
325    /// assert_eq!(res.status().as_u16(), 204);
326    /// let allowed = res.header("access-control-allow-methods").unwrap_or("");
327    /// assert!(allowed.contains("DELETE"));
328    /// # });
329    /// ```
330    pub fn allow_methods(mut self, methods: Vec<Method>) -> Self {
331        self.methods = methods;
332        self
333    }
334
335    /// Replaces the list of request headers advertised in preflight responses.
336    ///
337    /// The supplied header names are joined with `", "` and sent as the
338    /// `Access-Control-Allow-Headers` header in response to `OPTIONS` preflight
339    /// requests.  Pass `["*"]` to allow any header (note that this is a literal
340    /// wildcard string, not a glob pattern — its meaning is defined by the
341    /// browser's CORS implementation).
342    ///
343    /// An empty list (the default from [`Cors::new`]) omits the
344    /// `Access-Control-Allow-Headers` header entirely from preflight responses.
345    ///
346    /// # Example
347    ///
348    /// ```
349    /// use churust_core::{Churust, Call, TestClient};
350    /// use churust_cors::Cors;
351    /// use http::Method;
352    ///
353    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
354    /// let app = Churust::server()
355    ///     .install(
356    ///         Cors::new()
357    ///             .allow_origin("https://example.com")
358    ///             .allow_headers(vec!["Content-Type".into(), "X-Api-Key".into()]),
359    ///     )
360    ///     .routing(|r| {
361    ///         r.get("/", |_c: Call| async { "ok" });
362    ///     })
363    ///     .build();
364    ///
365    /// let res = TestClient::new(app)
366    ///     .request(Method::OPTIONS, "/")
367    ///     .header("origin", "https://example.com")
368    ///     .header("access-control-request-method", "GET")
369    ///     .send()
370    ///     .await;
371    ///
372    /// assert_eq!(res.status().as_u16(), 204);
373    /// let hdrs = res.header("access-control-allow-headers").unwrap_or("");
374    /// assert!(hdrs.contains("X-Api-Key"));
375    /// # });
376    /// ```
377    pub fn allow_headers(mut self, headers: Vec<String>) -> Self {
378        self.headers = headers;
379        self
380    }
381
382    /// Controls whether the `Access-Control-Allow-Credentials: true` header is
383    /// sent.
384    ///
385    /// Set this to `true` when your API relies on cookies, HTTP authentication,
386    /// or TLS client certificates for cross-origin requests.  The browser only
387    /// forwards credentials when **both** the server sets this header **and**
388    /// the client sets `XMLHttpRequest.withCredentials = true` (or the
389    /// `fetch` `credentials: "include"` option).
390    ///
391    /// > **CORS spec gotcha:** Credentials are incompatible with a wildcard
392    /// > (`*`) `Allow-Origin`.  If you enable credentials, make sure the
393    /// > policy lists explicit origins via [`allow_origin`](Cors::allow_origin)
394    /// > rather than using [`Cors::permissive`].
395    ///
396    /// # Example
397    ///
398    /// ```
399    /// use churust_core::{Churust, Call, TestClient};
400    /// use churust_cors::Cors;
401    ///
402    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
403    /// let app = Churust::server()
404    ///     .install(
405    ///         Cors::new()
406    ///             .allow_origin("https://trusted.example.com")
407    ///             .allow_credentials(true),
408    ///     )
409    ///     .routing(|r| {
410    ///         r.get("/", |_c: Call| async { "ok" });
411    ///     })
412    ///     .build();
413    ///
414    /// let res = TestClient::new(app)
415    ///     .get("/")
416    ///     .header("origin", "https://trusted.example.com")
417    ///     .send()
418    ///     .await;
419    ///
420    /// assert_eq!(res.header("access-control-allow-credentials"), Some("true"));
421    /// # });
422    /// ```
423    pub fn allow_credentials(mut self, yes: bool) -> Self {
424        self.credentials = yes;
425        self
426    }
427
428    /// Sets the `Access-Control-Max-Age` value (in seconds) for preflight caching.
429    ///
430    /// Browsers may cache a successful preflight response for up to `seconds`
431    /// seconds, avoiding repeated `OPTIONS` round-trips for subsequent requests
432    /// to the same endpoint.  The practical upper limit varies by browser (e.g.
433    /// Chrome caps it at 7200 seconds; Firefox caps it at 86 400 seconds).
434    ///
435    /// If this method is not called (the default for [`Cors::new`]), the
436    /// `Access-Control-Max-Age` header is omitted and the browser applies its
437    /// own default (typically 5 seconds).
438    ///
439    /// # Parameters
440    ///
441    /// - `seconds` — cache duration as a non-negative integer.  A value of `0`
442    ///   is legal and instructs browsers not to cache the preflight at all.
443    ///
444    /// # Example
445    ///
446    /// ```
447    /// use churust_core::{Churust, Call, TestClient};
448    /// use churust_cors::Cors;
449    /// use http::Method;
450    ///
451    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
452    /// let app = Churust::server()
453    ///     .install(
454    ///         Cors::new()
455    ///             .allow_origin("https://example.com")
456    ///             .max_age(3600),
457    ///     )
458    ///     .routing(|r| {
459    ///         r.get("/", |_c: Call| async { "ok" });
460    ///     })
461    ///     .build();
462    ///
463    /// let res = TestClient::new(app)
464    ///     .request(Method::OPTIONS, "/")
465    ///     .header("origin", "https://example.com")
466    ///     .header("access-control-request-method", "GET")
467    ///     .send()
468    ///     .await;
469    ///
470    /// assert_eq!(res.header("access-control-max-age"), Some("3600"));
471    /// # });
472    /// ```
473    pub fn max_age(mut self, seconds: u64) -> Self {
474        self.max_age = Some(seconds);
475        self
476    }
477
478    fn origin_allowed(&self, origin: &str) -> Option<String> {
479        match &self.origin {
480            AllowOrigin::Any => Some("*".to_string()),
481            AllowOrigin::List(list) => {
482                if list.iter().any(|o| o == origin) {
483                    Some(origin.to_string())
484                } else {
485                    None
486                }
487            }
488        }
489    }
490
491    fn apply_common(&self, res: &mut Response, allow_origin: &str) {
492        res.headers.insert(
493            ACCESS_CONTROL_ALLOW_ORIGIN,
494            HeaderValue::from_str(allow_origin).unwrap_or(HeaderValue::from_static("*")),
495        );
496        if self.credentials {
497            res.headers.insert(
498                ACCESS_CONTROL_ALLOW_CREDENTIALS,
499                HeaderValue::from_static("true"),
500            );
501        }
502    }
503}
504
505impl Default for Cors {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511/// Append `Origin` to `Vary` without disturbing what is already there.
512///
513/// This used to be a plain `insert` of `Vary: Origin` inside `apply_common`,
514/// which threw away whatever another layer had already put in the header.
515/// churust-compression merges `accept-encoding` into `Vary` as it unwinds, and
516/// CORS sits outside it, so the overwrite left a gzip-encoded response keyed on
517/// `Origin` alone: a shared cache would store those compressed bytes and hand
518/// them to the next same-origin client that sent no `Accept-Encoding` at all,
519/// which cannot decode them.
520///
521/// The merge itself is `Response::vary_on`, in churust-core, rather than a shape
522/// copied here and kept in step with the compression plugin's by hand: the two
523/// only interoperate if they agree on what a merged `Vary` looks like whichever
524/// order they are installed in, and one implementation is how that is
525/// guaranteed rather than intended.
526fn vary_on_origin(res: &mut Response) {
527    res.vary_on("origin");
528}
529
530impl Plugin for Cors {
531    fn install(self: Box<Self>, app: &mut AppBuilder) {
532        app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
533    }
534}
535
536struct CorsMiddleware {
537    cfg: Cors,
538}
539
540#[async_trait]
541impl Middleware for CorsMiddleware {
542    async fn handle(&self, call: Call, next: Next) -> Response {
543        let origin = call.header("origin").map(|s| s.to_string());
544        let is_preflight = *call.method() == Method::OPTIONS
545            && call
546                .header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
547                .is_some();
548
549        // Preflight: short-circuit with 204 + CORS headers.
550        if is_preflight {
551            let mut res = Response::new(StatusCode::NO_CONTENT);
552            if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
553                self.cfg.apply_common(&mut res, &o);
554                let methods = self
555                    .cfg
556                    .methods
557                    .iter()
558                    .map(|m| m.as_str())
559                    .collect::<Vec<_>>()
560                    .join(", ");
561                if let Ok(v) = HeaderValue::from_str(&methods) {
562                    res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
563                }
564                if !self.cfg.headers.is_empty() {
565                    let hs = self.cfg.headers.join(", ");
566                    if let Ok(v) = HeaderValue::from_str(&hs) {
567                        res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
568                    }
569                }
570                if let Some(age) = self.cfg.max_age {
571                    if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
572                        res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
573                    }
574                }
575            }
576            vary_on_origin(&mut res);
577            return res;
578        }
579
580        // Actual request: run the chain, then decorate the response.
581        let mut res = next.run(call).await;
582        if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
583            self.cfg.apply_common(&mut res, &o);
584        }
585        // Every response this middleware touches is marked, not only the ones
586        // that came out with an `Access-Control-Allow-Origin`. Whether that
587        // header is present at all is decided by the request's `Origin`: a
588        // same-origin request sends none and gets none back, a refused origin
589        // gets none either, an allowed one does. Marking only the allowed case
590        // left the other two freely cacheable, so a shared cache could store
591        // the header-less answer and replay it to an allowed origin, whose
592        // browser then blocks a response the server would have permitted.
593        vary_on_origin(&mut res);
594        res
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    // Only the tests name it now that merging lives in churust-core.
602    use churust_core::{App, Churust, TestClient};
603    use http::header::VARY;
604
605    fn app() -> App {
606        Churust::server()
607            .install(Cors::allow_any_origin_insecure())
608            .routing(|r| {
609                r.get("/", |_c: Call| async { "ok" });
610            })
611            .build()
612    }
613
614    #[tokio::test]
615    async fn actual_request_gets_allow_origin() {
616        let client = TestClient::new(app());
617        let res = client
618            .get("/")
619            .header("origin", "https://example.com")
620            .send()
621            .await;
622        assert_eq!(res.status(), StatusCode::OK);
623        assert_eq!(res.header("access-control-allow-origin"), Some("*"));
624        // Lower case because the merge normalises what it finds and appends in
625        // the same spelling churust-compression uses; `Vary` names fields, and
626        // field names are case-insensitive, so this is the same header as the
627        // `Origin` this test used to assert.
628        assert_eq!(res.header("vary"), Some("origin"));
629    }
630
631    #[tokio::test]
632    async fn preflight_returns_204_with_methods() {
633        let client = TestClient::new(app());
634        let res = client
635            .request(Method::OPTIONS, "/")
636            .header("origin", "https://example.com")
637            .header("access-control-request-method", "POST")
638            .send()
639            .await;
640        assert_eq!(res.status(), StatusCode::NO_CONTENT);
641        let methods = res.header("access-control-allow-methods").unwrap();
642        assert!(methods.contains("POST"));
643    }
644
645    /// The core dispatcher answers an unclaimed `OPTIONS` with `204` plus an
646    /// `Allow` header. That must not shadow CORS preflight, which needs to
647    /// respond with `access-control-*` headers instead.
648    ///
649    /// Cors sits in the `Plugins` phase and the router in `Fallback`, so
650    /// preflight short-circuits first — but that is an assumption about phase
651    /// ordering, and this test is what keeps it true.
652    #[tokio::test]
653    async fn preflight_takes_priority_over_automatic_options() {
654        let client = TestClient::new(app());
655        let res = client
656            .request(Method::OPTIONS, "/")
657            .header("origin", "https://example.com")
658            .header("access-control-request-method", "GET")
659            .send()
660            .await;
661
662        assert_eq!(res.status(), StatusCode::NO_CONTENT);
663        assert!(
664            res.header("access-control-allow-origin").is_some(),
665            "CORS preflight was swallowed by the automatic OPTIONS handler"
666        );
667    }
668
669    /// A `Vary` another layer already earned has to survive this one.
670    /// churust-compression merges `accept-encoding` into `Vary` on its way out
671    /// and the CORS middleware unwinds after it, so overwriting the header
672    /// would leave a gzip response keyed on `Origin` alone — a shared cache
673    /// would then hand those compressed bytes to the next client that sent no
674    /// `Accept-Encoding` at all.
675    #[tokio::test]
676    async fn a_pre_existing_vary_survives_the_cors_layer() {
677        let app = Churust::server()
678            .install(Cors::allow_any_origin_insecure())
679            .routing(|r| {
680                r.get("/report", |_c: Call| async {
681                    Response::text("ok")
682                        .with_header(VARY, HeaderValue::from_static("accept-encoding"))
683                });
684            })
685            .build();
686        let res = TestClient::new(app)
687            .get("/report")
688            .header("origin", "https://app.example.com")
689            .send()
690            .await;
691        assert_eq!(res.header("vary"), Some("accept-encoding, origin"));
692    }
693
694    /// Merging must not accumulate: a `Vary` that already names the origin is
695    /// left exactly as it is, and stays a single header line.
696    #[tokio::test]
697    async fn an_origin_already_named_in_vary_is_not_repeated() {
698        let app = Churust::server()
699            .install(Cors::allow_any_origin_insecure())
700            .routing(|r| {
701                r.get("/report", |_c: Call| async {
702                    Response::text("ok").with_header(VARY, HeaderValue::from_static("Origin"))
703                });
704            })
705            .build();
706        let res = TestClient::new(app)
707            .get("/report")
708            .header("origin", "https://app.example.com")
709            .send()
710            .await;
711        assert_eq!(res.header("vary"), Some("Origin"));
712    }
713
714    /// The refusal is itself origin-dependent: this response has no
715    /// `Access-Control-Allow-Origin` precisely because of who asked. Without
716    /// `Vary: Origin` a shared cache may store the header-less answer and
717    /// replay it to an allowed origin, which the browser then blocks.
718    #[tokio::test]
719    async fn a_response_to_a_disallowed_origin_still_varies_on_origin() {
720        let app = Churust::server()
721            .install(Cors::new().allow_origin("https://allowed.com"))
722            .routing(|r| {
723                r.get("/", |_c: Call| async { "ok" });
724            })
725            .build();
726        let res = TestClient::new(app)
727            .get("/")
728            .header("origin", "https://evil.com")
729            .send()
730            .await;
731        assert_eq!(res.header("access-control-allow-origin"), None);
732        assert_eq!(res.header("vary"), Some("origin"));
733    }
734
735    /// Same-origin requests carry no `Origin` at all, so they too get an answer
736    /// that differs from the cross-origin one. The cache key has to say so.
737    #[tokio::test]
738    async fn a_response_to_a_request_without_an_origin_still_varies_on_origin() {
739        let res = TestClient::new(app()).get("/").send().await;
740        assert_eq!(res.header("access-control-allow-origin"), None);
741        assert_eq!(res.header("vary"), Some("origin"));
742    }
743
744    #[tokio::test]
745    async fn disallowed_origin_gets_no_cors_header() {
746        let app = Churust::server()
747            .install(Cors::new().allow_origin("https://allowed.com"))
748            .routing(|r| {
749                r.get("/", |_c: Call| async { "ok" });
750            })
751            .build();
752        let client = TestClient::new(app);
753        let res = client
754            .get("/")
755            .header("origin", "https://evil.com")
756            .send()
757            .await;
758        assert_eq!(res.header("access-control-allow-origin"), None);
759    }
760}