feat(expenses): secure timeline and draft events

This commit is contained in:
caoxiaozhu
2026-07-14 00:07:07 +08:00
parent 22669a9071
commit 1347366b95
14 changed files with 1044 additions and 59 deletions

View File

@@ -23,6 +23,7 @@ def _read_test_user_headers(
grade: Annotated[str | None, Header(alias="X-Auth-Grade")] = None,
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None,
) -> CurrentUserContext:
normalized_username = str(username or "").strip()
normalized_name = str(name or normalized_username).strip()
@@ -44,6 +45,7 @@ def _read_test_user_headers(
name=normalized_name or normalized_username,
role_codes=normalized_roles,
is_admin=admin_flag,
tenant_id=str(tenant_id or "default").strip() or "default",
department_name=str(department or "").strip(),
cost_center=str(cost_center or "").strip(),
position=str(position or "").strip(),

View File

@@ -0,0 +1,339 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_workflow_constants import (
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
@pytest.fixture
def http_context() -> Generator[tuple[TestClient, sessionmaker[Session]], None, None]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
try:
yield client, session_factory
finally:
client.close()
app.dependency_overrides.clear()
engine.dispose()
def seed_timeline(
db: Session,
*,
claim_no: str = "RE-TIMELINE-001",
expense_type: str = "travel",
relation_type: str | None = None,
approval_stage: str = DIRECT_MANAGER_APPROVAL_STAGE,
with_case: bool = True,
tenant_id: str = "default",
) -> ExpenseClaim:
manager = Employee(
id="manager-1",
employee_no="M001",
name="李经理",
email="manager@example.com",
)
owner = Employee(
id="owner-1",
employee_no="E001",
name="张三",
email="owner@example.com",
manager=manager,
)
claim = ExpenseClaim(
id="claim-1",
claim_no=claim_no,
employee_id=owner.id,
employee_name=owner.name,
department_name="市场部",
expense_type=expense_type,
reason="客户现场差旅",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 13, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 7, 13, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage=approval_stage,
risk_flags_json=[],
)
db.add_all([manager, owner, claim])
db.flush()
if with_case:
ExpenseCaseService(db).record_claim_event(
claim,
event_type="claim_submitted",
actor_id=owner.email,
tenant_id=tenant_id,
correlation_id="timeline-http-permission",
idempotency_key="timeline-http-permission",
previous_status="draft",
previous_approval_stage="待提交",
relation_type=relation_type,
extra_payload={
"opinion": "同意,按计划执行。",
"internal_route": "finance-secret-route",
"archived_applications": [
{
"application_claim_id": "internal-application-id",
"application_claim_no": "AP-TIMELINE-001",
"archive_event_id": "internal-archive-event-id",
}
],
},
)
db.commit()
return claim
@pytest.mark.parametrize(
("approval_stage", "headers"),
[
(
DIRECT_MANAGER_APPROVAL_STAGE,
{
"x-auth-username": "owner@example.com",
"x-auth-name": "Owner",
"x-auth-employee-no": "E001",
"x-auth-role-codes": "user",
},
),
(
DIRECT_MANAGER_APPROVAL_STAGE,
{
"x-auth-username": "manager@example.com",
"x-auth-name": "Manager",
"x-auth-employee-no": "M001",
"x-auth-role-codes": "approver",
},
),
(
FINANCE_APPROVAL_STAGE,
{
"x-auth-username": "finance@example.com",
"x-auth-name": "Finance",
"x-auth-role-codes": "finance",
},
),
(
DIRECT_MANAGER_APPROVAL_STAGE,
{
"x-auth-username": "admin",
"x-auth-name": "Admin",
"x-auth-is-admin": "true",
},
),
],
ids=["owner", "current-manager", "finance", "admin"],
)
def test_expense_case_timeline_allows_supported_viewers(
http_context: tuple[TestClient, sessionmaker[Session]],
approval_stage: str,
headers: dict[str, str],
) -> None:
client, session_factory = http_context
with session_factory() as db:
claim = seed_timeline(db, approval_stage=approval_stage)
claim_id = claim.id
response = client.get(f"/api/v1/expense-cases/by-claim/{claim_id}", headers=headers)
assert response.status_code == 200
payload = response.json()
assert set(payload) == {"id", "case_no", "current_stage", "status", "links", "events"}
assert payload["case_no"] == "CASE-RE-TIMELINE-001"
assert payload["links"] == [{"relation_type": "claim"}]
assert len(payload["events"]) == 1
event = payload["events"][0]
assert set(event) == {
"id",
"event_type",
"actor_id",
"actor_type",
"payload_json",
"occurred_at",
}
assert event["actor_id"] == "owner@example.com"
assert event["payload_json"]["opinion"] == "同意,按计划执行。"
assert event["payload_json"]["archived_applications"] == [
{"application_claim_no": "AP-TIMELINE-001"}
]
serialized = response.text
for internal_value in (
"idempotency_key",
"correlation_id",
"causation_id",
"delivery_status",
"resource_id",
"internal_route",
"internal-application-id",
"internal-archive-event-id",
):
assert internal_value not in serialized
def test_expense_case_timeline_hides_claim_existence_from_unrelated_user(
http_context: tuple[TestClient, sessionmaker[Session]],
) -> None:
client, session_factory = http_context
with session_factory() as db:
claim_id = seed_timeline(db).id
response = client.get(
f"/api/v1/expense-cases/by-claim/{claim_id}",
headers={
"x-auth-username": "unrelated@example.com",
"x-auth-name": "Unrelated User",
"x-auth-employee-no": "E999",
"x-auth-role-codes": "user",
},
)
assert response.status_code == 404
assert response.json()["detail"] == "费用单据不存在。"
def test_expense_case_timeline_rejects_cross_tenant_lookup(
http_context: tuple[TestClient, sessionmaker[Session]],
) -> None:
client, session_factory = http_context
with session_factory() as db:
claim_id = seed_timeline(db, tenant_id="tenant-a").id
response = client.get(
f"/api/v1/expense-cases/by-claim/{claim_id}",
headers={
"x-auth-username": "owner@example.com",
"x-auth-name": "Owner",
"x-auth-employee-no": "E001",
"x-auth-role-codes": "user",
"x-auth-tenant-id": "tenant-b",
},
)
assert response.status_code == 404
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
http_context: tuple[TestClient, sessionmaker[Session]],
) -> None:
client, session_factory = http_context
with session_factory() as db:
claim_id = seed_timeline(db, with_case=False).id
response = client.get(
f"/api/v1/expense-cases/by-claim/{claim_id}",
headers={
"x-auth-username": "owner@example.com",
"x-auth-name": "Owner",
"x-auth-employee-no": "E001",
"x-auth-role-codes": "user",
},
)
assert response.status_code == 404
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
def test_expense_case_timeline_returns_safe_summary_for_all_linked_claims(
http_context: tuple[TestClient, sessionmaker[Session]],
) -> None:
client, session_factory = http_context
with session_factory() as db:
application = seed_timeline(
db,
claim_no="AP-TIMELINE-001",
expense_type="travel_application",
relation_type="application",
)
service = ExpenseCaseService(db)
expense_case = service.get_timeline_for_claim(application.id)
assert expense_case is not None
reimbursement = ExpenseClaim(
id="claim-2",
claim_no="RE-TIMELINE-002",
employee_id=application.employee_id,
employee_name=application.employee_name,
department_name=application.department_name,
expense_type="travel",
reason="客户现场差旅报销",
location="上海",
amount=Decimal("880.00"),
currency="CNY",
invoice_count=1,
occurred_at=application.occurred_at,
submitted_at=application.submitted_at,
status="submitted",
approval_stage=FINANCE_APPROVAL_STAGE,
risk_flags_json=[],
)
db.add(reimbursement)
db.flush()
service.record_claim_event(
reimbursement,
event_type="claim_submitted",
actor_id="owner@example.com",
expense_case=expense_case,
relation_type="generated_reimbursement",
correlation_id="linked-reimbursement",
idempotency_key="linked-reimbursement",
previous_status="draft",
previous_approval_stage="待提交",
)
db.commit()
response = client.get(
"/api/v1/expense-cases/by-claim/claim-1",
headers={
"x-auth-username": "owner@example.com",
"x-auth-name": "Owner",
"x-auth-employee-no": "E001",
"x-auth-role-codes": "user",
},
)
assert response.status_code == 200
payload = response.json()
assert {item["relation_type"] for item in payload["links"]} == {
"application",
"generated_reimbursement",
}
assert [event["event_type"] for event in payload["events"]] == [
"claim_submitted",
"claim_submitted",
]

View File

@@ -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"

View File

@@ -14,6 +14,7 @@ from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.services import attachment_association_jobs as attachment_jobs_module
@@ -284,6 +285,9 @@ def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_d
assert second_payload["result_payload"]["idempotent_replay"] is True
with session_factory() as db:
assert claim_count(db) == 1
events = list(db.scalars(select(BusinessEvent)).all())
assert len(events) == 1
assert events[0].event_type == "claim_draft_created"
def test_steward_action_executor_requires_confirmation_before_submit_side_effect() -> None:
@@ -342,6 +346,16 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.reason == "辅助国网仿生产服务器部署"
event = db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
).one()
assert event.event_type == "claim_draft_created"
assert event.actor_id == "zhangsan@example.com"
assert event.correlation_id == "steward-action:save_application_draft:task_app_001"
link = db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
).one()
assert link.relation_type == "application"
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.schemas.ontology import OntologyParseResult
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_cases import ExpenseCaseService
from app.services.user_agent import UserAgentService
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def build_request(*, run_id: str, tenant_id: str = "tenant-a") -> UserAgentRequest:
return UserAgentRequest(
run_id=run_id,
user_id="owner@example.com",
message="保存申请草稿",
ontology=OntologyParseResult(run_id=run_id),
context_json={
"session_type": "application",
"tenant_id": tenant_id,
"name": "张三",
"employee_no": "E001",
"department_name": "市场部",
},
)
def build_facts(*, reason: str = "客户现场差旅") -> dict[str, str]:
return {
"application_type": "差旅费用申请",
"time": "2026-07-13 至 2026-07-14",
"location": "上海",
"reason": reason,
"amount": "880元",
}
def test_ai_application_draft_update_writes_tenant_scoped_event() -> None:
session_factory = build_session_factory()
with session_factory() as db:
owner = Employee(
id="owner-1",
employee_no="E001",
name="张三",
email="owner@example.com",
)
claim = ExpenseClaim(
id="application-1",
claim_no="AP-DRAFT-001",
employee_id=owner.id,
employee_name=owner.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([owner, claim])
db.commit()
updated = UserAgentService(db)._update_expense_application_record(
build_request(run_id="application-draft-update"),
build_facts(reason="更新后的申请事由"),
claim,
submit=False,
)
assert updated.status == "draft"
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id))
assert event is not None
assert event.event_type == "claim_draft_updated"
assert event.tenant_id == "tenant-a"
assert event.actor_id == "owner@example.com"
assert event.correlation_id == "application-draft-update"
assert event.payload_json["previous_status"] == "returned"
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.tenant_id == "tenant-a"
def test_ai_application_draft_creation_rolls_back_when_event_write_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_factory = build_session_factory()
def fail_event(*_args, **_kwargs):
raise RuntimeError("模拟草稿事件写入失败")
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
with session_factory() as db:
with pytest.raises(RuntimeError, match="模拟草稿事件写入失败"):
UserAgentService(db)._create_expense_application_record(
build_request(run_id="application-draft-rollback"),
build_facts(),
submit=False,
)
db.commit()
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
assert list(db.scalars(select(BusinessEvent)).all()) == []
def test_ai_application_draft_update_rolls_back_when_event_write_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_factory = build_session_factory()
with session_factory() as db:
owner = Employee(
id="owner-1",
employee_no="E001",
name="张三",
email="owner@example.com",
)
claim = ExpenseClaim(
id="application-1",
claim_no="AP-DRAFT-ROLLBACK-001",
employee_id=owner.id,
employee_name=owner.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([owner, claim])
db.commit()
def fail_event(*_args, **_kwargs):
raise RuntimeError("模拟草稿事件写入失败")
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
with pytest.raises(RuntimeError, match="模拟草稿事件写入失败"):
UserAgentService(db)._update_expense_application_record(
build_request(run_id="application-draft-update-rollback"),
build_facts(reason="不应落库的新事由"),
claim,
submit=False,
)
db.commit()
persisted = db.get(ExpenseClaim, claim.id)
assert persisted is not None
assert persisted.reason == "原申请事由"
assert persisted.location == "北京"
assert persisted.amount == Decimal("500.00")
assert persisted.status == "returned"
assert persisted.approval_stage == "退回补充"
assert list(db.scalars(select(ExpenseCaseLink)).all()) == []
assert list(db.scalars(select(BusinessEvent)).all()) == []
def test_ai_application_draft_update_deduplicates_identical_snapshot() -> None:
session_factory = build_session_factory()
with session_factory() as db:
owner = Employee(
id="owner-1",
employee_no="E001",
name="张三",
email="owner@example.com",
)
claim = ExpenseClaim(
id="application-1",
claim_no="AP-DRAFT-IDEMPOTENT-001",
employee_id=owner.id,
employee_name=owner.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([owner, claim])
db.commit()
request = build_request(run_id="application-draft-idempotent")
facts = build_facts(reason="同一份草稿")
service = UserAgentService(db)
service._update_expense_application_record(request, facts, claim, submit=False)
service._update_expense_application_record(request, facts, claim, submit=False)
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
).all()
)
assert len(events) == 1
assert events[0].event_type == "claim_draft_updated"
assert events[0].payload_json["previous_status"] == "returned"
def test_ai_application_draft_update_keeps_distinct_snapshots_in_same_run() -> None:
session_factory = build_session_factory()
with session_factory() as db:
owner = Employee(
id="owner-1",
employee_no="E001",
name="张三",
email="owner@example.com",
)
claim = ExpenseClaim(
id="application-1",
claim_no="AP-DRAFT-VERSIONS-001",
employee_id=owner.id,
employee_name=owner.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([owner, claim])
db.commit()
request = build_request(run_id="application-draft-multi-version")
service = UserAgentService(db)
service._update_expense_application_record(
request,
build_facts(reason="第一版草稿"),
claim,
submit=False,
)
service._update_expense_application_record(
request,
build_facts(reason="第二版草稿"),
claim,
submit=False,
)
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
).all()
)
assert len(events) == 2
assert {event.event_type for event in events} == {"claim_draft_updated"}
assert len({event.idempotency_key for event in events}) == 2