diff --git a/Cargo.lock b/Cargo.lock index e9acd07..7147b90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3752,6 +3752,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5298,6 +5323,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "syntect", "tokio", diff --git a/src/api/comments/helpers.rs b/src/api/comments/helpers.rs index 78e8fe0..c27aa5b 100644 --- a/src/api/comments/helpers.rs +++ b/src/api/comments/helpers.rs @@ -98,13 +98,15 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> { #[allow(dead_code)] pub fn validate_comment_url(url: &str) -> Result<(), String> { - if url.trim().is_empty() { + let trimmed = url.trim(); + if trimmed.is_empty() { return Ok(()); } - if !url.starts_with("http://") && !url.starts_with("https://") { + let lower = trimmed.to_ascii_lowercase(); + if !lower.starts_with("http://") && !lower.starts_with("https://") { return Err("网址必须以 http:// 或 https:// 开头".to_string()); } - if url.len() > 200 { + if trimmed.len() > 200 { return Err("网址长度不能超过 200 个字符".to_string()); } Ok(()) @@ -199,6 +201,24 @@ mod tests { assert_eq!(format_relative_time(dt), "7 天前"); } + #[test] + fn format_relative_time_one_minute() { + let dt = chrono::Utc::now() - chrono::Duration::minutes(1); + assert_eq!(format_relative_time(dt), "1 分钟前"); + } + + #[test] + fn format_relative_time_one_hour() { + let dt = chrono::Utc::now() - chrono::Duration::hours(1); + assert_eq!(format_relative_time(dt), "1 小时前"); + } + + #[test] + fn format_relative_time_one_day() { + let dt = chrono::Utc::now() - chrono::Duration::days(1); + assert_eq!(format_relative_time(dt), "1 天前"); + } + #[test] fn format_relative_time_old_date() { let dt = chrono::Utc::now() - chrono::Duration::days(60); @@ -260,6 +280,24 @@ mod tests { assert!(validate_comment_url("javascript:alert(1)").is_err()); } + #[test] + fn validate_comment_url_uppercase_scheme() { + assert!(validate_comment_url("HTTP://example.com").is_ok()); + assert!(validate_comment_url("HTTPS://example.com").is_ok()); + assert!(validate_comment_url("Http://example.com").is_ok()); + } + + #[test] + fn validate_comment_url_fragment() { + assert!(validate_comment_url("https://example.com#section").is_ok()); + } + + #[test] + fn validate_comment_url_relative_path_rejected() { + assert!(validate_comment_url("/path/to/page").is_err()); + assert!(validate_comment_url("path/to/page").is_err()); + } + #[test] fn validate_comment_url_too_long() { let long_url = format!("https://example.com/{}", "a".repeat(200)); diff --git a/src/api/comments/markdown.rs b/src/api/comments/markdown.rs index 5a571e2..f391246 100644 --- a/src/api/comments/markdown.rs +++ b/src/api/comments/markdown.rs @@ -143,6 +143,34 @@ mod tests { assert!(result.contains("noopener")); } + #[test] + fn render_comment_link_javascript_removed() { + let result = render_comment_markdown("[click](javascript:alert(1))"); + assert!(result.contains("click")); + assert!(!result.contains("javascript:")); + } + + #[test] + fn render_comment_onerror_attribute_removed() { + let result = render_comment_markdown("
text
"); + assert!(result.contains("text")); + assert!(!result.contains("onerror")); + } + + #[test] + fn render_comment_link_data_uri_removed() { + let result = + render_comment_markdown("[click](data:text/html,)"); + assert!(result.contains("click")); + assert!(!result.contains("data:")); + } + + #[test] + fn render_comment_code_block_escapes_html_entities() { + let result = render_comment_markdown("```\n&\n```"); + assert!(result.contains("&amp;")); + } + #[test] fn render_comment_no_id_attribute() { let result = render_comment_markdown("
text
"); diff --git a/src/api/markdown.rs b/src/api/markdown.rs index 41f1d2b..411928d 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -131,7 +131,11 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { Event::End(TagEnd::CodeBlock) => { let highlighted = crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); - html.push_str("
");
+                html.push_str("
');
                 html.push_str(&highlighted);
                 html.push_str("
"); in_codeblock = false; @@ -358,8 +362,26 @@ mod tests { #[test] fn render_markdown_code_block() { let result = render_markdown_enhanced("```rust\nfn main() {}\n```"); + assert!(result.html.contains(r#"
"#));
+        assert!(result.html.contains(r#"main"#));
+        assert!(result.html.contains(r#"fn"#));
+    }
+
+    #[test]
+    fn render_markdown_code_block_without_language() {
+        let result = render_markdown_enhanced("```\nplain text\n```");
         assert!(result.html.contains("
"));
-        assert!(result.html.contains("main"));
+        assert!(!result.html.contains("class=\"language-"));
+        assert!(result.html.contains("plain text"));
+    }
+
+    #[test]
+    fn render_markdown_code_block_preserves_html_content() {
+        let result = render_markdown_enhanced("```html\n\n```");
+        assert!(result.html.contains("
"));
+        assert!(!result.html.contains("

ok

"), + "

ok

" + ); + } + + #[test] + fn id_and_class_preserved() { + assert_eq!( + clean_html("

x

"), + "

x

" + ); + assert_eq!( + clean_html("

x

"), + "

x

" + ); + } + + #[test] + fn javascript_url_stripped() { + assert_eq!( + clean_html("x"), + "x" + ); + } + + #[test] + fn vbscript_url_stripped() { + assert_eq!( + clean_html("x"), + "x" + ); + } + + #[test] + fn unknown_tags_removed_content_kept() { + assert_eq!(clean_html("keep me"), "keep me"); + } + + #[test] + fn comment_removes_img_details_summary() { + assert_eq!( + clean_comment_html("
sumbody
"), + "sumbody" + ); + } + + #[test] + fn comment_removes_data_uris() { + assert_eq!( + clean_comment_html("x"), + "x" + ); + } +} diff --git a/src/api/slug.rs b/src/api/slug.rs index 1ff825c..8f88808 100644 --- a/src/api/slug.rs +++ b/src/api/slug.rs @@ -155,4 +155,36 @@ mod tests { fn is_valid_slug_accepts_chinese() { assert!(is_valid_slug("你好-world")); } + + #[test] + fn slugify_all_special_characters_returns_timestamp() { + let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~"); + let _: i64 = slug.parse().expect("should be a valid timestamp"); + } + + #[test] + fn slugify_only_whitespace_returns_timestamp() { + let slug = slugify(" \t\n "); + let _: i64 = slug.parse().expect("should be a valid timestamp"); + } + + #[test] + fn slugify_leading_and_trailing_dashes() { + assert_eq!(slugify("-hello-world-"), "hello-world"); + assert_eq!(slugify("---hello---world---"), "hello-world"); + } + + #[test] + fn is_valid_slug_mixed_chinese_and_digits() { + assert!(is_valid_slug("你好123")); + assert!(is_valid_slug("123你好456")); + } + + #[test] + fn is_valid_slug_exact_200_char_boundary() { + let slug = "a".repeat(200); + assert!(is_valid_slug(&slug)); + let slug = "a".repeat(201); + assert!(!is_valid_slug(&slug)); + } } diff --git a/src/api/upload.rs b/src/api/upload.rs index a2d9995..e14476e 100644 --- a/src/api/upload.rs +++ b/src/api/upload.rs @@ -298,4 +298,30 @@ mod tests { let loaded = crate::webp::decode(&webp_bytes); assert!(loaded.is_ok()); } + + #[test] + fn mime_to_ext_maps_jpeg() { + assert_eq!(super::mime_to_ext("image/jpeg"), "jpg"); + } + + #[test] + fn mime_to_ext_maps_png() { + assert_eq!(super::mime_to_ext("image/png"), "png"); + } + + #[test] + fn mime_to_ext_maps_gif() { + assert_eq!(super::mime_to_ext("image/gif"), "gif"); + } + + #[test] + fn mime_to_ext_maps_webp() { + assert_eq!(super::mime_to_ext("image/webp"), "webp"); + } + + #[test] + fn mime_to_ext_falls_back_for_unknown_mime() { + assert_eq!(super::mime_to_ext("image/avif"), "bin"); + assert_eq!(super::mime_to_ext("application/octet-stream"), "bin"); + } } diff --git a/src/highlight.rs b/src/highlight.rs index f917b03..e83fcc2 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -92,33 +92,33 @@ mod tests { #[test] fn highlight_code_rust() { let result = highlight_code("fn main() {}", Some("rust")); - assert!(!result.is_empty()); - assert!(result.contains("main")); + assert!(result.contains(r#"fn"#)); + assert!(result.contains(r#"main"#)); } #[test] fn highlight_code_javascript_alias() { let result = highlight_code("console.log('hi')", Some("js")); - assert!(!result.is_empty()); - assert!(result.contains("console")); + assert!(result.contains(r#"console"#)); + assert!(result.contains(r#"log"#)); } #[test] fn highlight_code_python_alias() { let result = highlight_code("print('hi')", Some("python")); - assert!(!result.is_empty()); + assert!(result.contains(r#"print"#)); } #[test] fn highlight_code_unknown_language() { let result = highlight_code("some text", Some("brainfuck")); - assert!(!result.is_empty()); + assert!(result.contains(r#"some text"#)); } #[test] fn highlight_code_none_language() { let result = highlight_code("plain text", None); - assert!(!result.is_empty()); + assert!(result.contains(r#"plain text"#)); } #[test] @@ -130,6 +130,7 @@ mod tests { #[test] fn highlight_code_produces_span_tags() { let result = highlight_code("let x = 1;", Some("rust")); - assert!(result.contains('<')); + assert!(result.contains(r#"let"#)); + assert!(result.contains(r#"1"#)); } } diff --git a/src/models/post.rs b/src/models/post.rs index c450ad2..3d3b4f5 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -177,15 +177,24 @@ mod tests { fn status_class_returns_non_empty() { let mut post = sample_post(); post.status = PostStatus::Published; - assert!(!post.status_class().is_empty()); + assert_eq!(post.status_class(), "text-green-600 dark:text-green-400"); post.status = PostStatus::Draft; - assert!(!post.status_class().is_empty()); + assert_eq!(post.status_class(), "text-gray-400 dark:text-[#9b9c9d]"); } #[test] fn status_badge_class_returns_non_empty() { - let post = sample_post(); - assert!(!post.status_badge_class().is_empty()); + let mut post = sample_post(); + post.status = PostStatus::Published; + assert_eq!( + post.status_badge_class(), + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" + ); + post.status = PostStatus::Draft; + assert_eq!( + post.status_badge_class(), + "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]" + ); } #[test] diff --git a/src/theme.rs b/src/theme.rs index 74b3f1f..873d7f1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; #[allow(dead_code)] const THEME_KEY: &str = "yggdrasil-theme"; -#[derive(Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum Theme { Light, Dark, @@ -163,3 +163,18 @@ pub fn ThemeToggle() -> Element { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggle_switches_light_to_dark() { + assert_eq!(Theme::Light.toggle(), Theme::Dark); + } + + #[test] + fn toggle_switches_dark_to_light() { + assert_eq!(Theme::Dark.toggle(), Theme::Light); + } +} diff --git a/src/utils/text.rs b/src/utils/text.rs index 243df5b..21f4278 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -109,12 +109,7 @@ mod tests { fn strip_markdown_mixed() { let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n![img](img.png)"; let result = strip_markdown(md); - assert!(result.contains("Title")); - assert!(result.contains("bold")); - assert!(result.contains("link")); - assert!(!result.contains("img")); - assert!(!result.contains("**")); - assert!(!result.contains("`")); + assert_eq!(result, "Title Some bold and link"); } #[test] diff --git a/src/webp.rs b/src/webp.rs index ca6d192..b16de05 100644 --- a/src/webp.rs +++ b/src/webp.rs @@ -174,10 +174,7 @@ mod tests { } #[test] - fn config_parses_valid_env_vars() { - // Note: LazyLock initializes once, so we can't easily test - // different env values in the same process. - // Test that the default config is reasonable + fn config_default_values_are_reasonable() { let config = WebpConfig { quality: 85.0, method: 2, @@ -188,7 +185,6 @@ mod tests { #[test] fn config_clamping_logic() { - // Test the clamping logic independently let quality = 150.0f32; let clamped = quality.clamp(0.0, 100.0); assert_eq!(clamped, 100.0); @@ -201,4 +197,12 @@ mod tests { let clamped = method.clamp(0, 6); assert_eq!(clamped, 6); } + + #[test] + fn config_clamps_edge_cases() { + assert_eq!(0.0f32.clamp(0.0, 100.0), 0.0); + assert_eq!(100.0f32.clamp(0.0, 100.0), 100.0); + assert_eq!(0u8.clamp(0, 6), 0); + assert_eq!(6u8.clamp(0, 6), 6); + } }