Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
462 lines
16 KiB
Python
462 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import stat
|
||
from collections.abc import Generator
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from auth_helpers import install_legacy_header_auth_override
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import create_engine, select
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from app.api.deps import get_db
|
||
from app.core import expense_application_fingerprint_keys as fingerprint_keys
|
||
from app.db.base import Base
|
||
from app.main import create_app
|
||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||
from app.models.ai_learning import AIDecision, AIDecisionFeedback
|
||
from app.models.employee import Employee
|
||
from app.models.financial_record import ExpenseClaim
|
||
from app.models.role import Role
|
||
from app.services.expense_application_preview_decisions import (
|
||
ExpenseApplicationPreviewDecisionService,
|
||
)
|
||
|
||
|
||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||
engine = create_engine(
|
||
"sqlite+pysqlite:///:memory:",
|
||
connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool,
|
||
)
|
||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||
Base.metadata.create_all(bind=engine)
|
||
app = create_app()
|
||
install_legacy_header_auth_override(app)
|
||
|
||
def override_db() -> Generator[Session, None, None]:
|
||
db = session_factory()
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
|
||
app.dependency_overrides[get_db] = override_db
|
||
return TestClient(app), session_factory
|
||
|
||
|
||
def seed_employee(db: Session) -> None:
|
||
role = Role(id="role-preview-user", role_code="user", name="员工")
|
||
employee = Employee(
|
||
id="employee-preview-owner",
|
||
employee_no="E-PREVIEW-001",
|
||
name="张三",
|
||
email="preview-owner@example.com",
|
||
position="实施顾问",
|
||
grade="P4",
|
||
roles=[role],
|
||
)
|
||
db.add_all([role, employee])
|
||
db.commit()
|
||
|
||
|
||
def auth_headers(*, session_id: str = "session-preview-owner") -> dict[str, str]:
|
||
return {
|
||
"X-Auth-Username": "preview-owner@example.com",
|
||
"X-Auth-Name": "Zhang San",
|
||
"X-Auth-Employee-No": "E-PREVIEW-001",
|
||
"X-Auth-Employee-Id": "employee-preview-owner",
|
||
"X-Auth-Session-Id": session_id,
|
||
"X-Auth-Tenant-Id": "tenant-preview",
|
||
"X-Auth-Grade": "P4",
|
||
"X-Auth-Role-Codes": "user",
|
||
}
|
||
|
||
|
||
def issue_preview(
|
||
client: TestClient,
|
||
*,
|
||
request_id: str = "issue-preview-1",
|
||
include_transport: bool = True,
|
||
) -> dict:
|
||
transport_line = "出行方式:火车\n" if include_transport else ""
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-previews",
|
||
headers=auth_headers(),
|
||
json={
|
||
"message": (
|
||
"申请时间:2026-07-20 至 2026-07-22\n"
|
||
"地点:上海\n事由:客户现场实施\n天数:3天\n"
|
||
f"{transport_line}申请金额:1800元"
|
||
),
|
||
"conversation_id": "conversation-preview-1",
|
||
"request_id": request_id,
|
||
},
|
||
)
|
||
assert response.status_code == 201, response.text
|
||
return response.json()
|
||
|
||
|
||
def build_action_payload(issued: dict, *, reason: str = "客户现场实施") -> dict:
|
||
fields = {
|
||
**issued["application_preview"]["fields"],
|
||
"reason": reason,
|
||
}
|
||
return {
|
||
"source": "user_message",
|
||
"user_id": "forged-user@example.com",
|
||
"conversation_id": "conversation-preview-1",
|
||
"action_type": "save_draft",
|
||
"decision_id": issued["decision_id"],
|
||
"request_id": "action-preview-save-1",
|
||
"message": (
|
||
"费用申请保存草稿\n申请时间:2026-07-20 至 2026-07-22\n"
|
||
f"地点:上海\n事由:{reason}\n申请金额:1800元\n保存草稿"
|
||
),
|
||
"context_json": {
|
||
"application_action": "save_draft",
|
||
"application_save_mode": True,
|
||
"application_preview": {
|
||
"modelReviewStatus": "server_registered",
|
||
"fields": fields,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def build_legacy_action_payload() -> dict:
|
||
return build_action_payload(
|
||
{
|
||
"decision_id": "legacy-placeholder",
|
||
"application_preview": {
|
||
"fields": {
|
||
"time": "2026-07-20 至 2026-07-22",
|
||
"location": "上海",
|
||
"reason": "客户现场实施",
|
||
"days": "3天",
|
||
"transportMode": "火车",
|
||
"amount": "1800元",
|
||
"grade": "P4",
|
||
}
|
||
},
|
||
}
|
||
)
|
||
|
||
|
||
def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client)
|
||
action_payload = build_action_payload(issued)
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=action_payload,
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
result = response.json()["result"]
|
||
assert result["draft_payload"]["claim_id"]
|
||
assert result["decision_id"]
|
||
assert result["decision_id"] != issued["decision_id"]
|
||
retry_response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=action_payload,
|
||
)
|
||
assert retry_response.status_code == 200, retry_response.text
|
||
assert (
|
||
retry_response.json()["result"]["draft_payload"]["claim_id"]
|
||
== result["draft_payload"]["claim_id"]
|
||
)
|
||
assert retry_response.json()["result"]["decision_id"] == result["decision_id"]
|
||
|
||
with session_factory() as db:
|
||
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
next_decision = db.get(AIApplicationPreviewDecision, result["decision_id"])
|
||
decision = db.scalar(select(AIDecision))
|
||
feedback = db.scalar(select(AIDecisionFeedback))
|
||
assert consumed is not None
|
||
assert consumed.status == "consumed"
|
||
assert consumed.consumed_action == "save_draft"
|
||
assert consumed.field_fingerprints_json
|
||
assert "客户现场实施" not in str(consumed.field_fingerprints_json)
|
||
assert next_decision is not None
|
||
assert next_decision.status == "issued"
|
||
assert next_decision.decision_source == "server_draft"
|
||
assert decision is not None
|
||
assert decision.preview_decision_id == consumed.id
|
||
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
|
||
assert feedback is not None
|
||
assert feedback.verification_status == "server_verified"
|
||
assert feedback.feedback_type == "accepted"
|
||
assert feedback.training_eligible is False
|
||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||
|
||
|
||
def test_same_snapshot_with_different_issue_request_reuses_active_decision() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
first = issue_preview(client, request_id="issue-preview-same-snapshot-1")
|
||
second = issue_preview(client, request_id="issue-preview-same-snapshot-2")
|
||
|
||
assert second["decision_id"] == first["decision_id"]
|
||
with session_factory() as db:
|
||
decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all())
|
||
assert len(decisions) == 1
|
||
assert decisions[0].status == "issued"
|
||
|
||
|
||
def test_server_preview_decision_detects_server_side_field_edit() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client, request_id="issue-preview-edited")
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=build_action_payload(issued, reason="客户现场验收"),
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
with session_factory() as db:
|
||
feedback = db.scalar(select(AIDecisionFeedback))
|
||
assert feedback is not None
|
||
assert feedback.feedback_type == "edited"
|
||
assert feedback.verification_status == "server_verified"
|
||
assert feedback.changed_fields_json == [
|
||
{
|
||
"field_key": "reason",
|
||
"suggested_value_fingerprint": feedback.changed_fields_json[0][
|
||
"suggested_value_fingerprint"
|
||
],
|
||
"final_value_fingerprint": feedback.changed_fields_json[0][
|
||
"final_value_fingerprint"
|
||
],
|
||
}
|
||
]
|
||
assert "客户现场" not in str(feedback.changed_fields_json)
|
||
|
||
|
||
def test_server_preview_decision_detects_field_added_after_issuance() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(
|
||
client,
|
||
request_id="issue-preview-added-field",
|
||
include_transport=False,
|
||
)
|
||
payload = build_action_payload(issued)
|
||
payload["message"] = payload["message"].replace(
|
||
"申请金额:1800元",
|
||
"出行方式:飞机\n申请金额:1800元",
|
||
)
|
||
payload["context_json"]["application_preview"]["fields"]["transportMode"] = "飞机"
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=payload,
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
with session_factory() as db:
|
||
feedback = db.scalar(select(AIDecisionFeedback))
|
||
assert feedback is not None
|
||
assert feedback.feedback_type == "edited"
|
||
assert {item["field_key"] for item in feedback.changed_fields_json} >= {"transport_mode"}
|
||
|
||
|
||
def test_server_preview_decision_records_cleared_suggestion_as_rejected() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client, request_id="issue-preview-rejected-field")
|
||
payload = build_action_payload(issued)
|
||
payload["message"] = payload["message"].replace("出行方式:飞机\n", "")
|
||
payload["context_json"]["application_preview"]["fields"]["transportMode"] = ""
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=payload,
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
with session_factory() as db:
|
||
feedback = db.scalar(select(AIDecisionFeedback))
|
||
assert feedback is not None
|
||
assert feedback.feedback_type == "rejected"
|
||
assert feedback.verification_status == "server_verified"
|
||
assert {item["field_key"] for item in feedback.changed_fields_json} == {
|
||
"transport_mode"
|
||
}
|
||
|
||
|
||
def test_server_preview_decision_rejects_cross_session_replay_without_writes() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client, request_id="issue-preview-cross-session")
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(session_id="another-session"),
|
||
json=build_action_payload(issued),
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
assert "不属于当前登录会话" in response.json()["detail"]
|
||
with session_factory() as db:
|
||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
assert decision is not None
|
||
assert decision.status == "issued"
|
||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||
|
||
|
||
def test_active_server_preview_cannot_downgrade_by_omitting_decision_id() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client, request_id="issue-preview-downgrade")
|
||
payload = build_action_payload(issued)
|
||
payload.pop("decision_id")
|
||
payload.pop("request_id")
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=payload,
|
||
)
|
||
|
||
assert response.status_code == 409, response.text
|
||
assert "必须携带 decision_id" in response.json()["detail"]
|
||
with session_factory() as db:
|
||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
assert decision is not None
|
||
assert decision.status == "issued"
|
||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||
|
||
|
||
def test_legacy_action_without_active_server_decision_keeps_client_observed_path() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
payload = build_legacy_action_payload()
|
||
payload.pop("decision_id")
|
||
payload.pop("request_id")
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=payload,
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
with session_factory() as db:
|
||
feedback = db.scalar(select(AIDecisionFeedback))
|
||
assert feedback is not None
|
||
assert feedback.verification_status == "client_observed"
|
||
assert feedback.training_eligible is False
|
||
|
||
|
||
def test_missing_fingerprint_key_rejects_action_without_business_writes() -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
issued = issue_preview(client, request_id="issue-preview-missing-key")
|
||
with session_factory() as db:
|
||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
assert decision is not None
|
||
decision.fingerprint_key_version = "missing-test-key"
|
||
db.commit()
|
||
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=build_action_payload(issued),
|
||
)
|
||
|
||
assert response.status_code == 400, response.text
|
||
assert "密钥版本不可用" in response.json()["detail"]
|
||
with session_factory() as db:
|
||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
assert decision is not None
|
||
assert decision.status == "issued"
|
||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||
|
||
|
||
def test_draft_save_stays_successful_when_next_decision_issuance_fails(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
client, session_factory = build_client()
|
||
with session_factory() as db:
|
||
seed_employee(db)
|
||
|
||
original_issue = ExpenseApplicationPreviewDecisionService.issue
|
||
|
||
def fail_server_draft_issue(
|
||
service: ExpenseApplicationPreviewDecisionService,
|
||
*args: object,
|
||
**kwargs: object,
|
||
) -> object:
|
||
if kwargs.get("decision_source") == "server_draft":
|
||
raise RuntimeError("simulated next decision failure")
|
||
return original_issue(service, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(
|
||
ExpenseApplicationPreviewDecisionService,
|
||
"issue",
|
||
fail_server_draft_issue,
|
||
)
|
||
issued = issue_preview(client, request_id="issue-preview-next-failure")
|
||
response = client.post(
|
||
"/api/v1/reimbursements/application-preview-action",
|
||
headers=auth_headers(),
|
||
json=build_action_payload(issued),
|
||
)
|
||
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["result"]["draft_payload"]["claim_id"]
|
||
assert response.json()["result"]["decision_id"] is None
|
||
with session_factory() as db:
|
||
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||
assert consumed is not None
|
||
assert consumed.status == "consumed"
|
||
assert len(list(db.scalars(select(ExpenseClaim)).all())) == 1
|
||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||
|
||
|
||
def test_fingerprint_key_is_created_with_private_permissions(
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
key_directory = tmp_path / "fingerprint-keys"
|
||
monkeypatch.setattr(
|
||
fingerprint_keys,
|
||
"FINGERPRINT_KEY_DIRECTORY",
|
||
key_directory,
|
||
)
|
||
|
||
key = fingerprint_keys.get_expense_application_fingerprint_key(
|
||
"permission-test",
|
||
create=True,
|
||
)
|
||
|
||
key_path = key_directory / "permission-test.key"
|
||
assert len(key) == fingerprint_keys.KEY_BYTES
|
||
assert stat.S_IMODE(key_directory.stat().st_mode) == 0o700
|
||
assert stat.S_IMODE(key_path.stat().st_mode) == 0o600
|