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, VARY,
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. The merge below is deliberately the same shape as
520/// `churust_compression`'s `vary_on_accept_encoding` so the two plugins agree
521/// on what a merged `Vary` looks like whichever order they are installed in:
522/// split the existing values on commas, compare case-insensitively because
523/// field names are case-insensitive, and leave the header alone when it is
524/// already `*` (which varies on everything) or already names the origin. The
525/// token is appended in lower case for the same reason — it matches the
526/// spelling the compression plugin emits, so a response passing through both
527/// reads as one consistent list rather than a mixture.
528fn vary_on_origin(res: &mut Response) {
529    let existing: Vec<String> = res
530        .headers
531        .get_all(VARY)
532        .iter()
533        .filter_map(|v| v.to_str().ok())
534        .flat_map(|v| v.split(','))
535        .map(|v| v.trim().to_ascii_lowercase())
536        .filter(|v| !v.is_empty())
537        .collect();
538
539    if existing.iter().any(|v| v == "*" || v == "origin") {
540        return;
541    }
542
543    let mut merged = existing;
544    merged.push("origin".to_string());
545    if let Ok(value) = HeaderValue::from_str(&merged.join(", ")) {
546        res.headers.insert(VARY, value);
547    }
548}
549
550impl Plugin for Cors {
551    fn install(self: Box<Self>, app: &mut AppBuilder) {
552        app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
553    }
554}
555
556struct CorsMiddleware {
557    cfg: Cors,
558}
559
560#[async_trait]
561impl Middleware for CorsMiddleware {
562    async fn handle(&self, call: Call, next: Next) -> Response {
563        let origin = call.header("origin").map(|s| s.to_string());
564        let is_preflight = *call.method() == Method::OPTIONS
565            && call
566                .header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
567                .is_some();
568
569        // Preflight: short-circuit with 204 + CORS headers.
570        if is_preflight {
571            let mut res = Response::new(StatusCode::NO_CONTENT);
572            if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
573                self.cfg.apply_common(&mut res, &o);
574                let methods = self
575                    .cfg
576                    .methods
577                    .iter()
578                    .map(|m| m.as_str())
579                    .collect::<Vec<_>>()
580                    .join(", ");
581                if let Ok(v) = HeaderValue::from_str(&methods) {
582                    res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
583                }
584                if !self.cfg.headers.is_empty() {
585                    let hs = self.cfg.headers.join(", ");
586                    if let Ok(v) = HeaderValue::from_str(&hs) {
587                        res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
588                    }
589                }
590                if let Some(age) = self.cfg.max_age {
591                    if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
592                        res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
593                    }
594                }
595            }
596            vary_on_origin(&mut res);
597            return res;
598        }
599
600        // Actual request: run the chain, then decorate the response.
601        let mut res = next.run(call).await;
602        if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
603            self.cfg.apply_common(&mut res, &o);
604        }
605        // Every response this middleware touches is marked, not only the ones
606        // that came out with an `Access-Control-Allow-Origin`. Whether that
607        // header is present at all is decided by the request's `Origin`: a
608        // same-origin request sends none and gets none back, a refused origin
609        // gets none either, an allowed one does. Marking only the allowed case
610        // left the other two freely cacheable, so a shared cache could store
611        // the header-less answer and replay it to an allowed origin, whose
612        // browser then blocks a response the server would have permitted.
613        vary_on_origin(&mut res);
614        res
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use churust_core::{App, Churust, TestClient};
622
623    fn app() -> App {
624        Churust::server()
625            .install(Cors::allow_any_origin_insecure())
626            .routing(|r| {
627                r.get("/", |_c: Call| async { "ok" });
628            })
629            .build()
630    }
631
632    #[tokio::test]
633    async fn actual_request_gets_allow_origin() {
634        let client = TestClient::new(app());
635        let res = client
636            .get("/")
637            .header("origin", "https://example.com")
638            .send()
639            .await;
640        assert_eq!(res.status(), StatusCode::OK);
641        assert_eq!(res.header("access-control-allow-origin"), Some("*"));
642        // Lower case because the merge normalises what it finds and appends in
643        // the same spelling churust-compression uses; `Vary` names fields, and
644        // field names are case-insensitive, so this is the same header as the
645        // `Origin` this test used to assert.
646        assert_eq!(res.header("vary"), Some("origin"));
647    }
648
649    #[tokio::test]
650    async fn preflight_returns_204_with_methods() {
651        let client = TestClient::new(app());
652        let res = client
653            .request(Method::OPTIONS, "/")
654            .header("origin", "https://example.com")
655            .header("access-control-request-method", "POST")
656            .send()
657            .await;
658        assert_eq!(res.status(), StatusCode::NO_CONTENT);
659        let methods = res.header("access-control-allow-methods").unwrap();
660        assert!(methods.contains("POST"));
661    }
662
663    /// The core dispatcher answers an unclaimed `OPTIONS` with `204` plus an
664    /// `Allow` header. That must not shadow CORS preflight, which needs to
665    /// respond with `access-control-*` headers instead.
666    ///
667    /// Cors sits in the `Plugins` phase and the router in `Fallback`, so
668    /// preflight short-circuits first — but that is an assumption about phase
669    /// ordering, and this test is what keeps it true.
670    #[tokio::test]
671    async fn preflight_takes_priority_over_automatic_options() {
672        let client = TestClient::new(app());
673        let res = client
674            .request(Method::OPTIONS, "/")
675            .header("origin", "https://example.com")
676            .header("access-control-request-method", "GET")
677            .send()
678            .await;
679
680        assert_eq!(res.status(), StatusCode::NO_CONTENT);
681        assert!(
682            res.header("access-control-allow-origin").is_some(),
683            "CORS preflight was swallowed by the automatic OPTIONS handler"
684        );
685    }
686
687    /// A `Vary` another layer already earned has to survive this one.
688    /// churust-compression merges `accept-encoding` into `Vary` on its way out
689    /// and the CORS middleware unwinds after it, so overwriting the header
690    /// would leave a gzip response keyed on `Origin` alone — a shared cache
691    /// would then hand those compressed bytes to the next client that sent no
692    /// `Accept-Encoding` at all.
693    #[tokio::test]
694    async fn a_pre_existing_vary_survives_the_cors_layer() {
695        let app = Churust::server()
696            .install(Cors::allow_any_origin_insecure())
697            .routing(|r| {
698                r.get("/report", |_c: Call| async {
699                    Response::text("ok")
700                        .with_header(VARY, HeaderValue::from_static("accept-encoding"))
701                });
702            })
703            .build();
704        let res = TestClient::new(app)
705            .get("/report")
706            .header("origin", "https://app.example.com")
707            .send()
708            .await;
709        assert_eq!(res.header("vary"), Some("accept-encoding, origin"));
710    }
711
712    /// Merging must not accumulate: a `Vary` that already names the origin is
713    /// left exactly as it is, and stays a single header line.
714    #[tokio::test]
715    async fn an_origin_already_named_in_vary_is_not_repeated() {
716        let app = Churust::server()
717            .install(Cors::allow_any_origin_insecure())
718            .routing(|r| {
719                r.get("/report", |_c: Call| async {
720                    Response::text("ok").with_header(VARY, HeaderValue::from_static("Origin"))
721                });
722            })
723            .build();
724        let res = TestClient::new(app)
725            .get("/report")
726            .header("origin", "https://app.example.com")
727            .send()
728            .await;
729        assert_eq!(res.header("vary"), Some("Origin"));
730    }
731
732    /// The refusal is itself origin-dependent: this response has no
733    /// `Access-Control-Allow-Origin` precisely because of who asked. Without
734    /// `Vary: Origin` a shared cache may store the header-less answer and
735    /// replay it to an allowed origin, which the browser then blocks.
736    #[tokio::test]
737    async fn a_response_to_a_disallowed_origin_still_varies_on_origin() {
738        let app = Churust::server()
739            .install(Cors::new().allow_origin("https://allowed.com"))
740            .routing(|r| {
741                r.get("/", |_c: Call| async { "ok" });
742            })
743            .build();
744        let res = TestClient::new(app)
745            .get("/")
746            .header("origin", "https://evil.com")
747            .send()
748            .await;
749        assert_eq!(res.header("access-control-allow-origin"), None);
750        assert_eq!(res.header("vary"), Some("origin"));
751    }
752
753    /// Same-origin requests carry no `Origin` at all, so they too get an answer
754    /// that differs from the cross-origin one. The cache key has to say so.
755    #[tokio::test]
756    async fn a_response_to_a_request_without_an_origin_still_varies_on_origin() {
757        let res = TestClient::new(app()).get("/").send().await;
758        assert_eq!(res.header("access-control-allow-origin"), None);
759        assert_eq!(res.header("vary"), Some("origin"));
760    }
761
762    #[tokio::test]
763    async fn disallowed_origin_gets_no_cors_header() {
764        let app = Churust::server()
765            .install(Cors::new().allow_origin("https://allowed.com"))
766            .routing(|r| {
767                r.get("/", |_c: Call| async { "ok" });
768            })
769            .build();
770        let client = TestClient::new(app);
771        let res = client
772            .get("/")
773            .header("origin", "https://evil.com")
774            .send()
775            .await;
776        assert_eq!(res.header("access-control-allow-origin"), None);
777    }
778}