完善审计日志展示与导出权限
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user