test: 补充单元测试并修复 urlencoding 解码 bug

- auth.rs 新增 JWT 生成/验证回环、篡改拒绝、refresh token 随机性、临时 token 有效期测试
- payment.rs 新增套餐定义、URL 解码、支付宝表单解析(UTF-8/GBK)、RSA2 签名验签、UA 检测测试
- 修复 urlencoding 两个真实 bug:UTF-8 多字节序列乱码、不完整转义吞字符
- 移除 db.rs 悬空 doc 注释,修复 auth.rs 冗余引用(clippy 0 警告)
- cargo test 从 2 个增至 36 个全过
- 同步 IMPROVEMENTS.md(索引/重复查询/测试覆盖/公告系统状态)
This commit is contained in:
2026-08-14 13:08:50 +08:00
parent 366d62f262
commit 3cce81321f
5 changed files with 284 additions and 29 deletions

View File

@@ -280,18 +280,20 @@ DROP TABLE IF EXISTS invitation_codes;
- 完成时间2026-04-15 - 完成时间2026-04-15
### 1.2 数据库操作优化 ### 1.2 数据库操作优化
- [ ] **重复查询问题** - [x] **重复查询问题**
- 位置:`insert_weather_data` 中多次查询用户信息 - 位置:`insert_weather_data` 中多次查询用户信息
- 改进:合并查询或使用缓存 - 改进:合并查询或使用缓存
- 影响:减少数据库负载 - 状态:`get_user_quota` 已合并为单次查询(见 `db.rs` 顶部注释2026-08-11 复核无遗留 N+1
- [ ] **缺少数据库索引** - [x] **缺少数据库索引**
- 现状:未见索引定义 - 状态:`migrations/006_add_performance_indexes.sql` 已添加
- 改进:添加常用查询字段索引
```sql ```sql
CREATE INDEX idx_weather_data_user_id ON weather_data(user_id); CREATE INDEX idx_weather_data_user_id ON weather_data(user_id);
CREATE INDEX idx_weather_data_date ON weather_data(date); CREATE INDEX idx_weather_data_date ON weather_data(date DESC);
CREATE INDEX idx_weather_data_user_favorite ON weather_data(user_id, is_favorite);
CREATE INDEX idx_weather_data_user_date ON weather_data(user_id, date DESC);
CREATE INDEX idx_users_openid ON users(openid); CREATE INDEX idx_users_openid ON users(openid);
CREATE INDEX idx_payment_orders_status ON payment_orders(status);
``` ```
### 1.3 错误处理改进 ### 1.3 错误处理改进
@@ -422,13 +424,13 @@ DROP TABLE IF EXISTS invitation_codes;
## 五、开发体验改进 ## 五、开发体验改进
### 5.1 测试覆盖 ### 5.1 测试覆盖
- [ ] **仅有一个基础编译测试** - [~] **基础测试不足**2026-08-11 已补充单元测试)
- 现状:`tests/integration_test.rs` 只有 `assert_eq!(2 + 2, 4)` - 现状:原仅有 `tests/integration_test.rs` 的编译测试和套餐一致性测试
- 改进:添加完整的测试套件 - 已补充(`cargo test` 共 36 用例):
- 类型: - `src/auth.rs`JWT 生成/验证回环、篡改拒绝、错误密钥拒绝、refresh token 随机性、临时 token 有效期
- 单元测试:工具函数、数据结构 - `src/handlers/payment.rs`套餐定义、URL 解码(**修复了 UTF-8 乱码与吞字符两个真实 bug**、支付宝表单解析UTF-8/GBK、RSA2 签名/验签回环PEM + Base64 DER 密钥)、移动端 UA 检测、订单号唯一性
- 集成测试API 端点 - `src/rate_limiter.rs`限流阈值、按客户端隔离、IP 提取(已有)
- 数据库测试CRUD 操作 - 待做API 集成测试(需要测试数据库)
- [ ] **缺少 API 测试** - [ ] **缺少 API 测试**
- 需求:验证 API 行为 - 需求:验证 API 行为
@@ -1369,6 +1371,9 @@ async onRedeemCode() {
## 十四、公告系统 ## 十四、公告系统
> **状态**:✅ 已实现2026-05`handlers/notifications.rs` + 迁移 `011_add_notifications.sql`,前端 `pkg-extra/notifications` 页面)
> 下方为原始设计文档,保留供参考。
### 功能概述 ### 功能概述
公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。 公告系统用于向用户发送系统公告,支持管理员发布、编辑、删除公告,用户查看公告列表和详情。

