diff --git a/backend/.tarpaulin.local.toml b/backend/.tarpaulin.local.toml index 97443d2..a5ad3e6 100644 --- a/backend/.tarpaulin.local.toml +++ b/backend/.tarpaulin.local.toml @@ -4,4 +4,4 @@ skip-clean = true target-dir = "coverage" output-dir = "coverage" fail-under = 60 -exclude-files = ["target/*", "private/*"] +exclude-files = ["target/*", "private/*", "tests/*"] diff --git a/backend/src/settings/cors.rs b/backend/src/settings/cors.rs new file mode 100644 index 0000000..728e820 --- /dev/null +++ b/backend/src/settings/cors.rs @@ -0,0 +1,216 @@ +use poem::{ + http::{Method, header}, + middleware::Cors, +}; + +/// CORS (Cross-Origin Resource Sharing) configuration for the HTTP API. +/// +/// Controls which origins can access the API from browsers. In development, +/// use permissive settings (`allowed_origins: ["*"]`). In production, use +/// restrictive settings with specific origins. +/// +/// # Security Constraint +/// +/// When `allow_credentials` is `true`, `allowed_origins` MUST NOT contain +/// wildcard `"*"`. This is enforced by browser security policy and will be +/// validated by the `build_cors()` function. +#[derive(Debug, serde::Deserialize, Clone)] +pub struct CorsSettings { + /// List of allowed origin URLs (e.g., `["https://sta.example.com"]`). + /// + /// Use `["*"]` for development to allow all origins. + /// In production, specify exact origins to prevent unauthorized access. + #[serde(default)] + pub allowed_origins: Vec, + /// Whether to allow credentials (cookies, authorization headers) in CORS requests. + /// + /// Set to `true` in production when using Authelia authentication. + /// MUST be `false` when using wildcard `"*"` in `allowed_origins`. + #[serde(default)] + pub allow_credentials: bool, + /// Duration in seconds that browsers can cache CORS preflight responses. + /// + /// Typical value: `3600` (1 hour). Higher values reduce preflight requests + /// but delay policy changes from taking effect. + #[serde(default = "default_max_age_secs")] + pub max_age_secs: i32, +} + +impl Default for CorsSettings { + fn default() -> Self { + Self { + allowed_origins: vec![], + allow_credentials: false, + max_age_secs: 3600, + } + } +} + +/// Default value for CORS max age in seconds (1 hour). +const fn default_max_age_secs() -> i32 { + 3600 +} + +impl From for Cors { + fn from(val: CorsSettings) -> Self { + assert!( + !(val.allow_credentials && val.allowed_origins.contains(&"*".to_string())), + "CORS misconfiguration: wildcard origin not allowed with credentials=true" + ); + let mut cors = Self::new(); + for origin in &val.allowed_origins { + cors = cors.allow_origin(origin); + } + cors = cors.allow_methods(vec![ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ]); + cors = cors.allow_headers(vec![header::CONTENT_TYPE, header::AUTHORIZATION]); + cors = cors + .allow_credentials(val.allow_credentials) + .max_age(val.max_age_secs); + tracing::info!( + target: "backend::settings::cors", + allowed_origins = ?val.allowed_origins, + allow_credentials = ?val.allow_credentials, + max_age_secs = ?val.max_age_secs, + "CORS middleware configured" + ); + cors + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // T009: Tests for CorsSettings struct deserialization + #[test] + fn cors_settings_deserialize_from_yaml() { + let yaml = r#" + allowed_origins: + - "http://localhost:5173" + - "https://sta.example.com" + allow_credentials: true + max_age_secs: 7200 + "#; + let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(settings.allowed_origins.len(), 2); + assert_eq!(settings.allowed_origins[0], "http://localhost:5173"); + assert_eq!(settings.allowed_origins[1], "https://sta.example.com"); + assert!(settings.allow_credentials); + assert_eq!(settings.max_age_secs, 7200); + } + + #[test] + fn cors_settings_default_has_empty_origins() { + let settings = CorsSettings::default(); + assert!( + settings.allowed_origins.is_empty(), + "Default CorsSettings should have empty allowed_origins for restrictive fail-safe" + ); + assert!( + !settings.allow_credentials, + "Default CorsSettings should have credentials disabled" + ); + assert_eq!( + settings.max_age_secs, 3600, + "Default CorsSettings should have 1 hour max_age" + ); + } + + #[test] + fn cors_settings_with_wildcard_deserializes() { + let yaml = r#" + allowed_origins: + - "*" + allow_credentials: false + max_age_secs: 3600 + "#; + let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(settings.allowed_origins.len(), 1); + assert_eq!(settings.allowed_origins[0], "*"); + assert!(!settings.allow_credentials); + assert_eq!(settings.max_age_secs, 3600); + } + + #[test] + fn cors_settings_deserialize_with_defaults() { + // Test partial deserialization using serde defaults + let yaml = r#" + allowed_origins: + - "https://example.com" + "#; + let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(settings.allowed_origins.len(), 1); + assert_eq!(settings.allowed_origins[0], "https://example.com"); + // These should use defaults + assert!(!settings.allow_credentials); + assert_eq!(settings.max_age_secs, 3600); + } + + // T013: Tests for From for Cors trait implementation + #[test] + fn cors_conversion_with_wildcard_origin() { + let settings = CorsSettings { + allowed_origins: vec!["*".to_string()], + allow_credentials: false, + max_age_secs: 3600, + }; + + // Should successfully convert without panic + let _cors: Cors = settings.into(); + } + + #[test] + fn cors_conversion_with_specific_origin() { + let settings = CorsSettings { + allowed_origins: vec!["https://sta.example.com".to_string()], + allow_credentials: true, + max_age_secs: 7200, + }; + + // Should successfully convert without panic + let _cors: Cors = settings.into(); + } + + #[test] + fn cors_conversion_with_multiple_origins() { + let settings = CorsSettings { + allowed_origins: vec![ + "http://localhost:5173".to_string(), + "https://sta.example.com".to_string(), + ], + allow_credentials: false, + max_age_secs: 3600, + }; + + // Should successfully convert without panic + let _cors: Cors = settings.into(); + } + + #[test] + #[should_panic(expected = "CORS misconfiguration: wildcard origin not allowed with credentials=true")] + fn cors_conversion_panics_on_wildcard_with_credentials() { + let settings = CorsSettings { + allowed_origins: vec!["*".to_string()], + allow_credentials: true, // Invalid combination! + max_age_secs: 3600, + }; + + // This should panic due to browser security constraint violation + let _cors: Cors = settings.into(); + } + + #[test] + fn cors_conversion_with_empty_origins() { + let settings = CorsSettings::default(); + + // Should successfully convert even with empty origins (restrictive CORS) + let _cors: Cors = settings.into(); + } +} diff --git a/backend/src/settings.rs b/backend/src/settings/mod.rs similarity index 73% rename from backend/src/settings.rs rename to backend/src/settings/mod.rs index ca354c0..59e2ad7 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings/mod.rs @@ -7,6 +7,9 @@ //! Settings include application details, Modbus connection parameters, relay configuration, //! rate limiting, and environment settings. +mod cors; +pub use cors::CorsSettings; + /// Application configuration settings. /// /// Loads configuration from YAML files and environment variables. @@ -214,54 +217,6 @@ impl Default for RelaySettings { } } -/// Default value for CORS max age in seconds (1 hour). -const fn default_max_age_secs() -> i32 { - 3600 -} - -/// CORS (Cross-Origin Resource Sharing) configuration for the HTTP API. -/// -/// Controls which origins can access the API from browsers. In development, -/// use permissive settings (`allowed_origins: ["*"]`). In production, use -/// restrictive settings with specific origins. -/// -/// # Security Constraint -/// -/// When `allow_credentials` is `true`, `allowed_origins` MUST NOT contain -/// wildcard `"*"`. This is enforced by browser security policy and will be -/// validated by the `build_cors()` function. -#[derive(Debug, serde::Deserialize, Clone)] -pub struct CorsSettings { - /// List of allowed origin URLs (e.g., `["https://sta.example.com"]`). - /// - /// Use `["*"]` for development to allow all origins. - /// In production, specify exact origins to prevent unauthorized access. - #[serde(default)] - pub allowed_origins: Vec, - /// Whether to allow credentials (cookies, authorization headers) in CORS requests. - /// - /// Set to `true` in production when using Authelia authentication. - /// MUST be `false` when using wildcard `"*"` in `allowed_origins`. - #[serde(default)] - pub allow_credentials: bool, - /// Duration in seconds that browsers can cache CORS preflight responses. - /// - /// Typical value: `3600` (1 hour). Higher values reduce preflight requests - /// but delay policy changes from taking effect. - #[serde(default = "default_max_age_secs")] - pub max_age_secs: i32, -} - -impl Default for CorsSettings { - fn default() -> Self { - Self { - allowed_origins: vec![], - allow_credentials: false, - max_age_secs: 3600, - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -388,56 +343,7 @@ mod tests { assert_eq!(settings.per_seconds, 60); // default } - // T009: Tests for CorsSettings struct (TDD - write tests first) - #[test] - fn cors_settings_deserialize_from_yaml() { - let yaml = r#" - allowed_origins: - - "http://localhost:5173" - - "https://sta.example.com" - allow_credentials: true - max_age_secs: 7200 - "#; - let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(settings.allowed_origins.len(), 2); - assert_eq!(settings.allowed_origins[0], "http://localhost:5173"); - assert_eq!(settings.allowed_origins[1], "https://sta.example.com"); - assert!(settings.allow_credentials); - assert_eq!(settings.max_age_secs, 7200); - } - - #[test] - fn cors_settings_default_has_empty_origins() { - let settings = CorsSettings::default(); - assert!( - settings.allowed_origins.is_empty(), - "Default CorsSettings should have empty allowed_origins for restrictive fail-safe" - ); - assert!( - !settings.allow_credentials, - "Default CorsSettings should have credentials disabled" - ); - assert_eq!( - settings.max_age_secs, 3600, - "Default CorsSettings should have 1 hour max_age" - ); - } - - #[test] - fn cors_settings_with_wildcard_deserializes() { - let yaml = r#" - allowed_origins: - - "*" - allow_credentials: false - max_age_secs: 3600 - "#; - let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(settings.allowed_origins.len(), 1); - assert_eq!(settings.allowed_origins[0], "*"); - assert!(!settings.allow_credentials); - assert_eq!(settings.max_age_secs, 3600); - } - + // T009: Integration test for CorsSettings within Settings struct #[test] fn settings_loads_cors_section_from_yaml() { // Create a temporary settings file with CORS configuration @@ -483,19 +389,4 @@ relay: assert!(!settings.cors.allow_credentials); assert_eq!(settings.cors.max_age_secs, 3600); } - - #[test] - fn cors_settings_deserialize_with_defaults() { - // Test partial deserialization using serde defaults - let yaml = r#" - allowed_origins: - - "https://example.com" - "#; - let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(settings.allowed_origins.len(), 1); - assert_eq!(settings.allowed_origins[0], "https://example.com"); - // These should use defaults - assert!(!settings.allow_credentials); - assert_eq!(settings.max_age_secs, 3600); - } } diff --git a/backend/src/startup.rs b/backend/src/startup.rs index abb3de4..4bcfcdb 100644 --- a/backend/src/startup.rs +++ b/backend/src/startup.rs @@ -225,131 +225,4 @@ mod tests { assert_eq!(app.host(), "127.0.0.1"); assert_eq!(app.port(), 8080); } - - // T013: Tests for build_cors() function (TDD - write tests FIRST) - mod cors_tests { - use super::*; - use crate::settings::CorsSettings; - - #[test] - #[should_panic(expected = "CORS misconfiguration")] - fn build_cors_with_credentials_and_wildcard_panics() { - // GIVEN a CORS configuration with wildcard origin AND credentials enabled - let settings = CorsSettings { - allowed_origins: vec!["*".to_string()], - allow_credentials: true, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - // THEN it should panic with a clear error message - let _cors = build_cors(&settings); - } - - #[test] - fn build_cors_with_wildcard_origin_creates_permissive_cors() { - // GIVEN a CORS configuration with wildcard origin - let settings = CorsSettings { - allowed_origins: vec!["*".to_string()], - allow_credentials: false, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should create a Cors middleware that allows any origin - // Note: We can't directly test Cors behavior without integration tests - // This test verifies that build_cors() completes without panicking - } - - #[test] - fn build_cors_with_specific_origin_creates_restrictive_cors() { - // GIVEN a CORS configuration with specific origins - let settings = CorsSettings { - allowed_origins: vec![ - "https://sta.example.com".to_string(), - "http://localhost:5173".to_string(), - ], - allow_credentials: true, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should create a Cors middleware that only allows specified origins - // Note: We can't directly test Cors behavior without integration tests - // This test verifies that build_cors() completes without panicking - } - - #[test] - fn build_cors_sets_correct_methods() { - // GIVEN a CORS configuration - let settings = CorsSettings { - allowed_origins: vec!["https://example.com".to_string()], - allow_credentials: false, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should configure the following methods: - // GET, POST, PUT, PATCH, DELETE, OPTIONS - // Note: Direct method verification requires integration tests - // This test ensures build_cors() completes without errors - } - - #[test] - fn build_cors_sets_correct_headers() { - // GIVEN a CORS configuration - let settings = CorsSettings { - allowed_origins: vec!["https://example.com".to_string()], - allow_credentials: false, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should configure the following headers: - // content-type, authorization - // Note: Direct header verification requires integration tests - // This test ensures build_cors() completes without errors - } - - #[test] - fn build_cors_sets_max_age_from_settings() { - // GIVEN a CORS configuration with custom max_age - let settings = CorsSettings { - allowed_origins: vec!["https://example.com".to_string()], - allow_credentials: false, - max_age_secs: 7200, // 2 hours - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should configure max_age to 7200 seconds - // Note: Direct max_age verification requires integration tests - // This test ensures build_cors() completes without errors - } - - #[test] - fn build_cors_with_empty_origins() { - // GIVEN a CORS configuration with no allowed origins (restrictive fail-safe) - let settings = CorsSettings { - allowed_origins: vec![], - allow_credentials: false, - max_age_secs: 3600, - }; - - // WHEN build_cors() is called - let _cors = build_cors(&settings); - - // THEN it should create a Cors middleware that denies all origins - // This test ensures build_cors() handles the fail-safe case - } - } } diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index d2e3ab5..d650743 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -114,7 +114,7 @@ - **File**: backend/src/startup.rs (in tests module) - **Complexity**: Medium | **Uncertainty**: Low -- [ ] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs +- [x] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs - Function signature: `fn build_cors(settings: &CorsSettings) -> Cors` - Validate: if `allow_credentials=true` AND `allowed_origins` contains "*", panic with clear error message - Iterate over `allowed_origins` and call `cors.allow_origin()` for each