diff --git a/.dockerignore b/.dockerignore index cc6a29ee7..5c6a57841 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,7 @@ demo/ .DS_Store target/ **/*.rs.bk +.aider* .idea/ .vscode/ test_log* diff --git a/.gitignore b/.gitignore index f6ef743d5..d157d093f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .DS_Store target/ **/*.rs.bk +.aider* .idea/ .vscode/ test_log* diff --git a/Cargo.lock b/Cargo.lock index 17b06bb3d..1b36ef15f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,17 +2145,16 @@ dependencies = [ [[package]] name = "fmmap" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6099ab52d5329340a3014f60ca91bc892181ae32e752360d07be9295924dcb0b" +checksum = "687c574434dc6e3cd24a363fe0944711174f947fe71696fdc9a0ae046fe6e715" dependencies = [ - "async-trait", "byteorder", "bytes", "enum_dispatch", "fs4", - "memmapix", - "parse-display 0.8.2", + "memmap2 0.9.5", + "parse-display 0.10.0", "pin-project-lite", "tokio", ] @@ -2228,14 +2227,13 @@ dependencies = [ [[package]] name = "fs4" -version = "0.6.6" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47" +checksum = "c29c30684418547d476f0b48e84f4821639119c483b1eccd566c8cd0cd05f521" dependencies = [ - "async-trait", "rustix 0.38.44", "tokio", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -3509,15 +3507,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memmapix" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f517ab414225d5f1755bd284d9545bd08a72a3958b3c6384d72e95de9cc1a1d3" -dependencies = [ - "rustix 0.38.44", -] - [[package]] name = "mime" version = "0.3.17" @@ -3845,52 +3834,51 @@ dependencies = [ [[package]] name = "parse-display" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6509d08722b53e8dafe97f2027b22ccbe3a5db83cb352931e9716b0aa44bc5c" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" dependencies = [ - "once_cell", - "parse-display-derive 0.8.2", + "parse-display-derive 0.9.1", "regex", + "regex-syntax 0.8.5", ] [[package]] name = "parse-display" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +checksum = "287d8d3ebdce117b8539f59411e4ed9ec226e0a4153c7f55495c6070d68e6f72" dependencies = [ - "parse-display-derive 0.9.1", + "parse-display-derive 0.10.0", "regex", "regex-syntax 0.8.5", ] [[package]] name = "parse-display-derive" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68517892c8daf78da08c0db777fcc17e07f2f63ef70041718f8a7630ad84f341" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" dependencies = [ - "once_cell", "proc-macro2", "quote", "regex", - "regex-syntax 0.7.5", - "structmeta 0.2.0", + "regex-syntax 0.8.5", + "structmeta", "syn 2.0.101", ] [[package]] name = "parse-display-derive" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +checksum = "7fc048687be30d79502dea2f623d052f3a074012c6eac41726b7ab17213616b1" dependencies = [ "proc-macro2", "quote", "regex", "regex-syntax 0.8.5", - "structmeta 0.3.0", + "structmeta", "syn 2.0.101", ] @@ -4063,9 +4051,9 @@ dependencies = [ [[package]] name = "pmtiles" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18dabde2a77b24221be77404aa1adb4998715447abf4b6d87d550d6a9f4df75" +checksum = "092d7bfd038840136755f50f04ebad76b1ade5523b66ffd637e2609f896f546a" dependencies = [ "async-compression", "aws-sdk-s3", @@ -4075,7 +4063,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.12", "tilejson", "tokio", "varint-rs", @@ -4603,12 +4591,6 @@ version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" -[[package]] -name = "regex-syntax" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -5660,18 +5642,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "structmeta" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ad9e09554f0456d67a69c1584c9798ba733a5b50349a6c0d0948710523922d" -dependencies = [ - "proc-macro2", - "quote", - "structmeta-derive 0.2.0", - "syn 2.0.101", -] - [[package]] name = "structmeta" version = "0.3.0" @@ -5680,18 +5650,7 @@ checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" dependencies = [ "proc-macro2", "quote", - "structmeta-derive 0.3.0", - "syn 2.0.101", -] - -[[package]] -name = "structmeta-derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00" -dependencies = [ - "proc-macro2", - "quote", + "structmeta-derive", "syn 2.0.101", ] diff --git a/docs/src/config-file.md b/docs/src/config-file.md index c1e8567e5..e1db56083 100644 --- a/docs/src/config-file.md +++ b/docs/src/config-file.md @@ -44,6 +44,19 @@ preferred_encoding: gzip # Enable or disable Martin web UI. At the moment, only allows `enable-for-all` which enables the web UI for all connections. This may be undesirable in a production environment. [default: disable] web_ui: disable +# CORS Configuration +# +# Defaults to `cors: true`, which allows all origins. +# Sending/Acting on CORS headers can be completely disabled via `cors: false` +cors: + # Sets the `Access-Control-Allow-Origin` header [default: *] + # '*' will use the requests `ORIGIN` header + origin: + - https://example.org + # Sets `Access-Control-Max-Age` Header. [default: null] + # null means not setting the header for preflight requests + max_age: 3600 + # Database configuration. This can also be a list of PG configs. postgres: # Database connection string. diff --git a/martin/src/srv/config.rs b/martin/src/srv/config.rs index 1e1168093..8ff4eb9e0 100644 --- a/martin/src/srv/config.rs +++ b/martin/src/srv/config.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +use super::cors::CorsConfig; use crate::args::PreferredEncoding; pub const KEEP_ALIVE_DEFAULT: u64 = 75; @@ -15,6 +16,7 @@ pub struct SrvConfig { pub preferred_encoding: Option, #[cfg(feature = "webui")] pub web_ui: Option, + pub cors: Option, } #[cfg(test)] @@ -22,6 +24,7 @@ mod tests { use indoc::indoc; use super::*; + use crate::srv::cors::CorsProperties; use crate::tests::some; #[test] @@ -73,4 +76,65 @@ mod tests { } ); } + + #[test] + fn parse_config_cors() { + assert_eq!( + serde_yaml::from_str::(indoc! {" + keep_alive: 75 + listen_addresses: '0.0.0.0:3000' + worker_processes: 8 + cors: false + "}) + .unwrap(), + SrvConfig { + keep_alive: Some(75), + listen_addresses: some("0.0.0.0:3000"), + worker_processes: Some(8), + cors: Some(CorsConfig::SimpleFlag(false)), + ..Default::default() + } + ); + assert_eq!( + serde_yaml::from_str::(indoc! {" + keep_alive: 75 + listen_addresses: '0.0.0.0:3000' + worker_processes: 8 + cors: true + "}) + .unwrap(), + SrvConfig { + keep_alive: Some(75), + listen_addresses: some("0.0.0.0:3000"), + worker_processes: Some(8), + cors: Some(CorsConfig::SimpleFlag(true)), + ..Default::default() + } + ); + assert_eq!( + serde_yaml::from_str::(indoc! {" + keep_alive: 75 + listen_addresses: '0.0.0.0:3000' + worker_processes: 8 + cors: + origin: + - https://martin.maplibre.org + - https://example.org + "}) + .unwrap(), + SrvConfig { + keep_alive: Some(75), + listen_addresses: some("0.0.0.0:3000"), + worker_processes: Some(8), + cors: Some(CorsConfig::Properties(CorsProperties { + origin: vec![ + "https://martin.maplibre.org".to_string(), + "https://example.org".to_string() + ], + max_age: None, + })), + ..Default::default() + } + ); + } } diff --git a/martin/src/srv/cors.rs b/martin/src/srv/cors.rs new file mode 100644 index 000000000..6cd7fb7e8 --- /dev/null +++ b/martin/src/srv/cors.rs @@ -0,0 +1,226 @@ +use actix_http::Method; +use log::info; +use serde::{Deserialize, Serialize}; + +use crate::{MartinError, MartinResult}; + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum CorsError { + #[error("At least one 'origin' must be specified in the 'cors' configuration")] + NoOriginsConfigured, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum CorsConfig { + Properties(CorsProperties), + SimpleFlag(bool), +} + +impl Default for CorsConfig { + fn default() -> Self { + Self::Properties(CorsProperties::default()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct CorsProperties { + #[serde(default)] + pub origin: Vec, + pub max_age: Option, +} + +impl Default for CorsProperties { + fn default() -> Self { + Self { + origin: vec!["*".to_string()], + max_age: None, + } + } +} + +impl CorsProperties { + pub fn validate(&self) -> Result<(), CorsError> { + if self.origin.is_empty() { + Err(CorsError::NoOriginsConfigured) + } else { + Ok(()) + } + } +} + +impl CorsConfig { + /// Checks that that if cors is configured explicitely (instead of via `true`/`false`), `origin` is configured + pub fn validate(&self) -> MartinResult<()> { + match self { + CorsConfig::SimpleFlag(_) => Ok(()), + CorsConfig::Properties(properties) => properties.validate().map_err(MartinError::from), + } + } + + #[must_use] + /// Create [`actix_cors::Cors`] from the configuration + pub fn make_cors_middleware(&self) -> Option { + match self { + CorsConfig::SimpleFlag(false) => { + info!("CORS is disabled"); + None + } + CorsConfig::SimpleFlag(true) => { + let properties = CorsProperties::default(); + info!("Enabled CORS with defaults: {properties:?}"); + Some(Self::create_cors(&properties)) + } + CorsConfig::Properties(properties) => Some(Self::create_cors(properties)), + } + } + + fn create_cors(properties: &CorsProperties) -> actix_cors::Cors { + let mut cors = actix_cors::Cors::default(); + + // allow any origin by default + // this returns the value of the requests `ORIGIN` header in `Access-Control-Allow-Origin` + if properties.origin.contains(&"*".to_string()) { + cors = cors.allow_any_origin(); + } else { + for origin in &properties.origin { + cors = cors.allowed_origin(origin); + } + } + + // only allow GET method by default + cors = cors.allowed_methods([Method::GET]); + + // sets `Access-Control-Max-Age` if configured + cors = cors.max_age(properties.max_age); + + cors + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use super::*; + + #[test] + fn test_cors_config_default() { + let config = CorsConfig::default(); + let middleware = config.make_cors_middleware(); + assert!(middleware.is_some()); + + // Check if it's using the appropiate default properties + if let CorsConfig::Properties(properties) = config { + assert_eq!(properties.origin, vec!["*"]); + assert_eq!(properties.max_age, None); + } else { + panic!("Expected Properties variant for default config"); + } + } + + #[test] + fn test_cors_middleware_disabled() { + let config = CorsConfig::SimpleFlag(false); + assert!(config.make_cors_middleware().is_none()); + } + + #[test] + fn test_cors_yaml_parsing() { + let config: CorsConfig = serde_yaml::from_str(indoc! {" + origin: + - https://example.org + max_age: 3600 + "}) + .unwrap(); + + if let CorsConfig::Properties(settings) = config { + assert_eq!(settings.origin, vec!["https://example.org".to_string()]); + assert_eq!(settings.max_age, Some(3600)); + } else { + panic!("Expected Settings variant for detailed config"); + } + + let config: CorsConfig = serde_yaml::from_str("false").unwrap(); + assert_eq!(config, CorsConfig::SimpleFlag(false)); + + let config: CorsConfig = serde_yaml::from_str("true").unwrap(); + assert_eq!(config, CorsConfig::SimpleFlag(true)); + + let config: CorsConfig = serde_yaml::from_str(indoc! {" + origin: + - https://example.org + - https://martin.maplibre.org + max_age: 3600 + "}) + .unwrap(); + + if let CorsConfig::Properties(settings) = config { + assert_eq!( + settings.origin, + vec![ + "https://example.org".to_string(), + "https://martin.maplibre.org".to_string(), + ] + ); + assert_eq!(settings.max_age, Some(3600)); + } else { + panic!("Expected Settings variant for detailed config"); + } + } + + #[test] + fn test_cors_validation() { + let config: CorsConfig = serde_yaml::from_str(indoc! {"max_age: 3600"}).unwrap(); + if let CorsConfig::Properties(settings) = config { + // This should fail validation + assert_eq!(settings.validate(), Err(CorsError::NoOriginsConfigured)); + } else { + panic!("Expected Properties variant"); + } + + let config: CorsConfig = serde_yaml::from_str(indoc! {" + origin: + - https://example.org + max_age: 3600"}) + .unwrap(); + + let CorsConfig::Properties(settings) = config else { + panic!("Expected Properties variant"); + }; + assert!(settings.validate().is_ok()); + } + + #[test] + fn test_cors_validation_error_empty_origin() { + let properties = CorsProperties { + origin: vec![], + max_age: Some(3600), + }; + + assert_eq!(properties.validate(), Err(CorsError::NoOriginsConfigured)); + } + + #[test] + fn test_cors_with_valid_properties() { + let properties = CorsProperties { + origin: vec!["https://example.com".to_string()], + max_age: Some(3600), + }; + assert!(properties.validate().is_ok()); + + let config = CorsConfig::Properties(properties); + let middleware = config.make_cors_middleware(); + assert!(middleware.is_some()); + } + + #[test] + fn test_cors_with_wildcard_origin() { + let properties = CorsProperties::default(); + assert_eq!(properties.origin, vec!["*".to_string()]); + assert!(properties.validate().is_ok()); + + let middleware = CorsConfig::Properties(properties).make_cors_middleware(); + assert!(middleware.is_some()); + } +} diff --git a/martin/src/srv/mod.rs b/martin/src/srv/mod.rs index 9519ef325..8504ec30a 100644 --- a/martin/src/srv/mod.rs +++ b/martin/src/srv/mod.rs @@ -7,6 +7,8 @@ mod fonts; mod server; pub use server::{Catalog, RESERVED_KEYWORDS, new_server, router}; +pub mod cors; + mod tiles; pub use tiles::{DynTileSource, TileRequest}; diff --git a/martin/src/srv/server.rs b/martin/src/srv/server.rs index b87bdc470..ab650a449 100644 --- a/martin/src/srv/server.rs +++ b/martin/src/srv/server.rs @@ -3,10 +3,9 @@ use std::pin::Pin; use std::string::ToString; use std::time::Duration; -use actix_cors::Cors; use actix_web::error::ErrorInternalServerError; use actix_web::http::header::CACHE_CONTROL; -use actix_web::middleware::{Compress, Logger, NormalizePath, TrailingSlash}; +use actix_web::middleware::{Compress, Condition, Logger, NormalizePath, TrailingSlash}; use actix_web::web::Data; use actix_web::{App, HttpResponse, HttpServer, Responder, route, web}; use futures::TryFutureExt; @@ -161,10 +160,11 @@ pub fn new_server(config: SrvConfig, state: ServerState) -> MartinResult<(Server .clone() .unwrap_or_else(|| LISTEN_ADDRESSES_DEFAULT.to_string()); + let cors_config = config.cors.clone().unwrap_or_default(); + cors_config.validate()?; + let factory = move || { - let cors_middleware = Cors::default() - .allow_any_origin() - .allowed_methods(vec!["GET"]); + let cors_middleware = cors_config.make_cors_middleware(); let app = App::new() .app_data(Data::new(state.tiles.clone())) @@ -179,12 +179,17 @@ pub fn new_server(config: SrvConfig, state: ServerState) -> MartinResult<(Server #[cfg(feature = "styles")] let app = app.app_data(Data::new(state.styles.clone())); - app.app_data(Data::new(catalog.clone())) - .app_data(Data::new(config.clone())) - .wrap(cors_middleware) - .wrap(NormalizePath::new(TrailingSlash::MergeOnly)) - .wrap(Logger::default()) - .configure(|c| router(c, &config)) + let app = app + .app_data(Data::new(catalog.clone())) + .app_data(Data::new(config.clone())); + + app.wrap(Condition::new( + cors_middleware.is_some(), + cors_middleware.unwrap_or_default(), + )) + .wrap(NormalizePath::new(TrailingSlash::MergeOnly)) + .wrap(Logger::default()) + .configure(|c| router(c, &config)) }; #[cfg(feature = "lambda")] diff --git a/martin/src/utils/error.rs b/martin/src/utils/error.rs index 68dc3850e..ee9ca5556 100644 --- a/martin/src/utils/error.rs +++ b/martin/src/utils/error.rs @@ -89,4 +89,7 @@ pub enum MartinError { #[error("Internal error: {0}")] InternalError(#[from] Box), + + #[error(transparent)] + CorsError(#[from] crate::srv::cors::CorsError), } diff --git a/martin/tests/cors_test.rs b/martin/tests/cors_test.rs new file mode 100644 index 000000000..915bb5678 --- /dev/null +++ b/martin/tests/cors_test.rs @@ -0,0 +1,209 @@ +use actix_http::Method; +use actix_http::header::ACCESS_CONTROL_MAX_AGE; +use actix_web::http::header::{ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN}; +use actix_web::test::{TestRequest, call_service}; +use ctor::ctor; +use indoc::indoc; + +pub mod utils; +pub use utils::*; + +#[ctor] +fn init() { + let _ = env_logger::builder().is_test(true).try_init(); +} + +macro_rules! create_app { + ($sources:expr) => {{ + let cfg = mock_cfg($sources); + let state = mock_sources(cfg.clone()).await.0; + let srv_config = cfg.srv; + let cors_middleware = srv_config + .clone() + .cors + .unwrap_or_default() + .make_cors_middleware(); + + ::actix_web::test::init_service( + ::actix_web::App::new() + .app_data(actix_web::web::Data::new( + ::martin::srv::Catalog::new(&state).unwrap(), + )) + .app_data(actix_web::web::Data::new(::martin::NO_MAIN_CACHE)) + .app_data(actix_web::web::Data::new(state.tiles)) + .app_data(actix_web::web::Data::new(srv_config.clone())) + .wrap(actix_web::middleware::Condition::new( + cors_middleware.is_some(), + cors_middleware.unwrap_or_default(), + )) + .configure(|c| ::martin::srv::router(c, &srv_config)), + ) + .await + }}; +} + +#[actix_rt::test] +async fn test_cors_explicit_disabled() { + let app = create_app!(indoc! {" + cors: false + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::get() + .uri("/health") + .insert_header((ORIGIN, "https://example.org")) + .to_request(); + let response = call_service(&app, req).await; + assert!( + response + .headers() + .get(ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); +} + +#[actix_rt::test] +async fn test_cors_implicit_enabled() { + let app = create_app!(indoc! {" + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::get() + .uri("/health") + .insert_header((ORIGIN, "https://example.org")) + .to_request(); + + let response = call_service(&app, req).await; + assert_eq!( + response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://example.org" + ); +} + +#[actix_rt::test] +async fn test_cors_explicit_enabled() { + let app = create_app!(indoc! {" + cors: true + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::get() + .uri("/health") + .insert_header((ORIGIN, "https://example.org")) + .to_request(); + + let response = call_service(&app, req).await; + assert_eq!( + response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://example.org" + ); +} + +#[actix_rt::test] +async fn test_cors_specific_origin() { + let app = create_app!(indoc! {" + cors: + origin: + - https://martin.maplibre.org + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::get() + .uri("/health") + .insert_header((ORIGIN, "https://martin.maplibre.org")) + .to_request(); + let response = call_service(&app, req).await; + assert_eq!( + response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://martin.maplibre.org" + ); +} + +#[actix_rt::test] +async fn test_cors_no_header_on_mismatch() { + let app = create_app!(indoc! {" + cors: + origin: + - https://example.org + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::get() + .uri("/health") + .insert_header((ORIGIN, "https://martin.maplibre.org")) + .to_request(); + let response = call_service(&app, req).await; + assert!( + response + .headers() + .get(ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); +} + +#[actix_rt::test] +async fn test_cors_preflight_request_with_max_age() { + let app = create_app!(indoc! {" + cors: + origin: + - https://example.org + max_age: 3600 + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::default() + .method(Method::OPTIONS) + .uri("/health") + .insert_header((ORIGIN, "https://example.org")) + .insert_header((ACCESS_CONTROL_REQUEST_METHOD, "GET")) + .to_request(); + + let response = call_service(&app, req).await; + assert_eq!( + response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://example.org" + ); + assert_eq!( + response.headers().get(ACCESS_CONTROL_MAX_AGE).unwrap(), + "3600" + ); +} + +#[actix_rt::test] +async fn test_cors_preflight_request_without_max_age() { + let app = create_app!(indoc! {" + cors: + origin: + - https://example.org + max_age: null + mbtiles: + sources: + test: ../tests/fixtures/mbtiles/world_cities.mbtiles + "}); + + let req = TestRequest::default() + .method(Method::OPTIONS) + .uri("/health") + .insert_header((ORIGIN, "https://example.org")) + .insert_header((ACCESS_CONTROL_REQUEST_METHOD, "GET")) + .to_request(); + + let response = call_service(&app, req).await; + assert_eq!( + response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://example.org" + ); + assert!(response.headers().get(ACCESS_CONTROL_MAX_AGE).is_none()); +}