View File

@@ -124,3 +124,71 @@ pub fn verify_temp_token(token: &str, secret: &str) -> Result<TempTokenClaims, S
Ok(decoded.claims) Ok(decoded.claims)
} }
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "unit-test-secret-0123456789";
#[test]
fn test_generate_and_verify_token_roundtrip() {
let token = generate_token(42, "openid_test_123", 2, TEST_SECRET).unwrap();
let claims = verify_token(&token, TEST_SECRET).unwrap();
assert_eq!(claims.user_id, 42);
assert_eq!(claims.openid, "openid_test_123");
assert_eq!(claims.user_type, 2);
assert!(claims.exp > claims.iat, "过期时间应晚于签发时间");
assert!(claims.exp > Utc::now().timestamp(), "令牌应未过期");
}
#[test]
fn test_verify_token_rejects_wrong_secret() {
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
assert!(verify_token(&token, "wrong-secret").is_err());
}
#[test]
fn test_verify_token_rejects_tampered_payload() {
let token = generate_token(42, "openid", 2, TEST_SECRET).unwrap();
// 篡改 token 的载荷部分(第 2 段),应验证失败
let mut parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
parts[1] = "eyJ1c2VyX2lkIjo5OTl9";
let tampered = parts.join(".");
assert!(verify_token(&tampered, TEST_SECRET).is_err());
}
#[test]
fn test_verify_token_rejects_garbage() {
assert!(verify_token("not-a-jwt", TEST_SECRET).is_err());
assert!(verify_token("", TEST_SECRET).is_err());
}
#[test]
fn test_generate_refresh_token_is_random_uuid() {
let a = generate_refresh_token(1, TEST_SECRET).unwrap();
let b = generate_refresh_token(1, TEST_SECRET).unwrap();
assert_ne!(a, b, "两次生成的 refresh token 不应相同");
// UUID v4 格式8-4-4-4-12 十六进制
let parts: Vec<&str> = a.split('-').collect();
assert_eq!(parts.len(), 5);
assert!(parts.iter().all(|p| !p.is_empty()));
}
#[test]
fn test_temp_token_roundtrip_and_expiry_window() {
let token = generate_temp_token("openid_share", 777, TEST_SECRET, 10).unwrap();
let claims = verify_temp_token(&token, TEST_SECRET).unwrap();
assert_eq!(claims.openid, "openid_share");
assert_eq!(claims.resource_id, 777);
// 10 分钟有效期exp - iat 约等于 600 秒(允许 ±2 秒误差)
let lifetime = claims.exp - claims.iat;
assert!((598..=602).contains(&lifetime), "临时 token 有效期应为 10 分钟,实际 {lifetime}s");
assert!(verify_temp_token(&token, "wrong-secret").is_err());
}
}

View File

