test(health): PII 加密集成测试 + 性能基准 + 编译修复
- 10 个集成测试: CRUD 加密流(8) + 多租户隔离(2) - 3 个性能基准: encrypt avg 17μs, decrypt avg 14μs, 批量50条 877μs - 8 个 key_manager 单元测试 + 4 个 masking 边界测试 - 迁移: 加宽 emergency_contact_phone/phone/license_number/result 列 - 修复: follow_up_service.create_record 返回密文改为解密返回 - 修复: consultation_service/patient_service HealthError::NotFound 引用
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use dashmap::DashMap;
|
||||
@@ -105,7 +104,6 @@ impl DekManager {
|
||||
|
||||
fn evict_if_full(&self) {
|
||||
if self.cache.len() >= self.max_entries {
|
||||
// 简单策略:移除最早加载的一半
|
||||
let to_remove: Vec<Uuid> = self.cache
|
||||
.iter()
|
||||
.filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2)
|
||||
@@ -118,3 +116,90 @@ impl DekManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::PiiCrypto;
|
||||
|
||||
fn test_kek() -> [u8; 32] {
|
||||
*PiiCrypto::dev_default().kek()
|
||||
}
|
||||
|
||||
fn test_uuid(i: u8) -> Uuid {
|
||||
let s = format!("00000000-0000-0000-0000-0000000000{:02x}", i);
|
||||
Uuid::parse_str(&s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_new_dek_returns_32_bytes() {
|
||||
let (dek, _enc) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||
assert_eq!(dek.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_new_dek_produces_unique_keys() {
|
||||
let (dek1, _) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||
let (dek2, _) = DekManager::generate_new_dek(&test_kek()).unwrap();
|
||||
assert_ne!(dek1, dek2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_dek_roundtrip() {
|
||||
let kek = test_kek();
|
||||
let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(1);
|
||||
let (recovered_dek, _ver) = mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek).unwrap();
|
||||
assert_eq!(original_dek, recovered_dek);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_create_generates_when_none() {
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(2);
|
||||
let (dek1, ver1) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
assert_eq!(ver1, 1);
|
||||
let (dek2, ver2) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
assert_eq!(dek1, dek2);
|
||||
assert_eq!(ver2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidate_removes_cached_dek() {
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(3);
|
||||
let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
mgr.invalidate(tenant_id);
|
||||
let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
assert_ne!(dek1, dek2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_with_wrong_kek_fails() {
|
||||
let kek1 = test_kek();
|
||||
let kek2 = [0xffu8; 32];
|
||||
let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(4);
|
||||
assert!(mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_entry_not_returned() {
|
||||
let mgr = DekManager::new(0, 100);
|
||||
let tenant_id = test_uuid(5);
|
||||
let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap();
|
||||
assert_ne!(dek1, dek2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_entries_eviction() {
|
||||
let mgr = DekManager::new(300, 3);
|
||||
for i in 0..5u8 {
|
||||
let _ = mgr.get_or_create_dek(test_uuid(i), None, &test_kek()).unwrap();
|
||||
}
|
||||
assert!(mgr.cache.len() <= 6);
|
||||
}
|
||||
}
|
||||
|
||||
64
crates/erp-core/src/crypto/masking.rs
Normal file
64
crates/erp-core/src/crypto/masking.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代
|
||||
pub fn mask_id_number(s: &str) -> String {
|
||||
if s.len() >= 7 {
|
||||
format!("{}****{}", &s[..3], &s[s.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代
|
||||
pub fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||
s.map(|p| {
|
||||
if p.len() >= 7 {
|
||||
format!("{}****{}", &p[..3], &p[p.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_id_18_digits() {
|
||||
assert_eq!("110****1234", mask_id_number("110101199001011234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_short() {
|
||||
assert_eq!("****", mask_id_number("123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_empty() {
|
||||
assert_eq!("****", mask_id_number(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_normal() {
|
||||
assert_eq!(Some("138****5678".to_string()), mask_phone(Some("13812345678")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_none() {
|
||||
assert_eq!(None, mask_phone(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_short() {
|
||||
assert_eq!(Some("****".to_string()), mask_phone(Some("123")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_exactly_7() {
|
||||
assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_exactly_7() {
|
||||
assert_eq!("123****4567", mask_id_number("1234567"));
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,64 @@ mod tests {
|
||||
let decrypted = decrypt(crypto.kek(), &encrypted).unwrap();
|
||||
assert_eq!(plaintext, decrypted);
|
||||
}
|
||||
|
||||
// ── 性能基准 ──
|
||||
|
||||
#[test]
|
||||
fn bench_encrypt_1000() {
|
||||
let crypto = test_crypto();
|
||||
let kek = crypto.kek();
|
||||
let plaintext = "13812345678";
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let _ = encrypt(kek, plaintext).unwrap();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"encrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_decrypt_1000() {
|
||||
let crypto = test_crypto();
|
||||
let kek = crypto.kek();
|
||||
let ciphertext = encrypt(kek, "13812345678").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let _ = decrypt(kek, &ciphertext).unwrap();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"decrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_batch_decrypt_50() {
|
||||
let crypto = test_crypto();
|
||||
let kek = crypto.kek();
|
||||
let ciphertexts: Vec<String> = (0..50)
|
||||
.map(|i| encrypt(kek, &format!("数据{}", i)).unwrap())
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
for ct in &ciphertexts {
|
||||
let _ = decrypt(kek, ct).unwrap();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed.as_millis() < 10,
|
||||
"批量解密 50 条应 < 10ms, 实际: {}ms",
|
||||
elapsed.as_millis()
|
||||
);
|
||||
eprintln!("batch decrypt 50 条: {:?}", elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user