test(health): PII 加密集成测试 + 性能基准 + 编译修复
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-04-26 13:10:53 +08:00
parent 17b423b9b8
commit ebc0f20e33
10 changed files with 1211 additions and 4 deletions

View File

@@ -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);
}
}

View 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"));
}
}

View File

@@ -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);
}
}