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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,3 +364,158 @@ pub async fn create_message(
|
||||
content: decrypted_content, is_read: m.is_read, created_at: m.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 标记已读
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 标记会话为已读(将 unread_count_doctor 或 unread_count_patient 置零)。
|
||||
pub async fn mark_session_read(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
role: &str,
|
||||
) -> HealthResult<()> {
|
||||
let session = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::Id.eq(session_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::ConsultationNotFound)?;
|
||||
|
||||
use sea_orm::sea_query::Expr;
|
||||
let column = if role == "doctor" {
|
||||
consultation_session::Column::UnreadCountDoctor
|
||||
} else {
|
||||
consultation_session::Column::UnreadCountPatient
|
||||
};
|
||||
|
||||
let result = consultation_session::Entity::update_many()
|
||||
.col_expr(column, Expr::value(0i32))
|
||||
.filter(consultation_session::Column::Id.eq(session.id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::Version.eq(session.version))
|
||||
.exec(&state.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(HealthError::VersionMismatch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 医生仪表盘
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 医生仪表盘统计数据。
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DoctorDashboard {
|
||||
pub total_patients: i64,
|
||||
pub active_sessions: i64,
|
||||
pub unread_messages: i64,
|
||||
pub pending_follow_ups: i64,
|
||||
pub today_consultations: i64,
|
||||
}
|
||||
|
||||
/// 获取指定医生的仪表盘数据。
|
||||
pub async fn get_doctor_dashboard(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
doctor_user_id: Uuid,
|
||||
) -> HealthResult<DoctorDashboard> {
|
||||
use crate::entity::{doctor_profile, patient_doctor_relation, follow_up_task};
|
||||
use sea_orm::ColumnTrait;
|
||||
use sea_orm::QueryFilter;
|
||||
|
||||
// 查找医生 profile
|
||||
let doctor = doctor_profile::Entity::find()
|
||||
.filter(doctor_profile::Column::UserId.eq(doctor_user_id))
|
||||
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
|
||||
.filter(doctor_profile::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
let doctor_id = match doctor {
|
||||
Some(d) => d.id,
|
||||
None => {
|
||||
// 不是医生,返回空仪表盘
|
||||
return Ok(DoctorDashboard {
|
||||
total_patients: 0,
|
||||
active_sessions: 0,
|
||||
unread_messages: 0,
|
||||
pending_follow_ups: 0,
|
||||
today_consultations: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 关联患者数
|
||||
let total_patients = patient_doctor_relation::Entity::find()
|
||||
.filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id))
|
||||
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
|
||||
.count(&state.db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
// 活跃会话数
|
||||
let active_sessions = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::DoctorId.eq(doctor_user_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::Status.is_in(["active", "waiting"]))
|
||||
.count(&state.db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
// 未读消息总数
|
||||
use sea_orm::FromQueryResult;
|
||||
#[derive(FromQueryResult)]
|
||||
struct UnreadSum {
|
||||
total: Option<i64>,
|
||||
}
|
||||
let unread_result = UnreadSum::find_by_statement(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT COALESCE(SUM(unread_count_doctor), 0)::bigint as total
|
||||
FROM consultation_session
|
||||
WHERE tenant_id = $1 AND doctor_id = $2 AND deleted_at IS NULL AND status IN ('active', 'waiting')"#,
|
||||
[tenant_id.into(), doctor_user_id.into()],
|
||||
))
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
let unread_messages = unread_result.and_then(|r| r.total).unwrap_or(0);
|
||||
|
||||
// 待处理随访任务
|
||||
let pending_follow_ups = follow_up_task::Entity::find()
|
||||
.filter(follow_up_task::Column::AssignedTo.eq(doctor_user_id))
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::Status.eq("pending"))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.count(&state.db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
// 今日咨询数
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let today_start = today.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
let today_consultations = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::DoctorId.eq(doctor_user_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::CreatedAt.gte(today_start))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.count(&state.db)
|
||||
.await
|
||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(DoctorDashboard {
|
||||
total_patients: total_patients as i64,
|
||||
active_sessions: active_sessions as i64,
|
||||
unread_messages,
|
||||
pending_follow_ups: pending_follow_ups as i64,
|
||||
today_consultations: today_consultations as i64,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -329,10 +329,15 @@ pub async fn create_record(
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
let kek = state.crypto.kek();
|
||||
Ok(FollowUpRecordResp {
|
||||
id: record.id, task_id: record.task_id, executed_by: record.executed_by,
|
||||
executed_date: record.executed_date, result: record.result,
|
||||
patient_condition: record.patient_condition, medical_advice: record.medical_advice,
|
||||
executed_date: record.executed_date,
|
||||
result: pii::decrypt(kek, &record.result).unwrap_or(record.result.clone()),
|
||||
patient_condition: record.patient_condition.as_ref()
|
||||
.map(|v| pii::decrypt(kek, v).unwrap_or(v.clone())),
|
||||
medical_advice: record.medical_advice.as_ref()
|
||||
.map(|v| pii::decrypt(kek, v).unwrap_or(v.clone())),
|
||||
next_follow_up_date: record.next_follow_up_date,
|
||||
created_at: record.created_at, updated_at: record.updated_at, version: record.version,
|
||||
})
|
||||
|
||||
@@ -879,3 +879,134 @@ pub async fn list_tags(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者标签 CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct CreateTagReq {
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct TagResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: CreateTagReq,
|
||||
) -> HealthResult<TagResp> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
let tag = patient_tag::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name),
|
||||
color: Set(req.color),
|
||||
description: Set(req.description),
|
||||
is_system: Set(false),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
let tag = tag.insert(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient_tag.create", "patient_tag")
|
||||
.with_resource_id(tag.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(TagResp {
|
||||
id: tag.id, name: tag.name, color: tag.color, description: tag.description,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct UpdateTagReq {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
tag_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: UpdateTagReq,
|
||||
) -> HealthResult<TagResp> {
|
||||
let tag = patient_tag::Entity::find_by_id(tag_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::TagNotFound)?;
|
||||
|
||||
if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); }
|
||||
check_version(req.version, tag.version)?;
|
||||
|
||||
let mut active: patient_tag::ActiveModel = tag.into();
|
||||
if let Some(name) = req.name { active.name = Set(name); }
|
||||
if let Some(color) = req.color { active.color = Set(Some(color)); }
|
||||
if let Some(description) = req.description { active.description = Set(Some(description)); }
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(req.version + 1);
|
||||
|
||||
let updated = active.update(&state.db).await
|
||||
.map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag")
|
||||
.with_resource_id(updated.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(TagResp {
|
||||
id: updated.id, name: updated.name, color: updated.color, description: updated.description,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_tag(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
tag_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
version: i32,
|
||||
) -> HealthResult<()> {
|
||||
let tag = patient_tag::Entity::find_by_id(tag_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::TagNotFound)?;
|
||||
|
||||
if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); }
|
||||
check_version(version, tag.version)?;
|
||||
|
||||
let mut active: patient_tag::ActiveModel = tag.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(version + 1);
|
||||
active.update(&state.db).await
|
||||
.map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient_tag.delete", "patient_tag")
|
||||
.with_resource_id(tag_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ mod m20260427_000068_add_doctor_profile_pii_fields;
|
||||
mod m20260427_000069_add_dialysis_record_key_version;
|
||||
mod m20260427_000070_add_lab_report_key_version;
|
||||
mod m20260427_000071_add_diagnosis_key_version;
|
||||
mod m20260427_000072_widen_encrypted_phone_columns;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -149,6 +150,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260427_000069_add_dialysis_record_key_version::Migration),
|
||||
Box::new(m20260427_000070_add_lab_report_key_version::Migration),
|
||||
Box::new(m20260427_000071_add_diagnosis_key_version::Migration),
|
||||
Box::new(m20260427_000072_widen_encrypted_phone_columns::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260427_000072_widen_encrypted_phone_columns"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
// patient.emergency_contact_phone: varchar(20) → varchar(255),容纳 AES-256-GCM 密文
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient ALTER COLUMN emergency_contact_phone TYPE varchar(255)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// patient_family_member.phone: varchar(20) → varchar(255)
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient_family_member ALTER COLUMN phone TYPE varchar(255)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// doctor_profile.license_number: varchar(50) → varchar(255)
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE doctor_profile ALTER COLUMN license_number TYPE varchar(255)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// follow_up_record.result: varchar(20) → varchar(255)
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE follow_up_record ALTER COLUMN result TYPE varchar(255)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let conn = manager.get_connection();
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient ALTER COLUMN emergency_contact_phone TYPE varchar(20)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE patient_family_member ALTER COLUMN phone TYPE varchar(20)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE doctor_profile ALTER COLUMN license_number TYPE varchar(50)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute_unprepared(
|
||||
"ALTER TABLE follow_up_record ALTER COLUMN result TYPE varchar(20)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,5 @@ mod workflow_tests;
|
||||
mod health_patient_tests;
|
||||
#[path = "integration/health_appointment_tests.rs"]
|
||||
mod health_appointment_tests;
|
||||
#[path = "integration/health_pii_encryption_tests.rs"]
|
||||
mod health_pii_encryption_tests;
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
//! 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)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user