完善审计日志展示与导出权限
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
@@ -7,11 +8,11 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions
|
||||
from app.api.v1.members import update_member
|
||||
from app.api.v1.members import add_member, update_member
|
||||
from app.api.v1.permission_monitoring import resolve_monitoring_scope
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.core.deps import require_admin_or_any_project_pm, require_system_permission
|
||||
from app.schemas.member import StudyMemberUpdate
|
||||
from app.schemas.member import StudyMemberCreate, StudyMemberUpdate
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,6 +41,26 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = F
|
||||
)
|
||||
|
||||
|
||||
async def _seed_named_user(db: AsyncSession, user_id: uuid.UUID, full_name: str, *, is_admin: bool = False) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(user_id),
|
||||
"email": f"{user_id.hex}@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": full_name,
|
||||
"clinical_department": "Clinical",
|
||||
"is_admin": is_admin,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
@@ -289,3 +310,77 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_member_add_audit_uses_member_display_name(db_session: AsyncSession):
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
target_user_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "MEMBER-AUDIT-NAME")
|
||||
await _seed_user(db_session, admin_id, is_admin=True)
|
||||
await _seed_named_user(db_session, target_user_id, "张三")
|
||||
await db_session.commit()
|
||||
|
||||
await add_member(
|
||||
study_id=study_id,
|
||||
member_in=StudyMemberCreate(user_id=target_user_id, role_in_study="CRA"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT detail
|
||||
FROM audit_logs
|
||||
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_ADDED'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"study_id": str(study_id)},
|
||||
)
|
||||
).one()
|
||||
detail = json.loads(row.detail)
|
||||
assert detail["targetName"] == "张三"
|
||||
assert str(target_user_id) not in detail["targetName"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_member_update_audit_uses_member_display_name(db_session: AsyncSession):
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
target_user_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "MEMBER-UPDATE-AUDIT-NAME")
|
||||
await _seed_user(db_session, admin_id, is_admin=True)
|
||||
await _seed_named_user(db_session, target_user_id, "李四")
|
||||
member_id = await _seed_member_return_id(db_session, study_id, target_user_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
await update_member(
|
||||
study_id=study_id,
|
||||
member_id=member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="CTA"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT detail
|
||||
FROM audit_logs
|
||||
WHERE study_id = :study_id AND action = 'PROJECT_MEMBER_UPDATED'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"study_id": str(study_id)},
|
||||
)
|
||||
).one()
|
||||
detail = json.loads(row.detail)
|
||||
assert detail["targetName"] == "李四"
|
||||
assert str(target_user_id) not in detail["targetName"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ from app.crud import study as study_crud
|
||||
class _FakeDb:
|
||||
def __init__(self):
|
||||
self.tables = []
|
||||
self.operations = []
|
||||
self.committed = False
|
||||
|
||||
async def execute(self, stmt):
|
||||
table = getattr(getattr(stmt, "table", None), "name", None)
|
||||
if table:
|
||||
self.tables.append(table)
|
||||
self.operations.append((getattr(stmt, "__visit_name__", ""), table))
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
@@ -29,3 +31,13 @@ async def test_delete_study_removes_contract_fee_payments_before_contract_fees()
|
||||
assert "contract_fees" in db.tables
|
||||
assert db.tables.index("contract_fee_payments") < db.tables.index("contract_fees")
|
||||
assert db.committed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_study_preserves_audit_logs():
|
||||
db = _FakeDb()
|
||||
|
||||
await study_crud.delete(db, uuid.uuid4())
|
||||
|
||||
assert ("delete", "audit_logs") not in db.operations
|
||||
assert ("update", "audit_logs") in db.operations
|
||||
|
||||
Reference in New Issue
Block a user