Files
hms/crates/erp-server/tests/integration/health_patient_export_tests.rs
iven 15b6bec215 feat(health): B5 个保法 §45 患者数据可携权导出
GET /health/patients/{id}/export?format=json|fhir 双格式同步导出:
- json: 明文 PII(解密不脱敏,可携权本意),聚合 7 段数据
- fhir: FHIR R4 Bundle(复用现有 converter,PII 天然脱敏)
- 安全边界:consent 门控 + patient 角色 self-scope + 审计 patient.exported(不含明文 PII)+ 日志不记 payload
- 权限 health.patient.export(医护=all, patient=self),迁移 m20260626_000171
- 事件 patient.exported;6 集成测试全绿

含顺手修复 auth_tests UserService::list 签名 drift(exclude_only_roles),解锁 integration crate 编译。
§47 删除权留后续。
2026-06-26 17:58:20 +08:00

239 lines
7.8 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.
//! 个保法 §45 患者数据导出集成测试
//!
//! 验证双格式导出、明文 PII、跨租户隔离、审计不含明文 PII、FHIR 脱敏。
//! 直接调用 service 层(不走 HTTP/认证/consent与 health_patient_tests.rs 一致。
use erp_core::crypto::PiiCrypto;
use erp_core::entity::audit_log;
use erp_core::events::EventBus;
use erp_health::dto::export_dto::ExportFormat;
use erp_health::dto::patient_dto::CreatePatientReq;
use erp_health::service::patient_service;
use erp_health::state::HealthState;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use super::test_db::TestDb;
/// 构建测试用 HealthState
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
HealthState {
db: db.clone(),
event_bus: EventBus::new(100),
crypto: PiiCrypto::dev_default(),
jwt_secret: "test-jwt-secret".to_string(),
external_health_checks: vec![],
cron_heartbeat: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
/// 构造带完整 PII 的患者请求
fn make_patient_req() -> CreatePatientReq {
CreatePatientReq {
name: "张三".to_string(),
gender: Some("male".to_string()),
birth_date: Some(chrono::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("高血压病史3年".to_string()),
emergency_contact_name: Some("李四".to_string()),
emergency_contact_phone: Some("13800138000".to_string()),
source: Some("offline".to_string()),
notes: None,
}
}
#[tokio::test]
async fn test_export_json_contains_plaintext_pii() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let patient =
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
.await
.expect("创建患者应成功");
let resp = patient_service::export_patient(
&state,
tenant_id,
Some(operator_id),
patient.id,
ExportFormat::Json,
)
.await
.expect("json 导出应成功");
assert_eq!(resp.format, ExportFormat::Json);
// §45 可携权本意json 导出含明文 PII非脱敏
assert_eq!(
resp.payload["patient"]["id_number"].as_str().unwrap(),
"110101199001151234",
"json 导出应含明文身份证号"
);
assert_eq!(
resp.payload["patient"]["allergy_history"].as_str().unwrap(),
"青霉素过敏"
);
assert_eq!(
resp.payload["patient"]["medical_history_summary"]
.as_str()
.unwrap(),
"高血压病史3年"
);
assert_eq!(
resp.payload["patient"]["emergency_contact_phone"]
.as_str()
.unwrap(),
"13800138000"
);
}
#[tokio::test]
async fn test_export_fhir_returns_bundle() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
.await
.expect("fhir 导出应成功");
assert_eq!(resp.format, ExportFormat::Fhir);
assert_eq!(resp.payload["resourceType"], "Bundle");
assert_eq!(resp.payload["type"], "collection");
assert_eq!(
resp.payload["entry"][0]["resource"]["resourceType"],
"Patient"
);
assert!(
resp.payload["total"].as_u64().unwrap() >= 1,
"Bundle 至少含 Patient 资源"
);
}
#[tokio::test]
async fn test_export_empty_patient_no_error() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let req = CreatePatientReq {
name: "空患者".to_string(),
gender: None,
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,
};
let patient = patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Json)
.await
.expect("空患者导出不应报错");
assert_eq!(resp.payload["device_readings"].as_array().unwrap().len(), 0);
assert_eq!(resp.resource_counts["observations"].as_u64().unwrap(), 0);
assert_eq!(resp.resource_counts["appointments"].as_u64().unwrap(), 0);
}
#[tokio::test]
async fn test_export_cross_tenant_isolation() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_a = uuid::Uuid::new_v4();
let tenant_b = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_a, None, make_patient_req())
.await
.expect("创建患者应成功");
// tenant_b 尝试导出 tenant_a 的患者 → find_packet 按 tenant 过滤 → NotFound
let result =
patient_service::export_patient(&state, tenant_b, None, patient.id, ExportFormat::Json)
.await;
assert!(result.is_err(), "跨租户导出应失败");
}
#[tokio::test]
async fn test_export_writes_audit_without_plaintext() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let patient =
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
.await
.expect("创建患者应成功");
patient_service::export_patient(
&state,
tenant_id,
Some(operator_id),
patient.id,
ExportFormat::Json,
)
.await
.expect("导出应成功");
let logs = audit_log::Entity::find()
.filter(audit_log::Column::Action.eq("patient.exported"))
.filter(audit_log::Column::ResourceId.eq(patient.id))
.all(test_db.db())
.await
.expect("查 audit 应成功");
assert_eq!(logs.len(), 1, "应写一条 patient.exported 审计");
let new_value = logs[0].new_value.as_ref().expect("new_value 应存在");
let new_value_str = new_value.to_string();
assert!(new_value_str.contains("json"), "审计应记录 format=json");
assert!(
!new_value_str.contains("110101199001151234"),
"审计 new_value 绝不能含明文身份证号"
);
}
#[tokio::test]
async fn test_export_fhir_redacts_id_number() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
.await
.expect("fhir 导出应成功");
// FHIR Bundle 内 Patient.identifier.value 应脱敏或 REDACTED非明文
let id_val = resp.payload["entry"][0]["resource"]["identifier"][0]["value"]
.as_str()
.unwrap_or("");
assert_ne!(
id_val, "110101199001151234",
"FHIR 路径不应输出明文身份证号"
);
assert!(
id_val.contains('*') || id_val == "[REDACTED]",
"应为脱敏(含*)或 [REDACTED],实际: {id_val}"
);
}