完善审计日志展示与导出权限

This commit is contained in:
Cheng Zhou
2026-06-10 10:20:04 +08:00
parent 1fef12cd86
commit ea3f19e241
33 changed files with 1862 additions and 240 deletions
+149
View File
@@ -588,6 +588,155 @@ def test_audit_export_event_uses_export_permission():
assert "require_study_member()" not in event_chunk
def test_audit_export_event_does_not_trust_client_detail():
"""导出审计事件的业务描述应由后端生成,不能信任客户端 detail。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
source = route_path.read_text()
event_chunk = source[source.index("async def create_audit_event") :]
assert "detail=payload.detail" not in event_chunk
assert "entity_type=payload.entity_type" not in event_chunk
assert "entity_id=payload.entity_id" not in event_chunk
assert "_build_export_audit_detail" in event_chunk
def test_audit_logs_do_not_expose_delete_endpoint():
"""审计日志不能提供单条删除接口,避免破坏审计不可篡改性。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
source = route_path.read_text()
assert "@router.delete" not in source
assert "delete_audit_log" not in source
def test_etmf_audit_details_are_structured_json():
"""eTMF 审计详情不能手拼 JSON 或写空对象。"""
service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "etmf_service.py"
source = service_path.read_text()
assert "json.dumps" in source
assert "detail=f'{{" not in source
assert 'detail="{}"' not in source
def test_setup_config_diff_summary_keeps_all_business_changes_and_skips_ids():
"""立项配置审计明细应完整保留业务字段,跳过行内技术 ID。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "studies.py"
source = route_path.read_text()
summary_chunk = source[source.index("def _setup_collect_diff_lines") : source.index("@router.post")]
assert '_SETUP_TECHNICAL_FIELDS = {"id"}' in source
assert "if key in _SETUP_TECHNICAL_FIELDS:" in summary_chunk
assert "已省略" not in summary_chunk
assert "max_lines" not in summary_chunk
def test_subject_audit_uses_structured_business_snapshot():
"""参与者审计应把编号放入对象,把业务变化放入详情。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "subjects.py"
source = route_path.read_text()
create_subject_source = source[source.index("async def create_subject") : source.index("@router.get")]
update_subject_source = source[source.index("async def update_subject") : source.index("@router.delete")]
delete_subject_source = source[source.index("async def delete_subject") :]
assert "_subject_audit_snapshot" in source
assert '"targetName": subject.subject_no' in create_subject_source
assert '"description": f"创建参与者 {subject.subject_no}"' in create_subject_source
assert '"targetName": updated.subject_no' in update_subject_source
assert '"description": description' in update_subject_source
assert '"targetName": target_name' in delete_subject_source
assert '"description": f"删除参与者 {target_name}"' in delete_subject_source
assert '"before": before_snapshot' in delete_subject_source
assert 'detail=f"参与者 {subject.subject_no} 已创建"' not in source
assert 'f"参与者 {updated.subject_no} 状态' not in source
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
def test_faq_audit_uses_business_descriptions():
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
category_source = (api_dir / "faq_categories.py").read_text()
item_source = (api_dir / "faqs.py").read_text()
assert 'description": f"创建“{category.name}”分类"' in category_source
assert 'description": f"更新“{updated.name}”分类"' in category_source
assert 'description": f"删除“{category_name}”分类"' in category_source
assert 'f"创建医学咨询问题“{question_name}"' in item_source
assert 'f"更新医学咨询问题“{question_name}"' in item_source
assert 'f"回复医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
assert "FAQ 分类 {category.name} 已创建" not in category_source
assert 'detail="FAQ 已创建"' not in item_source
assert 'detail = "FAQ updated"' not in item_source
assert '"description": "创建医学咨询问题"' not in item_source
def test_domain_audits_use_business_object_names():
"""药品、设备、访视、AE、费用、立项等审计应记录具体业务对象。"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
sources = {
name: (api_dir / name).read_text()
for name in [
"drug_shipments.py",
"material_equipments.py",
"visits.py",
"aes.py",
"precautions.py",
"fees_contracts.py",
"startup.py",
"subject_histories.py",
]
}
assert "_shipment_audit_description" in sources["drug_shipments.py"]
assert "_equipment_audit_detail" in sources["material_equipments.py"]
assert "_visit_audit_detail" in sources["visits.py"]
assert "_ae_audit_detail" in sources["aes.py"]
assert "_precaution_audit_detail" in sources["precautions.py"]
assert "_contract_audit_detail" in sources["fees_contracts.py"]
assert "_payment_audit_detail" in sources["fees_contracts.py"]
assert "_feasibility_audit_detail" in sources["startup.py"]
assert "_ethics_audit_detail" in sources["startup.py"]
assert "_kickoff_audit_detail" in sources["startup.py"]
assert "_training_audit_detail" in sources["startup.py"]
assert "_history_audit_detail" in sources["subject_histories.py"]
forbidden_fragments = [
'detail=f"访视 {visit_id} 已删除"',
'detail=f"AE {ae.id} 已创建"',
'detail=f"AE {ae_id} 已删除"',
'detail=f"设备 {item.id} 已创建"',
'detail=f"设备 {equipment_id} 已更新"',
'detail=f"设备 {equipment_id} 已删除"',
'detail=f"注意事项 {precaution_id} 已更新"',
'detail=f"注意事项 {precaution_id} 已删除"',
'detail="合同费用已创建"',
'detail="合同费用已更新"',
'detail="合同费用已删除"',
'detail="合同费用分期已创建"',
'detail="合同费用分期已更新"',
'detail="合同费用分期已删除"',
'detail="立项记录已创建"',
'detail="立项记录已更新"',
'detail="立项记录已删除"',
'detail="伦理记录已创建"',
'detail="伦理记录已更新"',
'detail="伦理记录已删除"',
'detail="启动会记录已创建"',
'detail="启动会记录已更新"',
'detail="培训授权人员已创建"',
'detail="培训授权人员已更新"',
'detail="培训授权人员已删除"',
'detail="病史记录已创建"',
'detail="病史记录已更新"',
'detail="病史记录已删除"',
]
combined_source = "\n".join(sources.values())
for fragment in forbidden_fragments:
assert fragment not in combined_source
def test_user_role_contains_current_project_roles():
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)