use super::*; use crate::config::RateLimitBps; #[tokio::test] async fn save_sections_preserves_other_tables_and_comments() { let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write( &path, "# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n", ) .unwrap(); let mut cfg = ProxyConfig::default(); cfg.censorship.tls_domain = "new.example".to_string(); cfg.server.port = 443; let rev = save_sections_to_disk(&path, &cfg, &["censorship"]) .await .unwrap(); let written = std::fs::read_to_string(&path).unwrap(); assert!(written.contains("tls_domain = \"new.example\"")); // Untouched comments and tables remain byte content. assert!(written.contains("# top comment")); assert!(written.contains("[server]\nport = 443")); assert_eq!(rev, compute_revision(&written)); std::fs::remove_dir_all(&dir).ok(); } #[test] fn find_bounds_matches_array_of_tables() { let src = "[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n"; let bounds = find_toml_table_bounds(src, "upstreams"); assert!(bounds.is_some(), "should locate [[upstreams]] block start"); let (start, end) = bounds.unwrap(); let slice = &src[start..end]; assert!(slice.starts_with("[[upstreams]]")); // The bound spans through the last upstream block. assert!(slice.contains("kind = \"b\"")); } #[test] fn find_bounds_matches_header_with_inline_comment() { let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n"; let bounds = find_toml_table_bounds(src, "censorship"); assert!(bounds.is_some(), "commented header must still match"); let (start, end) = bounds.unwrap(); let slice = &src[start..end]; assert!(slice.starts_with("[censorship] # notes")); assert!(slice.contains("tls_domain")); // The bound terminates at the next header. assert!(!slice.contains("[server]")); } #[tokio::test] async fn save_general_section_keeps_subtables_dotted_without_duplicates() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); tokio::fs::write( &path, "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\ [general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n", ) .await .unwrap(); let mut cfg = ProxyConfig::default(); cfg.general.prefer_ipv6 = true; save_sections_to_disk(&path, &cfg, &["general"]) .await .unwrap(); let written = tokio::fs::read_to_string(&path).await.unwrap(); // No bare top-level [modes] / [links] headers leaked. for line in written.lines() { let header = line.trim(); assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}"); assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}"); } // Sub-tables kept their dotted prefix exactly once each. assert_eq!( written.matches("[general.modes]").count(), 1, "[general.modes] must appear exactly once:\n{written}" ); assert_eq!( written.matches("[general.links]").count(), 1, "[general.links] must appear exactly once:\n{written}" ); // Result parses (duplicate tables would error here). toml::from_str::(&written) .unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}")); // The unrelated table remains untouched. assert!(written.contains("[server]\nport = 443")); } #[tokio::test] async fn save_general_section_is_idempotent_across_repeated_saves() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); tokio::fs::write( &path, "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\ [general.links]\npublic_host = \"old.example\"\n", ) .await .unwrap(); let mut cfg = ProxyConfig::default(); cfg.general.prefer_ipv6 = true; save_sections_to_disk(&path, &cfg, &["general"]) .await .unwrap(); save_sections_to_disk(&path, &cfg, &["general"]) .await .unwrap(); let written = tokio::fs::read_to_string(&path).await.unwrap(); assert_eq!(written.matches("[general.modes]").count(), 1, "{written}"); assert_eq!(written.matches("[general.links]").count(), 1, "{written}"); assert_eq!(written.matches("[general]").count(), 1, "{written}"); toml::from_str::(&written) .unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}")); } #[test] fn find_bounds_spans_dotted_subtables() { let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\ [general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n"; let bounds = find_toml_table_bounds(src, "general"); assert!(bounds.is_some(), "should locate [general] block"); let (start, end) = bounds.unwrap(); let slice = &src[start..end]; assert!(slice.starts_with("[general]")); // Nested sub-tables belong to the parent table bound. assert!(slice.contains("[general.modes]")); assert!(slice.contains("[general.links]")); // The bound terminates before an unrelated header. assert!(!slice.contains("[server]")); } #[test] fn find_bounds_does_not_overrun_sibling_prefix() { // access.users must not swallow access.user_enabled (dot guards the prefix). let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n"; let bounds = find_toml_table_bounds(src, "access.users").unwrap(); let slice = &src[bounds.0..bounds.1]; assert!(slice.starts_with("[access.users]")); assert!(!slice.contains("[access.user_enabled]")); } #[test] fn nested_include_detection_does_not_reject_similar_access_keys() { assert!(has_include_inside_table( "[access.users]\ninclude = \"users.toml\"\n" )); assert!(!has_include_inside_table( "[access.users]\ninclude_user = \"00000000000000000000000000000000\"\n" )); } #[tokio::test] async fn save_general_handles_non_contiguous_subtables() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); // Hand-edited layout: [general.modes] sits AFTER an unrelated [server]. tokio::fs::write( &path, "[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\ [general.modes]\ntls = true\n", ) .await .unwrap(); let mut cfg = ProxyConfig::default(); cfg.general.prefer_ipv6 = true; save_sections_to_disk(&path, &cfg, &["general"]) .await .unwrap(); let written = tokio::fs::read_to_string(&path).await.unwrap(); assert_eq!( written.matches("[general.modes]").count(), 1, "non-contiguous [general.modes] must not duplicate:\n{written}" ); toml::from_str::(&written) .unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}")); // The unrelated section remains present. assert!(written.contains("[server]")); } #[tokio::test] async fn manifest_revision_changes_when_an_included_source_changes() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("config.toml"); let included = dir.path().join("included.toml"); tokio::fs::write(&root, "include = \"included.toml\"\n") .await .unwrap(); tokio::fs::write(&included, "[censorship]\ntls_domain = \"one.example\"\n") .await .unwrap(); let first = current_revision(&root).await.unwrap(); tokio::fs::write(&included, "[censorship]\ntls_domain = \"two.example\"\n") .await .unwrap(); let second = current_revision(&root).await.unwrap(); assert_ne!(first, second); } #[tokio::test] async fn manifest_revision_does_not_require_typed_config_validation() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("config.toml"); tokio::fs::write(&root, "[server]\nport = \"invalid\"\n") .await .unwrap(); let revision = current_revision(&root).await.unwrap(); assert_eq!(revision.len(), 64); assert!(load_config_snapshot(&root, false).await.is_err()); } #[tokio::test] async fn access_mutation_writes_only_the_single_included_owner() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("config.toml"); let included = dir.path().join("users.toml"); let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"example.com\"\n"; let included_body = "[access.users]\nalice = \"00000000000000000000000000000000\"\n"; tokio::fs::write(&root, root_body).await.unwrap(); tokio::fs::write(&included, included_body).await.unwrap(); let mut cfg = load_config_from_disk(&root).await.unwrap(); cfg.access.users.insert( "bob".to_string(), "11111111111111111111111111111111".to_string(), ); let revision = save_access_sections_to_disk(&root, &cfg, &[AccessSection::Users]) .await .unwrap(); assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body); let written = tokio::fs::read_to_string(&included).await.unwrap(); assert!(written.contains("bob = \"11111111111111111111111111111111\"")); assert_eq!(revision, current_revision(&root).await.unwrap()); } #[tokio::test] async fn access_mutation_rejects_sections_with_different_source_owners() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("config.toml"); let included = dir.path().join("enabled.toml"); let root_body = concat!( "include = \"enabled.toml\"\n", "[access.users]\nalice = \"00000000000000000000000000000000\"\n" ); let included_body = "[access.user_enabled]\nalice = false\n"; tokio::fs::write(&root, root_body).await.unwrap(); tokio::fs::write(&included, included_body).await.unwrap(); let cfg = load_config_from_disk(&root).await.unwrap(); let error = save_access_sections_to_disk( &root, &cfg, &[AccessSection::Users, AccessSection::UserEnabled], ) .await .unwrap_err(); assert_eq!(error.code, "config_patch_not_atomic"); assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body); assert_eq!( tokio::fs::read_to_string(&included).await.unwrap(), included_body ); } #[test] fn render_user_rate_limits_section() { let mut cfg = ProxyConfig::default(); cfg.access.user_rate_limits.insert( "alice".to_string(), RateLimitBps { up_bps: 1024, down_bps: 2048, }, ); let rendered = render_access_section(&cfg, AccessSection::UserRateLimits).expect("section must render"); assert!(rendered.starts_with("[access.user_rate_limits]\n")); assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }")); }