diff --git a/apps/nxmesh-master/src/service/proxy/mod.rs b/apps/nxmesh-master/src/service/proxy/mod.rs index ce9dce3..0ca5748 100644 --- a/apps/nxmesh-master/src/service/proxy/mod.rs +++ b/apps/nxmesh-master/src/service/proxy/mod.rs @@ -1,4 +1,7 @@ -use crate::service::proxy::types::{ProxyConfig, ProxyServiceResult}; +use crate::service::proxy::types::{ + AgentConfigBinding, CreateProxyConfigParams, ProxyConfig, ProxyConfigSummary, + ProxyServiceResult, ProxyType, UpdateProxyConfigParams, +}; pub(crate) mod nginx; pub(crate) mod repo; @@ -10,6 +13,31 @@ pub mod types; #[async_trait::async_trait] pub trait ProxyServiceTrait: Send + Sync + 'static { async fn get_proxy_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult; + + // CRUD + async fn list_configs(&self) -> ProxyServiceResult>; + async fn create_config( + &self, + params: CreateProxyConfigParams, + ) -> ProxyServiceResult; + async fn update_config( + &self, + id: uuid::Uuid, + params: UpdateProxyConfigParams, + ) -> ProxyServiceResult; + async fn delete_config(&self, id: uuid::Uuid) -> ProxyServiceResult; + + // Binding + async fn get_active_agent_config( + &self, + agent_id: uuid::Uuid, + ) -> ProxyServiceResult>; + async fn bind_agent( + &self, + agent_id: uuid::Uuid, + config_id: uuid::Uuid, + ) -> ProxyServiceResult; + async fn unbind_agent(&self, agent_id: uuid::Uuid) -> ProxyServiceResult; } pub trait ProxyConfigRenderer: Send + Sync + 'static { diff --git a/apps/nxmesh-master/src/service/proxy/nginx/mod.rs b/apps/nxmesh-master/src/service/proxy/nginx/mod.rs index 96dc6df..3752abc 100644 --- a/apps/nxmesh-master/src/service/proxy/nginx/mod.rs +++ b/apps/nxmesh-master/src/service/proxy/nginx/mod.rs @@ -185,3 +185,246 @@ impl ProxyConfigRenderer for NginxConfigRenderer { output } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::proxy::types::*; + use std::collections::HashMap; + + fn make_id() -> uuid::Uuid { + uuid::Uuid::new_v4() + } + + fn basic_proxy_config() -> ProxyConfig { + let upstream_id = make_id(); + let location_id = make_id(); + let server_id = make_id(); + + ProxyConfig { + id: make_id(), + name: "test".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + upstreams: HashMap::from([( + upstream_id, + UpstreamConfig { + id: upstream_id, + name: "backend".to_string(), + target_host: "127.0.0.1".to_string(), + target_port: 3000, + metadata: None, + override_of_id: None, + location_blocks: vec![OverrideRef { + id: location_id, + override_of_id: None, + }], + }, + )]), + location_blocks: HashMap::from([( + location_id, + LocationBlockConfig { + id: location_id, + server_id, + path_pattern: "/api".to_string(), + proxy_pass_upstream_id: Some(upstream_id), + metadata: None, + override_of_id: None, + access_rules: vec![], + limit_rules: vec![], + proxy_settings: vec![], + rewrite_rules: vec![], + }, + )]), + server_blocks: HashMap::from([( + server_id, + ServerBlockConfig { + id: server_id, + server_name: Some(vec!["example.com".to_string()]), + listen_port: 80, + ssl_enabled: Some(false), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![OverrideRef { + id: location_id, + override_of_id: None, + }], + log_settings: vec![], + ssl_certificates: vec![], + }, + )]), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + } + } + + #[test] + fn test_render_basic_nginx_config() { + let config = basic_proxy_config(); + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + + assert!(output.contains("upstream backend {"), "should contain upstream block"); + assert!(output.contains("server 127.0.0.1:3000;"), "should contain upstream server"); + assert!(output.contains("server {"), "should contain server block"); + assert!(output.contains("listen 80;"), "should contain listen directive"); + assert!(output.contains("server_name example.com;"), "should contain server name"); + assert!(output.contains("location /api {"), "should contain location block"); + assert!(output.contains("proxy_pass http://backend;"), "should contain proxy pass"); + } + + #[test] + fn test_render_config_with_cache_zone() { + let zone_id = make_id(); + let mut config = basic_proxy_config(); + config.cache_zones.insert( + zone_id, + CacheZoneConfig { + id: zone_id, + name: "mycache".to_string(), + path: "/var/cache/nginx".to_string(), + size: "10m".to_string(), + override_of_id: None, + }, + ); + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + assert!(output.contains("proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mycache:10m;")); + } + + #[test] + fn test_render_config_with_access_rules() { + let rule_id = make_id(); + let mut config = basic_proxy_config(); + + // Get the server ID from the config + let sb_id = *config.server_blocks.keys().next().unwrap(); + + let rule = AccessRuleConfig { + id: rule_id, + r#type: "allow".to_string(), + ip_cidr: "192.168.1.0/24".to_string(), + description: None, + priority: 10, + override_of_id: None, + }; + config.access_rules.insert(rule_id, rule); + + // Add access rule ref to the server block + let sb = config.server_blocks.get_mut(&sb_id).unwrap(); + sb.access_rules.push(OverrideRef { + id: rule_id, + override_of_id: None, + }); + + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + assert!(output.contains("allow 192.168.1.0/24;")); + } + + #[test] + fn test_render_config_empty() { + let config = ProxyConfig { + id: make_id(), + name: "empty".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + server_blocks: HashMap::new(), + upstreams: HashMap::new(), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + location_blocks: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + }; + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + assert!(output.is_empty() || output.trim().is_empty()); + } + + #[test] + fn test_render_config_with_limit_zone_and_rule() { + let mut config = basic_proxy_config(); + let zone_id = make_id(); + let location_id = *config.location_blocks.keys().next().unwrap(); + let rule_id = make_id(); + + config.limit_zones.insert( + zone_id, + LimitZoneConfig { + id: zone_id, + name: "reqzone".to_string(), + key: "$binary_remote_addr".to_string(), + rate: "10r/s".to_string(), + override_of_id: None, + }, + ); + + config.limit_rules.insert( + rule_id, + LimitRuleConfig { + id: rule_id, + location_id, + zone_id, + burst: Some(20), + nodelay: Some(true), + is_deleted: false, + override_of_id: None, + }, + ); + + let lb = config.location_blocks.get_mut(&location_id).unwrap(); + lb.limit_rules.push(OverrideRef { + id: rule_id, + override_of_id: None, + }); + + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + assert!(output.contains("limit_req_zone $binary_remote_addr zone=reqzone:10r/s;")); + assert!(output.contains("limit_req zone=reqzone burst=20 nodelay;")); + } + + #[test] + fn test_render_config_with_ssl() { + let mut config = basic_proxy_config(); + let cert_id = make_id(); + let sb_id = *config.server_blocks.keys().next().unwrap(); + + config.ssl_certificates.insert( + cert_id, + SslCertificateConfig { + id: cert_id, + name: "test-cert".to_string(), + cert_path: "/etc/ssl/certs/test.pem".to_string(), + key_path: "/etc/ssl/private/test.key".to_string(), + expiry_date: chrono::Utc::now(), + }, + ); + + let sb = config.server_blocks.get_mut(&sb_id).unwrap(); + sb.ssl_enabled = Some(true); + sb.ssl_certificates.push(OverrideRef { + id: cert_id, + override_of_id: None, + }); + + let renderer = NginxConfigRenderer; + let output = renderer.render(&config); + assert!(output.contains("listen 80 ssl;")); + assert!(output.contains("ssl_certificate /etc/ssl/certs/test.pem;")); + assert!(output.contains("ssl_certificate_key /etc/ssl/private/test.key;")); + } +} diff --git a/apps/nxmesh-master/src/service/proxy/repo.rs b/apps/nxmesh-master/src/service/proxy/repo.rs index 3f531ad..1605e45 100644 --- a/apps/nxmesh-master/src/service/proxy/repo.rs +++ b/apps/nxmesh-master/src/service/proxy/repo.rs @@ -1,27 +1,48 @@ use std::collections::HashMap; -use sea_orm::{DatabaseConnection, prelude::*}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, prelude::*}; use crate::service::proxy::types::{ - Mergeable, OverrideRef, ProxyConfig, ProxyServiceError, ProxyServiceResult, ProxyType, + AgentConfigBinding, CreateProxyConfigParams, Mergeable, OverrideRef, ProxyConfig, + ProxyConfigSummary, ProxyServiceError, ProxyServiceResult, ProxyType, UpdateProxyConfigParams, }; #[async_trait::async_trait] pub trait ProxyRepo: Send + Sync + 'static { - // get the raw config for the given proxy_id. This should return the config of the given proxy_id without merging it with its parent configs (if any). async fn get_proxy_raw_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult; - - // get the raw config for the given proxy_id. This should return the config of the given proxy_id and all its parent configs (if any) without merging them. The returned vector is ordered from the leaf (given proxy_id) down to the root config (most specific to least specific). async fn get_proxy_raw_configs( &self, proxy_id: uuid::Uuid, ) -> ProxyServiceResult>; - - // get the merged config for the given proxy_id. This should merge the config of the given proxy_id with its parent configs (if any) and return the final merged config. async fn get_merged_proxy_config( &self, proxy_id: uuid::Uuid, ) -> ProxyServiceResult; + + // CRUD + async fn list_proxy_configs(&self) -> ProxyServiceResult>; + async fn create_proxy_config( + &self, + params: CreateProxyConfigParams, + ) -> ProxyServiceResult; + async fn update_proxy_config( + &self, + id: uuid::Uuid, + params: UpdateProxyConfigParams, + ) -> ProxyServiceResult; + async fn delete_proxy_config(&self, id: uuid::Uuid) -> ProxyServiceResult; + + // Agent config binding + async fn get_active_agent_config( + &self, + agent_id: uuid::Uuid, + ) -> ProxyServiceResult>; + async fn bind_agent_to_config( + &self, + agent_id: uuid::Uuid, + config_id: uuid::Uuid, + ) -> ProxyServiceResult; + async fn unbind_agent(&self, agent_id: uuid::Uuid) -> ProxyServiceResult; } pub(crate) struct ProxyRepoImpl { @@ -441,4 +462,163 @@ impl ProxyRepo for ProxyRepoImpl { } Ok(merged) } + + // ── CRUD ── + + async fn list_proxy_configs(&self) -> ProxyServiceResult> { + use crate::db::entities::proxy_config::Column; + use sea_orm::QueryOrder; + + let configs = crate::db::entities::proxy_config::Entity::find() + .order_by(Column::UpdatedAt, sea_orm::Order::Desc) + .all(&self.db) + .await?; + Ok(configs.into_iter().map(Into::into).collect()) + } + + async fn create_proxy_config( + &self, + params: CreateProxyConfigParams, + ) -> ProxyServiceResult { + use crate::db::entities::proxy_config::ActiveModel; + let now = chrono::Utc::now().naive_utc(); + let model = ActiveModel { + id: Set(uuid::Uuid::new_v4()), + name: Set(params.name), + description: Set(params.description), + is_template: Set(params.is_template), + created_at: Set(now), + updated_at: Set(now), + }; + let result = model.insert(&self.db).await?; + Ok(result.into()) + } + + async fn update_proxy_config( + &self, + id: uuid::Uuid, + params: UpdateProxyConfigParams, + ) -> ProxyServiceResult { + use crate::db::entities::proxy_config::ActiveModel; + use crate::db::entities::proxy_config::Entity as ProxyConfigEntity; + + let existing = ProxyConfigEntity::find_by_id(id) + .one(&self.db) + .await? + .ok_or(ProxyServiceError::ConfigNotFound)?; + + let mut model: ActiveModel = existing.into(); + if let Some(name) = params.name { + model.name = Set(name); + } + if let Some(description) = params.description { + model.description = Set(Some(description)); + } + if let Some(is_template) = params.is_template { + model.is_template = Set(is_template); + } + model.updated_at = Set(chrono::Utc::now().naive_utc()); + + let result = model.update(&self.db).await?; + Ok(result.into()) + } + + async fn delete_proxy_config(&self, id: uuid::Uuid) -> ProxyServiceResult { + let result = crate::db::entities::proxy_config::Entity::delete_by_id(id) + .exec(&self.db) + .await?; + Ok(result.rows_affected > 0) + } + + // ── Agent config binding ── + + async fn get_active_agent_config( + &self, + agent_id: uuid::Uuid, + ) -> ProxyServiceResult> { + use crate::db::entities::agent_config_binding::Column; + use crate::db::entities::proxy_config::Entity as ProxyConfigEntity; + use sea_orm::Condition; + + let binding = crate::db::entities::agent_config_binding::Entity::find() + .filter( + Condition::all() + .add(Column::AgentId.eq(agent_id)) + .add(Column::IsActive.eq(true)), + ) + .one(&self.db) + .await?; + + match binding { + Some(b) => { + let config = ProxyConfigEntity::find_by_id(b.config_id) + .one(&self.db) + .await? + .ok_or(ProxyServiceError::ConfigNotFound)?; + Ok(Some(config.into())) + } + None => Ok(None), + } + } + + async fn bind_agent_to_config( + &self, + agent_id: uuid::Uuid, + config_id: uuid::Uuid, + ) -> ProxyServiceResult { + use crate::db::entities::agent_config_binding::ActiveModel; + use crate::db::entities::agent_config_binding::Column; + use sea_orm::Condition; + + // Deactivate existing active binding for this agent + if let Some(existing) = crate::db::entities::agent_config_binding::Entity::find() + .filter( + Condition::all() + .add(Column::AgentId.eq(agent_id)) + .add(Column::IsActive.eq(true)), + ) + .one(&self.db) + .await? + { + let mut active: ActiveModel = existing.into(); + active.is_active = Set(false); + active.update(&self.db).await?; + } + + let now = chrono::Utc::now().naive_utc(); + let model = ActiveModel { + id: Set(uuid::Uuid::new_v4()), + agent_id: Set(Some(agent_id)), + group_id: Set(None), + config_id: Set(config_id), + is_active: Set(true), + applied_at: Set(now), + }; + let result = model.insert(&self.db).await?; + Ok(result.into()) + } + + async fn unbind_agent(&self, agent_id: uuid::Uuid) -> ProxyServiceResult { + use crate::db::entities::agent_config_binding::ActiveModel; + use crate::db::entities::agent_config_binding::Column; + use sea_orm::Condition; + + let existing = crate::db::entities::agent_config_binding::Entity::find() + .filter( + Condition::all() + .add(Column::AgentId.eq(agent_id)) + .add(Column::IsActive.eq(true)), + ) + .one(&self.db) + .await?; + + if let Some(b) = existing { + let mut active: ActiveModel = b.into(); + active.is_active = Set(false); + active.update(&self.db).await?; + Ok(true) + } else { + Ok(false) + } + } } diff --git a/apps/nxmesh-master/src/service/proxy/service.rs b/apps/nxmesh-master/src/service/proxy/service.rs index 941af35..870dc11 100644 --- a/apps/nxmesh-master/src/service/proxy/service.rs +++ b/apps/nxmesh-master/src/service/proxy/service.rs @@ -4,7 +4,10 @@ use sea_orm::DatabaseConnection; use super::nginx::NginxConfigRenderer; use super::repo::{ProxyRepo, ProxyRepoImpl}; -use super::types::{ProxyConfig, ProxyServiceError, ProxyServiceResult, ProxyType}; +use super::types::{ + AgentConfigBinding, CreateProxyConfigParams, ProxyConfig, ProxyConfigSummary, + ProxyServiceError, ProxyServiceResult, ProxyType, UpdateProxyConfigParams, +}; use super::{ProxyConfigRenderer, ProxyServiceTrait}; pub struct ProxyServiceImpl { @@ -28,6 +31,48 @@ impl ProxyServiceTrait for ProxyServiceImpl { async fn get_proxy_config(&self, proxy_id: uuid::Uuid) -> ProxyServiceResult { self.repo.get_merged_proxy_config(proxy_id).await } + + async fn list_configs(&self) -> ProxyServiceResult> { + self.repo.list_proxy_configs().await + } + + async fn create_config( + &self, + params: CreateProxyConfigParams, + ) -> ProxyServiceResult { + self.repo.create_proxy_config(params).await + } + + async fn update_config( + &self, + id: uuid::Uuid, + params: UpdateProxyConfigParams, + ) -> ProxyServiceResult { + self.repo.update_proxy_config(id, params).await + } + + async fn delete_config(&self, id: uuid::Uuid) -> ProxyServiceResult { + self.repo.delete_proxy_config(id).await + } + + async fn get_active_agent_config( + &self, + agent_id: uuid::Uuid, + ) -> ProxyServiceResult> { + self.repo.get_active_agent_config(agent_id).await + } + + async fn bind_agent( + &self, + agent_id: uuid::Uuid, + config_id: uuid::Uuid, + ) -> ProxyServiceResult { + self.repo.bind_agent_to_config(agent_id, config_id).await + } + + async fn unbind_agent(&self, agent_id: uuid::Uuid) -> ProxyServiceResult { + self.repo.unbind_agent(agent_id).await + } } impl ProxyServiceImpl { diff --git a/apps/nxmesh-master/src/service/proxy/types.rs b/apps/nxmesh-master/src/service/proxy/types.rs index 05efbea..1d65225 100644 --- a/apps/nxmesh-master/src/service/proxy/types.rs +++ b/apps/nxmesh-master/src/service/proxy/types.rs @@ -1,17 +1,15 @@ use std::collections::HashMap; -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ProxyServiceError { + #[error("proxy config not found")] ConfigNotFound, - InvalidConfig, + #[error("invalid proxy config: {0}")] + InvalidConfig(String), + #[error("no renderer registered for this proxy type")] RendererNotFound, - DatabaseError(sea_orm::DbErr), -} - -impl From for ProxyServiceError { - fn from(err: sea_orm::DbErr) -> Self { - ProxyServiceError::DatabaseError(err) - } + #[error("database error: {0}")] + DatabaseError(#[from] sea_orm::DbErr), } pub type ProxyServiceResult = Result; @@ -169,14 +167,17 @@ impl impl Mergeable for ServerBlockConfig { fn merge(&mut self, other: ServerBlockConfig) { - if let Some(server_name) = other.server_name { - self.server_name = Some(server_name); + // self (child) overrides other (parent): keep child's values, fill gaps from parent + if self.server_name.is_none() { + self.server_name = other.server_name; } - self.listen_port = other.listen_port; - if let Some(ssl_enabled) = other.ssl_enabled { - self.ssl_enabled = Some(ssl_enabled); + // listen_port is non-optional, child always keeps its own + if self.ssl_enabled.is_none() { + self.ssl_enabled = other.ssl_enabled; + } + if self.override_of_id.is_none() { + self.override_of_id = other.override_of_id; } - self.override_of_id = other.override_of_id; self.access_rules = merge_override_vecs( std::mem::take(&mut self.access_rules), @@ -480,3 +481,322 @@ impl Overridable for ProxySettingConfig { impl Overridable for RewriteRuleConfig { fn override_of_id(&self) -> Option { self.override_of_id } } + +// ── CRUD types ── + +pub struct ProxyConfigSummary { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub is_template: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +impl From for ProxyConfigSummary { + fn from(m: crate::db::entities::proxy_config::Model) -> Self { + Self { + id: m.id, + name: m.name, + description: m.description, + is_template: m.is_template, + created_at: m.created_at.and_utc(), + updated_at: m.updated_at.and_utc(), + } + } +} + +pub struct CreateProxyConfigParams { + pub name: String, + pub description: Option, + pub is_template: bool, +} + +pub struct UpdateProxyConfigParams { + pub name: Option, + pub description: Option, + pub is_template: Option, +} + +// ── Agent config binding ── + +pub struct AgentConfigBinding { + pub id: uuid::Uuid, + pub agent_id: Option, + pub group_id: Option, + pub config_id: uuid::Uuid, + pub is_active: bool, + pub applied_at: chrono::DateTime, +} + +impl From for AgentConfigBinding { + fn from(m: crate::db::entities::agent_config_binding::Model) -> Self { + Self { + id: m.id, + agent_id: m.agent_id, + group_id: m.group_id, + config_id: m.config_id, + is_active: m.is_active, + applied_at: m.applied_at.and_utc(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_id() -> uuid::Uuid { + uuid::Uuid::new_v4() + } + + #[test] + fn test_merge_override_vecs_child_overrides_parent() { + let parent_id = make_id(); + let child_id = make_id(); + let child = vec![OverrideRef { + id: child_id, + override_of_id: Some(parent_id), + }]; + let parent = vec![OverrideRef { + id: parent_id, + override_of_id: None, + }]; + let result = merge_override_vecs(child, parent); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, child_id); + } + + #[test] + fn test_merge_override_vecs_removes_overridden_parent() { + let parent_id = make_id(); + let child_override = make_id(); + let child = vec![OverrideRef { + id: child_override, + override_of_id: Some(parent_id), + }]; + let parent = vec![OverrideRef { + id: parent_id, + override_of_id: None, + }]; + let result = merge_override_vecs(child, parent); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, child_override); + } + + #[test] + fn test_merge_override_vecs_empty_child() { + let parent = vec![OverrideRef { + id: make_id(), + override_of_id: None, + }]; + let result = merge_override_vecs(vec![], parent.clone()); + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, parent[0].id); + } + + #[test] + fn test_server_block_merge_child_overrides_parent() { + let id = make_id(); + let mut child = ServerBlockConfig { + id, + server_name: Some(vec!["child.example.com".to_string()]), + listen_port: 443, + ssl_enabled: Some(true), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent = ServerBlockConfig { + id, + server_name: Some(vec!["parent.example.com".to_string()]), + listen_port: 80, + ssl_enabled: Some(false), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + child.merge(parent); + // child keeps its own values (self overrides other) + assert_eq!(child.server_name, Some(vec!["child.example.com".to_string()])); + assert_eq!(child.listen_port, 443); + assert_eq!(child.ssl_enabled, Some(true)); + } + + #[test] + fn test_server_block_merge_fills_from_parent() { + let id = make_id(); + let mut child = ServerBlockConfig { + id, + server_name: None, + listen_port: 443, + ssl_enabled: None, + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent = ServerBlockConfig { + id, + server_name: Some(vec!["parent.example.com".to_string()]), + listen_port: 80, + ssl_enabled: Some(false), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + child.merge(parent); + // child fills missing optional fields from parent + assert_eq!(child.server_name, Some(vec!["parent.example.com".to_string()])); + assert_eq!(child.listen_port, 443); // non-optional: child keeps its own + assert_eq!(child.ssl_enabled, Some(false)); + } + + #[test] + fn test_proxy_config_merge_server_block_overrides() { + let sb_id = make_id(); + let child_sb = ServerBlockConfig { + id: sb_id, + server_name: Some(vec!["child.example.com".to_string()]), + listen_port: 443, + ssl_enabled: Some(true), + override_of_id: Some(make_id()), + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent_sb = ServerBlockConfig { + id: sb_id, + server_name: Some(vec!["parent.example.com".to_string()]), + listen_port: 80, + ssl_enabled: Some(false), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent_id = make_id(); + let mut child_proxy = ProxyConfig { + id: parent_id, + name: "child".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + server_blocks: HashMap::from([(child_sb.id, child_sb)]), + upstreams: HashMap::new(), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + location_blocks: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + }; + let parent_proxy = ProxyConfig { + id: make_id(), + name: "parent".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + server_blocks: HashMap::from([(parent_sb.id, parent_sb)]), + upstreams: HashMap::new(), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + location_blocks: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + }; + child_proxy.merge(parent_proxy); + assert_eq!(child_proxy.server_blocks.len(), 1); + let merged_sb = &child_proxy.server_blocks[&sb_id]; + assert_eq!(merged_sb.server_name, Some(vec!["child.example.com".to_string()])); + assert_eq!(merged_sb.listen_port, 443); + assert_eq!(merged_sb.ssl_enabled, Some(true)); + } + + #[test] + fn test_proxy_config_merge_adds_parent_server_block() { + let child_sb_id = make_id(); + let parent_sb_id = make_id(); + let child_sb = ServerBlockConfig { + id: child_sb_id, + server_name: Some(vec!["child.example.com".to_string()]), + listen_port: 443, + ssl_enabled: Some(true), + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent_sb = ServerBlockConfig { + id: parent_sb_id, + server_name: Some(vec!["parent.example.com".to_string()]), + listen_port: 80, + ssl_enabled: None, + override_of_id: None, + access_rules: vec![], + location_blocks: vec![], + log_settings: vec![], + ssl_certificates: vec![], + }; + let parent_id = make_id(); + let mut child_proxy = ProxyConfig { + id: parent_id, + name: "child".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + server_blocks: HashMap::from([(child_sb.id, child_sb)]), + upstreams: HashMap::new(), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + location_blocks: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + }; + let parent_proxy = ProxyConfig { + id: make_id(), + name: "parent".to_string(), + r#type: ProxyType::Nginx, + description: None, + parent_config_id: None, + server_blocks: HashMap::from([(parent_sb.id, parent_sb)]), + upstreams: HashMap::new(), + access_rules: HashMap::new(), + cache_zones: HashMap::new(), + limit_rules: HashMap::new(), + limit_zones: HashMap::new(), + location_blocks: HashMap::new(), + log_settings: HashMap::new(), + proxy_settings: HashMap::new(), + rewrite_rules: HashMap::new(), + ssl_certificates: HashMap::new(), + }; + child_proxy.merge(parent_proxy); + assert_eq!(child_proxy.server_blocks.len(), 2); + // Both child and parent server blocks should be present + assert!(child_proxy.server_blocks.contains_key(&child_sb_id)); + assert!(child_proxy.server_blocks.contains_key(&parent_sb_id)); + } +}