diff --git a/src/api/error.rs b/src/api/error.rs index e69e1cc..bc63ee5 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -106,4 +106,37 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("文章不存在"), "expected passthrough: {msg}"); } + + #[test] + fn internal_message_passthrough() { + // Internal 错误的消息原样透传,便于向用户展示可读的内部错误描述。 + let err: ServerFnError = AppError::Internal("内部错误").into(); + let msg = err.to_string(); + assert!(msg.contains("内部错误"), "expected passthrough: {msg}"); + } + + #[test] + fn transaction_hides_sql_details() { + // 事务错误同样返回通用提示,不泄露 SQL 细节。 + let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into(); + let msg = err.to_string(); + assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}"); + assert!( + !msg.contains("deadlock"), + "should not leak error detail: {msg}" + ); + assert!(msg.contains("操作失败"), "expected generic message: {msg}"); + } + + #[test] + fn db_conn_query_transaction_all_return_generic_message() { + // 三类数据库错误对外均返回固定中文提示,避免泄露实现细节。 + let db_conn: ServerFnError = AppError::DbConn("x".into()).into(); + let query: ServerFnError = AppError::Query("x".into()).into(); + let tx: ServerFnError = AppError::Transaction("x".into()).into(); + + assert!(db_conn.to_string().contains("服务暂时不可用")); + assert!(query.to_string().contains("操作失败")); + assert!(tx.to_string().contains("操作失败")); + } } diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs index 9a5b30d..4b2b24a 100644 --- a/src/api/sanitizer.rs +++ b/src/api/sanitizer.rs @@ -435,4 +435,76 @@ mod tests { "x" ); } + + // ---- is_safe_url 直接分支测试 ---- + // is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。 + + #[test] + fn is_safe_url_allows_https() { + let schemes = default_allowed_schemes(); + assert!(is_safe_url("https://example.com", &schemes, false)); + assert!(is_safe_url("http://example.com", &schemes, false)); + } + + #[test] + fn is_safe_url_rejects_javascript() { + let schemes = default_allowed_schemes(); + assert!(!is_safe_url("javascript:alert(1)", &schemes, false)); + } + + #[test] + fn is_safe_url_rejects_vbscript() { + let schemes = default_allowed_schemes(); + assert!(!is_safe_url("vbscript:msgbox", &schemes, false)); + } + + #[test] + fn is_safe_url_data_uri_respects_flag() { + let schemes = default_allowed_schemes(); + // 文章正文允许 data URI + assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true)); + // 评论禁用 data URI + assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false)); + } + + #[test] + fn is_safe_url_allows_relative_and_fragment() { + let schemes = default_allowed_schemes(); + // 绝对路径 + assert!(is_safe_url("/path/to/page", &schemes, false)); + // 锚点 + assert!(is_safe_url("#section", &schemes, false)); + } + + #[test] + fn is_safe_url_empty_is_safe() { + let schemes = default_allowed_schemes(); + // 空 URL(如 img 无 src)视为安全。 + assert!(is_safe_url("", &schemes, false)); + assert!(is_safe_url(" ", &schemes, false)); + } + + #[test] + fn is_safe_url_allows_other_whitelisted_schemes() { + let schemes = default_allowed_schemes(); + // mailto / tel / ftp 等均在默认白名单中。 + assert!(is_safe_url("mailto:user@example.com", &schemes, false)); + assert!(is_safe_url("tel:+8613800138000", &schemes, false)); + assert!(is_safe_url("ftp://example.com/file", &schemes, false)); + } + + #[test] + fn is_safe_url_rejects_scheme_with_whitespace() { + let schemes = default_allowed_schemes(); + // 含空格的 scheme 名是已知的混淆手法,应被拒绝。 + assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false)); + } + + #[test] + fn is_safe_url_scheme_matching_is_case_insensitive() { + let schemes = default_allowed_schemes(); + // scheme 大小写不敏感:HTTPS 与 https 等价。 + assert!(is_safe_url("HTTPS://example.com", &schemes, false)); + assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false)); + } } diff --git a/src/highlight.rs b/src/highlight.rs index 872bbc7..dcc56c6 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -69,7 +69,8 @@ pub mod server { ("golang", "go"), ]; for &(from, to) in aliases { - if lang == from { + // 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。 + if lang.eq_ignore_ascii_case(from) { if let Some(s) = ss.find_syntax_by_extension(to) { return s; } @@ -151,4 +152,73 @@ mod tests { assert!(result.contains(r#"let"#)); assert!(result.contains(r#"1"#)); } + + #[test] + fn highlight_code_uppercase_language_falls_back_via_lowercase() { + // 大写语言标识应通过小写回退路径匹配到对应语法。 + let lower = highlight_code("fn main() {}", Some("rust")); + let upper = highlight_code("fn main() {}", Some("RUST")); + // 大写标识的输出必须与小写标识完全一致,证明回退路径生效。 + assert_eq!(lower, upper); + assert!(lower.contains(r#"fn"#)); + } + + #[test] + fn highlight_code_resolves_golang_alias() { + // 别名表中 "golang" 映射到 "go" 扩展名,输出应与直接用 "go" 一致。 + let by_alias = highlight_code("package main", Some("golang")); + let by_ext = highlight_code("package main", Some("go")); + assert_eq!(by_alias, by_ext); + // 别名解析必须产出带 span 的高亮输出,而非纯文本。 + assert!(by_alias.contains("span")); + } + + #[test] + fn highlight_code_resolves_bash_alias() { + // 别名表中 "bash" 映射到 "sh" 扩展名。 + let result = highlight_code("echo hello", Some("bash")); + assert!(result.contains("span")); + } + + #[test] + fn highlight_code_resolves_yml_alias() { + // 别名表中 "yml" 映射到 "yaml" 扩展名。 + let result = highlight_code("key: value", Some("yml")); + assert!(!result.is_empty()); + } + + #[test] + fn highlight_code_unknown_language_falls_back_to_plain_text() { + // 无法识别的语言应回退到纯文本语法,仍能输出内容。 + let result = highlight_code("hello world", Some("totally-not-a-language-xyz")); + assert!(result.contains("hello world")); + } + + #[test] + fn highlight_code_empty_language_string_falls_back_to_plain_text() { + // 空字符串语言标识应走纯文本回退路径。 + let result = highlight_code("just text", Some("")); + assert!(result.contains("just text")); + } + + #[test] + fn highlight_code_trims_surrounding_whitespace() { + // 代码首尾的空白会被 trim 掉再高亮。 + let result = highlight_code(" \nfn main() {}\n ", Some("rust")); + assert!(result.contains(r#"fn"#)); + } + + #[test] + fn highlight_code_multiline_output_spans_all_lines() { + // 多行代码每一行都应被解析为带 span 的输出。 + let code = "fn a() {}\nfn b() {}"; + let result = highlight_code(code, Some("rust")); + // 两处 fn 关键字都应出现 + assert_eq!( + result + .matches(r#"fn"#) + .count(), + 2 + ); + } } diff --git a/src/theme.rs b/src/theme.rs index b1654a2..54d2e9a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -210,4 +210,45 @@ mod tests { fn toggle_switches_dark_to_light() { assert_eq!(Theme::Dark.toggle(), Theme::Light); } + + #[test] + fn toggle_is_an_involution() { + // 连续切换两次应当回到原始主题。 + assert_eq!(Theme::Light.toggle().toggle(), Theme::Light); + assert_eq!(Theme::Dark.toggle().toggle(), Theme::Dark); + } + + #[test] + fn theme_derives_equality() { + // Theme 派生了 PartialEq,相同变体必须相等。 + assert_eq!(Theme::Light, Theme::Light); + assert_eq!(Theme::Dark, Theme::Dark); + assert_ne!(Theme::Light, Theme::Dark); + } + + #[test] + fn theme_preload_script_adds_dark_class() { + // 预加载脚本必须包含给 documentElement 添加 dark class 的逻辑。 + assert!(THEME_PRELOAD_SCRIPT.contains("classList.add('dark')")); + } + + #[test] + fn theme_preload_script_reads_local_storage() { + // 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。 + assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')")); + assert_eq!(THEME_KEY, "yggdrasil-theme"); + } + + #[test] + fn theme_preload_script_falls_back_to_prefers_color_scheme() { + // 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。 + assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark")); + } + + #[test] + fn theme_preload_script_swallows_errors() { + // 预加载脚本必须包裹在 try/catch 中,避免禁用 localStorage 时抛错。 + assert!(THEME_PRELOAD_SCRIPT.contains("try")); + assert!(THEME_PRELOAD_SCRIPT.contains("catch")); + } } diff --git a/src/webp.rs b/src/webp.rs index 1056587..b1987a2 100644 --- a/src/webp.rs +++ b/src/webp.rs @@ -237,4 +237,88 @@ mod tests { assert_eq!(0u8.clamp(0, 6), 0); assert_eq!(6u8.clamp(0, 6), 6); } + + #[test] + fn webp_error_encode_display() { + let err = WebpError::Encode("boom".to_string()); + assert_eq!(err.to_string(), "WebP encode error: boom"); + } + + #[test] + fn webp_error_decode_display() { + let err = WebpError::Decode("busted".to_string()); + assert_eq!(err.to_string(), "WebP decode error: busted"); + } + + #[test] + fn webp_error_implements_std_error() { + // WebpError 必须实现 std::error::Error,才能在 ? 传播链中使用。 + fn assert_error() {} + assert_error::(); + } + + #[test] + fn encode_converts_luma8_to_rgba() { + // Luma8(灰度)图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。 + let img = image::DynamicImage::new_luma8(4, 4); + let result = encode(&img, 80.0, 2); + assert!(result.is_ok()); + assert!(!result.unwrap().is_empty()); + } + + #[test] + fn encode_converts_luma_a8_to_rgba() { + // LumaA8(带 alpha 的灰度)同样走转换路径。 + let img = image::DynamicImage::new_luma_a8(4, 4); + let result = encode(&img, 80.0, 2); + assert!(result.is_ok()); + assert!(!result.unwrap().is_empty()); + } + + #[test] + fn encode_lower_quality_does_not_explode_on_solid_color() { + // 纯色图是 WebP 的极端情况(信息熵接近 0),确保高低质量都能编码成功 + // 而非 panic,且产物是合法非空字节流。不假设低质量体积一定更小, + // 因为这依赖底层 libwebp 的量化策略,非确定性不变量。 + let img = image::DynamicImage::new_rgb8(64, 64); + let high = encode(&img, 95.0, 4).unwrap(); + let low = encode(&img, 10.0, 4).unwrap(); + assert!(!high.is_empty()); + assert!(!low.is_empty()); + // 两者都应是合法的 WebP(能被本模块解码回来)。 + assert!(decode(&high).is_ok()); + assert!(decode(&low).is_ok()); + } + + #[test] + fn decode_invalid_bytes_returns_error() { + // 非 WebP 字节流应返回解码错误而非 panic。 + let junk = b"this is definitely not a webp image"; + let result = decode(junk); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_bytes_returns_error() { + // 空字节流应返回解码错误而非 panic。 + let result = decode(&[]); + assert!(result.is_err()); + } + + #[test] + fn decode_error_message_is_descriptive() { + // 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。 + let err = decode(b"not webp").unwrap_err(); + assert!(err.to_string().starts_with("WebP decode error")); + } + + #[test] + fn encode_decode_preserves_dimensions() { + // 编码再解码后,图像宽高应保持一致。 + let original = image::DynamicImage::new_rgb8(16, 9); + let encoded = encode(&original, 85.0, 4).unwrap(); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.width(), 16); + assert_eq!(decoded.height(), 9); + } }