feat(expenses): secure timeline and draft events
This commit is contained in:
@@ -1114,9 +1114,147 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
|
||||
assert db.scalar(
|
||||
select(BudgetReservation).where(BudgetReservation.source_id == claim.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||
) is None
|
||||
assert db.scalar(
|
||||
)
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_draft_created"
|
||||
assert event.payload_json["previous_status"] == ""
|
||||
assert event.payload_json["next_status"] == "draft"
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||
)
|
||||
assert link is not None
|
||||
assert link.relation_type == "application"
|
||||
|
||||
|
||||
def test_application_preview_action_rejects_forged_identity_when_editing_other_claim() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_claim(db)
|
||||
outsider = Employee(
|
||||
id="emp-outsider",
|
||||
employee_no="E90001",
|
||||
name="其他员工",
|
||||
email="outsider@example.com",
|
||||
)
|
||||
outsider_claim = ExpenseClaim(
|
||||
id="application-outsider-1",
|
||||
claim_no="AP-OUTSIDER-001",
|
||||
employee_id=outsider.id,
|
||||
employee_name=outsider.name,
|
||||
department_name="交付部",
|
||||
expense_type="travel_application",
|
||||
reason="其他员工原申请",
|
||||
location="北京",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 10, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="returned",
|
||||
approval_stage="退回补充",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([outsider, outsider_claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
json={
|
||||
"source": "user_message",
|
||||
"user_id": "outsider@example.com",
|
||||
"conversation_id": "conversation-forged-identity",
|
||||
"message": "费用申请保存草稿\n申请时间:2026-07-13 至 2026-07-14\n地点:上海\n事由:恶意修改\n申请金额:880元\n保存草稿",
|
||||
"context_json": {
|
||||
"session_type": "application",
|
||||
"application_action": "save_draft",
|
||||
"application_save_mode": True,
|
||||
"application_edit_mode": True,
|
||||
"application_edit_claim_id": "application-outsider-1",
|
||||
"username": "outsider@example.com",
|
||||
"name": "其他员工",
|
||||
"employee_no": "E90001",
|
||||
"role_codes": ["admin"],
|
||||
"is_admin": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "只能修改本人被退回的申请单。"
|
||||
with session_factory() as db:
|
||||
persisted = db.get(ExpenseClaim, "application-outsider-1")
|
||||
assert persisted is not None
|
||||
assert persisted.reason == "其他员工原申请"
|
||||
assert persisted.status == "returned"
|
||||
assert persisted.approval_stage == "退回补充"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == persisted.id)
|
||||
) is None
|
||||
|
||||
|
||||
def test_application_preview_action_reuses_created_draft_for_identical_retry() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_claim(db)
|
||||
|
||||
request_payload = {
|
||||
"source": "user_message",
|
||||
"user_id": "zhangsan@example.com",
|
||||
"conversation_id": "conversation-fast-save-retry",
|
||||
"message": "费用申请保存草稿\n地点:上海\n事由:项目验收\n申请金额:880元\n保存草稿",
|
||||
"context_json": {
|
||||
"session_type": "application",
|
||||
"application_action": "save_draft",
|
||||
"application_save_mode": True,
|
||||
},
|
||||
}
|
||||
headers = {
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
}
|
||||
|
||||
first_response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=headers,
|
||||
json=request_payload,
|
||||
)
|
||||
second_response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=headers,
|
||||
json=request_payload,
|
||||
)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
assert second_response.status_code == 200
|
||||
first_draft = first_response.json()["result"]["draft_payload"]
|
||||
second_draft = second_response.json()["result"]["draft_payload"]
|
||||
assert second_draft["claim_id"] == first_draft["claim_id"]
|
||||
assert second_draft["claim_no"] == first_draft["claim_no"]
|
||||
with session_factory() as db:
|
||||
application_claims = list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim).where(
|
||||
ExpenseClaim.expense_type == "travel_application"
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(application_claims) == 1
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == application_claims[0].id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "claim_draft_created"
|
||||
|
||||
Reference in New Issue
Block a user