Files
hms/crates/erp-server/tests/integration/health_pii_encryption_tests.rs
iven ebc0f20e33
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
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 引用
2026-04-26 13:10:53 +08:00

636 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PII 分级加密集成测试
//!
//! 验证加密/解密流程:创建时 Tier 1 字段加密存储、详情接口解密返回、
//! 列表接口隐藏 Tier 1 字段、HMAC 精确搜索、医生执业证号搜索重写。
use chrono::NaiveDate;
use erp_core::crypto::PiiCrypto;
use erp_core::events::EventBus;
use erp_health::dto::consultation_dto::{CreateMessageReq, CreateSessionReq};
use erp_health::dto::doctor_dto::CreateDoctorReq;
use erp_health::dto::follow_up_dto::{CreateFollowUpRecordReq, CreateFollowUpTaskReq};
use erp_health::dto::patient_dto::CreatePatientReq;
use erp_health::service::{
consultation_service, doctor_service, follow_up_service, patient_service,
};
use erp_health::state::HealthState;
use sea_orm::EntityTrait;
use uuid::Uuid;
use super::test_db::TestDb;
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
HealthState {
db: db.clone(),
event_bus: EventBus::new(100),
crypto: PiiCrypto::dev_default(),
}
}
fn sample_patient_req() -> CreatePatientReq {
CreatePatientReq {
name: "加密测试患者".to_string(),
gender: Some("male".to_string()),
birth_date: Some(NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()),
blood_type: Some("A".to_string()),
id_number: Some("110101199001151234".to_string()),
allergy_history: Some("青霉素、磺胺类药物过敏".to_string()),
medical_history_summary: Some("高血压 5 年".to_string()),
emergency_contact_name: Some("紧急联系人".to_string()),
emergency_contact_phone: Some("13812345678".to_string()),
source: Some("offline".to_string()),
notes: None,
}
}
// ── 1. Patient: 创建后 DB 中 Tier 1 字段为密文 ──
#[tokio::test]
async fn test_patient_tier1_fields_encrypted_in_db() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let req = sample_patient_req();
let patient = patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
// 直接查 DB 验证 id_number 是密文Base64 格式,非明文)
let row: Option<erp_health::entity::patient::Model> =
erp_health::entity::patient::Entity::find_by_id(patient.id)
.one(test_db.db())
.await
.expect("DB 查询应成功");
let row = row.expect("应找到记录");
// id_number 存储值不应包含明文
let stored_id = row.id_number.as_deref().unwrap_or("");
assert_ne!(stored_id, "110101199001151234", "身份证号不应以明文存储");
// AES-GCM 输出为 Base64不应有中文
assert!(
!stored_id.contains("1101"),
"密文不应包含身份证号片段"
);
// allergy_history 同理
let stored_allergy = row.allergy_history.as_deref().unwrap_or("");
assert!(
!stored_allergy.contains("青霉素"),
"过敏史不应以明文存储"
);
}
// ── 2. Patient: 详情接口返回解密明文 ──
#[tokio::test]
async fn test_patient_detail_returns_decrypted_fields() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let req = sample_patient_req();
let created = patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
let detail = patient_service::get_patient(&state, tenant_id, created.id)
.await
.expect("查询详情应成功");
// Tier 1 字段在详情视图返回解密后脱敏的值
assert_eq!(
detail.id_number.as_deref(),
Some("110****1234"),
"详情应返回脱敏的身份证号"
);
assert_eq!(
detail.emergency_contact_phone.as_deref(),
Some("138****5678"),
"详情应返回脱敏的紧急联系电话"
);
assert_eq!(
detail.allergy_history.as_deref(),
Some("青霉素、磺胺类药物过敏"),
"详情应返回解密的过敏史"
);
assert_eq!(
detail.medical_history_summary.as_deref(),
Some("高血压 5 年"),
"详情应返回解密的病史摘要"
);
}
// ── 3. Patient: 列表接口隐藏 Tier 1 字段 ──
#[tokio::test]
async fn test_patient_list_hides_tier1_fields() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let req = sample_patient_req();
patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
let list = patient_service::list_patients(&state, tenant_id, 1, 10, None, None)
.await
.expect("列表查询应成功");
assert_eq!(list.data.len(), 1);
let item = &list.data[0];
assert!(
item.id_number.is_none(),
"列表不应返回身份证号"
);
assert!(
item.allergy_history.is_none(),
"列表不应返回过敏史"
);
assert!(
item.medical_history_summary.is_none(),
"列表不应返回病史摘要"
);
assert!(
item.emergency_contact_phone.is_none(),
"列表不应返回紧急联系电话"
);
}
// ── 4. Patient: HMAC 精确搜索电话号码 ──
#[tokio::test]
async fn test_patient_hmac_search_by_phone() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let req = sample_patient_req();
patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
// 通过电话号码精确搜索
let result = patient_service::list_patients(
&state,
tenant_id,
1,
10,
Some("13812345678".to_string()),
None,
)
.await
.expect("电话搜索应成功");
assert_eq!(result.total, 1, "应通过电话号码找到患者");
assert_eq!(result.data[0].name, "加密测试患者");
// 搜索不存在的号码
let empty = patient_service::list_patients(
&state,
tenant_id,
1,
10,
Some("99999999999".to_string()),
None,
)
.await
.expect("搜索应成功");
assert_eq!(empty.total, 0, "不存在的号码不应有结果");
}
// ── 5. ConsultationMessage: content 加密存储 + 解密返回 ──
#[tokio::test]
async fn test_consultation_message_content_encrypted() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let sender_id = Uuid::now_v7();
// 先创建患者create_session 需要患者存在)
let patient = patient_service::create_patient(
&state,
tenant_id,
None,
CreatePatientReq {
name: "咨询测试患者".to_string(),
gender: Some("male".to_string()),
birth_date: None,
blood_type: None,
id_number: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
},
)
.await
.expect("创建患者应成功");
// 创建医生consultation_session.doctor_id 外键指向 doctor_profile.id
let doctor = doctor_service::create_doctor(
&state,
tenant_id,
None,
CreateDoctorReq {
user_id: None,
name: "咨询测试医生".to_string(),
department: None,
title: None,
specialty: None,
license_number: None,
bio: None,
},
)
.await
.expect("创建医生应成功");
// 创建 session
let session = consultation_service::create_session(
&state,
tenant_id,
None,
CreateSessionReq {
patient_id: patient.id,
doctor_id: Some(doctor.id),
consultation_type: Some("customer_service".to_string()),
},
)
.await
.expect("创建会话应成功");
let plain_content = "患者过敏史:青霉素、磺胺类药物";
let msg = consultation_service::create_message(
&state,
tenant_id,
Some(sender_id),
CreateMessageReq {
session_id: session.id,
sender_id,
sender_role: "patient".to_string(),
content_type: Some("text".to_string()),
content: plain_content.to_string(),
},
)
.await
.expect("创建消息应成功");
// API 返回的 content 应为明文
assert_eq!(msg.content, plain_content, "API 应返回解密后的明文");
// DB 中的 content 应为密文
let row: Option<erp_health::entity::consultation_message::Model> =
erp_health::entity::consultation_message::Entity::find_by_id(msg.id)
.one(test_db.db())
.await
.expect("DB 查询应成功");
let row = row.expect("应找到记录");
assert_ne!(row.content, plain_content, "DB 中 content 应为密文");
}
// ── 6. Doctor: license_number 加密 + HMAC 搜索重写 ──
#[tokio::test]
async fn test_doctor_license_number_encrypted_and_searchable() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
let doctor = doctor_service::create_doctor(
&state,
tenant_id,
None,
CreateDoctorReq {
user_id: None,
name: "张医生".to_string(),
department: Some("内科".to_string()),
title: Some("主任医师".to_string()),
specialty: None,
license_number: Some("YL-2024-00123".to_string()),
bio: None,
},
)
.await
.expect("创建医生应成功");
// 详情应返回解密的执业证号
assert_eq!(
doctor.license_number.as_deref(),
Some("YL-2024-00123"),
"详情应返回解密的执业证号"
);
// 列表视图 license_number 为 None
let list = doctor_service::list_doctors(
&state,
tenant_id,
1,
10,
Some("YL-2024-00123".to_string()),
None,
None,
)
.await
.expect("列表查询应成功");
assert_eq!(list.total, 1, "应通过执业证号 HMAC 搜索到医生");
// 列表视图的 license_number 为 NoneTier 1
assert!(list.data[0].license_number.is_none());
}
// ── 7. FollowUpRecord: result/patient_condition/medical_advice 加密 ──
#[tokio::test]
async fn test_follow_up_record_fields_encrypted() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
// 先创建患者create_task 需要患者存在)
let patient = patient_service::create_patient(
&state,
tenant_id,
None,
CreatePatientReq {
name: "随访测试患者".to_string(),
gender: Some("male".to_string()),
birth_date: None,
blood_type: None,
id_number: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
},
)
.await
.expect("创建患者应成功");
// 创建随访任务
let task = follow_up_service::create_task(
&state,
tenant_id,
None,
CreateFollowUpTaskReq {
patient_id: patient.id,
assigned_to: None,
follow_up_type: "phone".to_string(),
planned_date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(),
content_template: None,
related_appointment_id: None,
},
)
.await
.expect("创建随访任务应成功");
let record = follow_up_service::create_record(
&state,
tenant_id,
None,
CreateFollowUpRecordReq {
task_id: task.id,
executed_by: None,
executed_date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(),
result: "随访结果:病情稳定".to_string(),
patient_condition: Some("血压控制良好".to_string()),
medical_advice: Some("继续服药,定期复查".to_string()),
next_follow_up_date: None,
},
)
.await
.expect("创建随访记录应成功");
// API 应返回解密后的明文
assert_eq!(record.result, "随访结果:病情稳定");
assert_eq!(
record.patient_condition.as_deref(),
Some("血压控制良好")
);
assert_eq!(
record.medical_advice.as_deref(),
Some("继续服药,定期复查")
);
// DB 中应为密文
let row: Option<erp_health::entity::follow_up_record::Model> =
erp_health::entity::follow_up_record::Entity::find_by_id(record.id)
.one(test_db.db())
.await
.expect("DB 查询应成功");
let row = row.expect("应找到记录");
assert_ne!(row.result, "随访结果:病情稳定", "DB 中 result 应为密文");
}
// ── 8. Patient Family Member: phone 加密 + 脱敏返回 ──
#[tokio::test]
async fn test_family_member_phone_encrypted_and_masked() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = Uuid::now_v7();
// 先创建患者
let patient = patient_service::create_patient(
&state,
tenant_id,
None,
CreatePatientReq {
name: "家属测试患者".to_string(),
gender: Some("female".to_string()),
birth_date: None,
blood_type: None,
id_number: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
},
)
.await
.expect("创建患者应成功");
// 创建家庭成员
let family = patient_service::create_family_member(
&state,
tenant_id,
patient.id,
None,
erp_health::dto::patient_dto::FamilyMemberReq {
name: "家属王五".to_string(),
relationship: "spouse".to_string(),
phone: Some("13987654321".to_string()),
birth_date: None,
notes: None,
},
)
.await
.expect("创建家庭成员应成功");
// 返回的电话应为脱敏后的
assert_eq!(
family.phone.as_deref(),
Some("139****4321"),
"列表应返回脱敏的电话号码"
);
// DB 中的 phone 应为密文
let row: Option<erp_health::entity::patient_family_member::Model> =
erp_health::entity::patient_family_member::Entity::find_by_id(family.id)
.one(test_db.db())
.await
.expect("DB 查询应成功");
let row = row.expect("应找到记录");
assert_ne!(row.phone.as_deref(), Some("13987654321"), "DB 中 phone 应为密文");
}
// ═══════════════════════════════════════════════════════════════
// 多租户数据隔离测试
// ═══════════════════════════════════════════════════════════════
// ── 9. 租户间数据隔离:租户 B 无法读取租户 A 的加密患者 ──
#[tokio::test]
async fn test_tenant_isolation_encrypted_patient() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_a = Uuid::now_v7();
let tenant_b = Uuid::now_v7();
// 租户 A 创建患者
let patient_a = patient_service::create_patient(
&state,
tenant_a,
None,
CreatePatientReq {
name: "租户A患者".to_string(),
gender: Some("male".to_string()),
birth_date: None,
blood_type: None,
id_number: Some("110101199001011234".to_string()),
allergy_history: Some("青霉素过敏".to_string()),
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: Some("13811112222".to_string()),
source: None,
notes: None,
},
)
.await
.expect("租户 A 创建患者应成功");
// 租户 B 列表查询不应看到租户 A 的患者
let list_b = patient_service::list_patients(&state, tenant_b, 1, 10, None, None)
.await
.expect("租户 B 列表查询应成功");
assert_eq!(list_b.total, 0, "租户 B 不应看到租户 A 的患者");
// 租户 B 直接查 ID 应返回错误
let lookup = patient_service::get_patient(&state, tenant_b, patient_a.id).await;
assert!(lookup.is_err(), "租户 B 不应能查到租户 A 的患者");
// 租户 B 用租户 A 的电话搜索不应有结果
let search_b = patient_service::list_patients(
&state,
tenant_b,
1,
10,
Some("13811112222".to_string()),
None,
)
.await
.expect("搜索应成功");
assert_eq!(search_b.total, 0, "租户 B 不应通过电话搜索到租户 A 的患者");
}
// ── 10. 同一明文在不同租户下 HMAC hash 不同(因为 KEK 相同,但数据隔离正确) ──
#[tokio::test]
async fn test_cross_tenant_data_integrity() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_a = Uuid::now_v7();
let tenant_b = Uuid::now_v7();
// 两个租户都创建相同身份证号的患者(应各自成功,因为 tenant_id 不同)
let patient_a = patient_service::create_patient(
&state,
tenant_a,
None,
CreatePatientReq {
name: "租户A患者".to_string(),
gender: Some("male".to_string()),
birth_date: None,
blood_type: None,
id_number: Some("110101199001011234".to_string()),
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
},
)
.await
.expect("租户 A 创建患者应成功");
let patient_b = patient_service::create_patient(
&state,
tenant_b,
None,
CreatePatientReq {
name: "租户B患者".to_string(),
gender: Some("female".to_string()),
birth_date: None,
blood_type: None,
id_number: Some("110101199001011234".to_string()),
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
},
)
.await
.expect("租户 B 创建患者应成功");
// 验证两个患者 ID 不同
assert_ne!(patient_a.id, patient_b.id);
// 各自的详情应该正确
let detail_a = patient_service::get_patient(&state, tenant_a, patient_a.id)
.await
.expect("租户 A 查询应成功");
assert_eq!(detail_a.name, "租户A患者");
let detail_b = patient_service::get_patient(&state, tenant_b, patient_b.id)
.await
.expect("租户 B 查询应成功");
assert_eq!(detail_b.name, "租户B患者");
// DB 中两者的 id_number 密文不同(不同 nonce
let row_a = erp_health::entity::patient::Entity::find_by_id(patient_a.id)
.one(test_db.db())
.await
.expect("DB 查询应成功")
.expect("应找到记录");
let row_b = erp_health::entity::patient::Entity::find_by_id(patient_b.id)
.one(test_db.db())
.await
.expect("DB 查询应成功")
.expect("应找到记录");
assert_ne!(
row_a.id_number, row_b.id_number,
"相同明文加密后密文应不同(不同 nonce"
);
}