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 删除权留后续。
This commit is contained in:
@@ -32,6 +32,8 @@ mod health_follow_up_template_tests;
|
||||
mod health_follow_up_tests;
|
||||
#[path = "integration/health_medication_tests.rs"]
|
||||
mod health_medication_tests;
|
||||
#[path = "integration/health_patient_export_tests.rs"]
|
||||
mod health_patient_export_tests;
|
||||
#[path = "integration/health_patient_tests.rs"]
|
||||
mod health_patient_tests;
|
||||
#[path = "integration/health_pii_encryption_tests.rs"]
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn test_user_crud() {
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
@@ -90,6 +91,7 @@ async fn test_tenant_isolation() {
|
||||
page_size: Some(10),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
db,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 个保法 §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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user