- 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 引用
636 lines
20 KiB
Rust
636 lines
20 KiB
Rust
//! 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 为 None(Tier 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)"
|
||
);
|
||
}
|