fix: 全面 QA 审计修复 — 安全加固/代码质量/跨平台一致性/测试覆盖
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

Phase 0 安全热修复 (CRITICAL):
- 外部化微信 appid/secret 到 ERP__WECHAT__APPID/SECRET 环境变量
- 正确连接 HealthCrypto 到 ERP__HEALTH__AES_KEY/HMAC_KEY 环境变量
- 外部化小程序加密密钥到 TARO_APP_ENCRYPTION_KEY 环境变量
- 移除小程序 auth store 中的敏感信息 console.log

Phase 1 安全加固:
- 微信自动注册 display_name 添加 sanitize 防止 XSS
- 测试数据库凭据改为从 TEST_DB_URL 环境变量读取

Phase 2 代码质量:
- 提取 useThemeMode hook 消除 22 处重复暗色模式检测
- 提取共享健康常量到 constants/health.ts
- 拆分 patient_service.rs 脱敏函数到 masking.rs
- 移除未使用的 i18next/react-i18next 依赖
- 移除未使用的 api/errors.ts 和 erp-auth/anyhow 依赖

Phase 3 测试覆盖:
- 新增 5 个患者模块集成测试 (CRUD/租户隔离/验证/软删除)

Phase 4 跨平台一致性:
- 统一小程序 Patient.birthday → birth_date 匹配后端
- 统一小程序 Appointment.time_slot → start_time/end_time 匹配后端

Phase 5 架构:
- 微信登录添加多租户 TODO 注释
- 更新 wiki/infrastructure.md 环境变量文档
This commit is contained in:
iven
2026-04-25 10:00:49 +08:00
parent 07f4ba41ba
commit 945ccd64ba
56 changed files with 634 additions and 273 deletions

View File

@@ -0,0 +1,208 @@
//! erp-health 患者管理集成测试
//!
//! 验证患者 CRUD、租户隔离、字段校验、软删除等核心行为。
//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。
use erp_core::events::EventBus;
use erp_health::dto::patient_dto::CreatePatientReq;
use erp_health::service::patient_service;
use erp_health::state::HealthState;
use erp_health::HealthCrypto;
use super::test_db::TestDb;
/// 构建测试用 HealthState
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
HealthState {
db: db.clone(),
event_bus: EventBus::new(100),
crypto: HealthCrypto::dev_default(),
}
}
#[tokio::test]
async fn test_create_patient() {
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 req = 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: None,
emergency_contact_name: Some("李四".to_string()),
emergency_contact_phone: Some("13800138000".to_string()),
source: Some("offline".to_string()),
notes: None,
};
let patient = patient_service::create_patient(&state, tenant_id, Some(operator_id), req)
.await
.expect("创建患者应成功");
assert_eq!(patient.name, "张三");
assert_eq!(patient.gender, Some("male".to_string()));
assert_eq!(patient.status, "active");
assert_eq!(patient.verification_status, "pending");
assert_eq!(patient.version, 1);
assert!(patient.id_number.is_none(), "列表视图不应返回身份证号明文");
// 通过 get_patient 验证存储正确
let found = patient_service::get_patient(&state, tenant_id, patient.id)
.await
.expect("查询患者应成功");
assert_eq!(found.name, "张三");
assert_eq!(found.gender, Some("male".to_string()));
}
#[tokio::test]
async fn test_list_patients() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
// 创建 2 个患者
for i in 0..2 {
let req = CreatePatientReq {
name: format!("患者{}", i + 1),
gender: if i == 0 { Some("male".to_string()) } else { 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,
};
patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
}
let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None)
.await
.expect("列表查询应成功");
assert_eq!(result.total, 2, "应有 2 条患者记录");
assert_eq!(result.data.len(), 2, "当前页应返回 2 条");
}
#[tokio::test]
async fn test_patient_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();
// 租户 A 创建患者
let req_a = CreatePatientReq {
name: "租户A患者".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,
};
let patient_a = patient_service::create_patient(&state, tenant_a, None, req_a)
.await
.expect("租户 A 创建患者应成功");
// 租户 B 列表查询应看不到租户 A 的患者
let result_b = patient_service::list_patients(&state, tenant_b, 1, 10, None, None)
.await
.expect("租户 B 列表查询应成功");
assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的患者");
assert!(result_b.data.is_empty());
// 租户 B 通过 ID 查询租户 A 的患者应返回 PatientNotFound
let lookup_result = patient_service::get_patient(&state, tenant_b, patient_a.id).await;
assert!(
lookup_result.is_err(),
"跨租户查询应返回错误"
);
}
#[tokio::test]
async fn test_patient_validation_gender() {
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: Some("unknown".to_string()), // 不在白名单 ["male", "female", "other"] 中
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 result = patient_service::create_patient(&state, tenant_id, None, req).await;
assert!(result.is_err(), "无效性别应返回校验错误");
// 验证错误消息包含字段名
let err_msg = format!("{:#}", result.unwrap_err());
assert!(
err_msg.contains("gender"),
"错误消息应包含 'gender' 字段名,实际: {}",
err_msg
);
}
#[tokio::test]
async fn test_patient_soft_delete() {
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: Some("other".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,
};
let patient = patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
// 软删除
patient_service::delete_patient(&state, tenant_id, patient.id, None, patient.version)
.await
.expect("软删除应成功");
// 列表不应包含已软删除的患者
let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None)
.await
.expect("列表查询应成功");
assert_eq!(result.total, 0, "软删除后列表应为空");
assert!(result.data.is_empty());
// get_patient 也应返回 PatientNotFound
let lookup = patient_service::get_patient(&state, tenant_id, patient.id).await;
assert!(lookup.is_err(), "软删除后查询应返回错误");
}

View File

@@ -15,9 +15,10 @@ impl TestDb {
pub async fn new() -> Self {
let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple());
// 连接本地 PostgreSQL 的默认库postgres来创建测试库
let admin_url = "postgres://postgres:123123@localhost:5432/postgres";
let admin_db = Database::connect(admin_url)
let admin_url = std::env::var("TEST_DB_URL")
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
let admin_db = Database::connect(&admin_url)
.await
.expect("连接本地 PostgreSQL 失败,请确认服务正在运行");
@@ -31,8 +32,12 @@ impl TestDb {
drop(admin_db);
// 连接测试库
let test_url = format!("postgres://postgres:123123@localhost:5432/{}", db_name);
// 从 admin_url 推导测试库 URL替换路径部分
let test_url = if let Some(pos) = admin_url.rfind('/') {
format!("{}/{}", &admin_url[..pos], db_name)
} else {
format!("postgres://postgres:123123@localhost:5432/{}", db_name)
};
let db = Database::connect(&test_url)
.await
.expect("连接测试数据库失败");
@@ -63,8 +68,9 @@ impl Drop for TestDb {
.build();
if let Ok(rt) = rt {
rt.block_on(async {
let admin_url = "postgres://postgres:123123@localhost:5432/postgres";
if let Ok(admin_db) = Database::connect(admin_url).await {
let admin_url = std::env::var("TEST_DB_URL")
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
if let Ok(admin_db) = Database::connect(&admin_url).await {
let disconnect_sql = format!(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'",
db_name