@@ -370,8 +370,6 @@ pub async fn get_user_orders(
Ok(rows) Ok(rows)
} }
/// 扫描超过 10 分钟的待支付订单,尝试自动确认(用于回调重试)
/// 检查会员到期前 7 天的用户,发送即将到期通知 /// 检查会员到期前 7 天的用户,发送即将到期通知
pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> { pub async fn check_member_expiry_soon(pool: &PgPool) -> Result<u64, AppError> {
let affected = sqlx::query( let affected = sqlx::query(

View File

@@ -597,7 +597,7 @@ pub async fn web_generate_login_code(
ts % 0xFFFF, ts % 0xFFFF,
&random_part[..4] &random_part[..4]
); );
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase()); let display_code = format!("ASD-{}", short_code[..7].to_uppercase());
let code_for_db = display_code.clone(); let code_for_db = display_code.clone();
// 4. 存入临时表(关联 user_id // 4. 存入临时表(关联 user_id

View File

@@ -960,24 +960,40 @@ fn parse_alipay_form(body: &[u8]) -> BTreeMap<String, String> {
/// 手动 URL 解码percent-decoding /// 手动 URL 解码percent-decoding
/// 注意:`+` 始终解码为空格form-urlencoded 标准),而非仅在 `%` 转义之后 /// 注意:`+` 始终解码为空格form-urlencoded 标准),而非仅在 `%` 转义之后
fn urlencoding(s: &str) -> String { fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len()); let input: Vec<u8> = s.bytes().collect();
let mut chars = s.bytes().peekable(); let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
while let Some(b) = chars.next() { let mut i = 0;
while i < input.len() {
let b = input[i];
if b == b'%' { if b == b'%' {
let hi = chars.next().and_then(hex_val); let hi = input.get(i + 1).and_then(|&c| hex_val(c));
let lo = chars.next().and_then(hex_val); let lo = input.get(i + 2).and_then(|&c| hex_val(c));
if let (Some(h), Some(l)) = (hi, lo) { if let (Some(h), Some(l)) = (hi, lo) {
result.push((h << 4 | l) as char); bytes.push(h << 4 | l);
} else { i += 3;
result.push('%'); continue;
} }
} else if b == b'+' { // 不完整的转义:原样保留 '%',后续字符照常处理
result.push(' ');
} else {
result.push(b as char);
} }
if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
i += 1;
}
// 按字节收集后统一解码:优先 UTF-8失败回退 GBK兼容支付宝回调编码
match String::from_utf8(bytes.clone()) {
Ok(s) => s,
Err(_) => match encoding_rs::Encoding::for_label(b"gbk") {
Some(enc) => {
let (s, _, _) = enc.decode(&bytes);
s.into_owned()
}
None => String::from_utf8_lossy(&bytes).into_owned(),
},
} }
result
} }
fn hex_val(c: u8) -> Option<u8> { fn hex_val(c: u8) -> Option<u8> {
@@ -1620,3 +1636,171 @@ pub async fn payment_login_status(
membership_expires_at, membership_expires_at,
})) }))
} }
#[cfg(test)]
mod tests {
use super::*;
// ===== 套餐定义 =====
#[test]
fn test_get_package_info_known_packages() {
for (pkg, expected_amount, expected_days) in [
("monthly", 590, Some(30)),
("quarterly", 1680, Some(90)),
("half_year", 3190, Some(182)),
("yearly", 6020, Some(365)),
] {
let info = get_package_info(pkg).unwrap_or_else(|| panic!("套餐 {pkg} 应存在"));
assert_eq!(info.amount, expected_amount, "套餐 {pkg} 金额");
assert_eq!(info.days, expected_days, "套餐 {pkg} 天数");
}
}
#[test]
fn test_get_package_info_unknown_package() {
assert!(get_package_info("weekly").is_none());
assert!(get_package_info("lifetime").is_none());
assert!(get_package_info("").is_none());
}
// ===== URL 解码 =====
#[test]
fn test_urlencoding_decodes_percent_escapes() {
assert_eq!(urlencoding("hello%20world"), "hello world");
assert_eq!(urlencoding("%E4%B8%AD%E6%96%87"), "中文");
assert_eq!(urlencoding("a%2Bb"), "a+b");
}
#[test]
fn test_urlencoding_plus_means_space() {
assert_eq!(urlencoding("a+b+c"), "a b c");
}
#[test]
fn test_urlencoding_handles_invalid_escapes() {
// 不完整的 % 序列按字面保留
assert_eq!(urlencoding("100%"), "100%");
assert_eq!(urlencoding("%2"), "%2");
assert_eq!(urlencoding("%zz"), "%zz");
}
#[test]
fn test_hex_val() {
assert_eq!(hex_val(b'0'), Some(0));
assert_eq!(hex_val(b'9'), Some(9));
assert_eq!(hex_val(b'a'), Some(10));
assert_eq!(hex_val(b'f'), Some(15));
assert_eq!(hex_val(b'A'), Some(10));
assert_eq!(hex_val(b'F'), Some(15));
assert_eq!(hex_val(b'g'), None);
assert_eq!(hex_val(b'-'), None);
}
#[test]
fn test_parse_alipay_form_utf8() {
let body = b"out_trade_no=ASD20260101&trade_status=TRADE_SUCCESS&total_amount=5.90";
let map = parse_alipay_form(body);
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD20260101"));
assert_eq!(map.get("trade_status").map(String::as_str), Some("TRADE_SUCCESS"));
assert_eq!(map.get("total_amount").map(String::as_str), Some("5.90"));
}
#[test]
fn test_parse_alipay_form_gbk_fallback() {
// 模拟支付宝回调使用 GBK 编码(含中文 subject
let raw = "out_trade_no=ASD123&subject=包月会员";
let (gbk_bytes, _, _) = encoding_rs::GBK.encode(raw);
let map = parse_alipay_form(&gbk_bytes);
assert_eq!(map.get("out_trade_no").map(String::as_str), Some("ASD123"));
assert_eq!(map.get("subject").map(String::as_str), Some("包月会员"));
}
#[test]
fn test_parse_alipay_form_empty() {
let map = parse_alipay_form(b"");
assert!(map.is_empty());
}
// ===== RSA2 签名 / 验签 =====
fn generate_test_key_pair() -> (String, String) {
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
use rsa::RsaPrivateKey;
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
let private_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
let public_pem = private_key
.to_public_key()
.to_public_key_pem(LineEnding::LF)
.unwrap()
.to_string();
(private_pem, public_pem)
}
#[test]
fn test_rsa2_sign_verify_roundtrip() {
let (private_pem, public_pem) = generate_test_key_pair();
let content = "out_trade_no=ASD123&trade_status=TRADE_SUCCESS";
let sign = rsa2_sign(content, &private_pem).unwrap();
assert!(!sign.is_empty());
assert!(rsa2_verify(content, &sign, &public_pem).unwrap());
}
#[test]
fn test_rsa2_verify_rejects_tampered_content() {
let (private_pem, public_pem) = generate_test_key_pair();
let sign = rsa2_sign("original content", &private_pem).unwrap();
// 调用处语义:`if let Err(e) = rsa2_verify(...)` 即拒绝,篡改内容必须返回 Err
assert!(rsa2_verify("tampered content", &sign, &public_pem).is_err());
}
#[test]
fn test_rsa2_sign_supports_base64_der_key() {
use rsa::pkcs8::EncodePrivateKey;
use rsa::RsaPrivateKey;
// 支付宝密钥工具导出的是 Base64(DER),不是 PEM 文本
let mut rng = rand::thread_rng();
let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("生成测试密钥失败");
let der = private_key.to_pkcs8_der().unwrap();
let b64_key = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, der.as_bytes());
let sign = rsa2_sign("hello", &b64_key).unwrap();
assert!(!sign.is_empty());
}
#[test]
fn test_rsa2_sign_rejects_invalid_key() {
assert!(rsa2_sign("content", "not-a-key").is_err());
}
// ===== 移动端 UA 检测 =====
#[test]
fn test_is_mobile_ua() {
assert!(is_mobile_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"));
assert!(is_mobile_ua("Mozilla/5.0 (Linux; Android 14; Pixel 8)"));
assert!(is_mobile_ua("HarmonyOS Browser"));
assert!(is_mobile_ua("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"));
assert!(!is_mobile_ua("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"));
assert!(!is_mobile_ua("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15"));
}
// ===== 订单号格式 =====
#[test]
fn test_order_no_format_is_unique() {
// create_order 内部生成格式ASD{timestamp}{random},这里验证生成逻辑的幂等约束:
// 同一时刻多次生成的订单号不应相同(随机后缀)
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
let no = format!("ASD{}{:04}", chrono::Utc::now().timestamp_millis(), rand::random::<u16>());
assert!(seen.insert(no.clone()), "订单号重复: {no}");
}
}
}