Files
X-Financial/server/tests/test_expense_application_memory_admin_api.py

280 lines
9.3 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
from collections.abc import Generator
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.db.base import Base
from app.main import create_app
from app.models.ai_memory import MemoryEntry
from app.models.organization import OrganizationUnit
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
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)
with session_factory() as db:
db.add(
OrganizationUnit(
id="department-finance",
unit_code="FINANCE",
name="财务部",
unit_type="department",
)
)
db.commit()
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
return TestClient(app), session_factory
def _headers(*, tenant_id: str = "tenant-a", admin: bool = True) -> dict[str, str]:
return {
"X-Auth-Username": "admin@example.com" if admin else "employee@example.com",
"X-Auth-Name": "Tenant Admin" if admin else "Employee",
"X-Auth-Tenant-Id": tenant_id,
"X-Auth-Is-Admin": "true" if admin else "false",
"X-Auth-Role-Codes": "manager" if admin else "user",
}
def test_organization_memory_api_requires_platform_admin() -> None:
client, _ = _build_client()
response = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(admin=False),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-denied",
},
)
assert response.status_code == 403
def test_organization_memory_api_lifecycle_and_tenant_isolation() -> None:
client, session_factory = _build_client()
created = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-a",
},
)
assert created.status_code == 201, created.text
first = created.json()
assert first["scope_id"] == "tenant-a"
assert first["generation"] == 1
assert first["can_revoke"] is True
replayed = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-a",
},
)
assert replayed.status_code == 201
assert replayed.json()["id"] == first["id"]
changed_create_payload = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "飞机",
"expires_in_days": 180,
"reason": "同键不同请求体",
"request_id": "create-enterprise-memory-a",
},
)
assert changed_create_payload.status_code == 409
duplicate = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "轮船",
"expires_in_days": 180,
"reason": "不应静默覆盖",
"request_id": "create-enterprise-memory-b",
},
)
assert duplicate.status_code == 409
updated = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "飞机",
"expires_in_days": 120,
"expected_generation": 1,
"reason": "制度版本调整",
"request_id": "update-enterprise-memory-a",
},
)
assert updated.status_code == 200, updated.text
second = updated.json()
assert second["generation"] == 2
assert second["value"] == "飞机"
changed_update_payload = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "轮船",
"expires_in_days": 120,
"expected_generation": 1,
"reason": "同键不同请求体",
"request_id": "update-enterprise-memory-a",
},
)
assert changed_update_payload.status_code == 409
stale_old_id = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "轮船",
"expected_generation": 1,
"reason": "旧地址更新",
"request_id": "update-enterprise-memory-old-id",
},
)
assert stale_old_id.status_code == 409
stale = client.put(
f"/api/v1/expense-application-memories/organization/{second['id']}",
headers=_headers(),
json={
"value": "轮船",
"expected_generation": 1,
"reason": "陈旧页面更新",
"request_id": "update-enterprise-memory-stale",
},
)
assert stale.status_code == 409
assert client.get(
"/api/v1/expense-application-memories/organization",
headers=_headers(tenant_id="tenant-b"),
).json() == {"items": []}
hidden = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(tenant_id="tenant-b"),
json={
"expected_generation": 2,
"reason": "越权尝试",
"request_id": "revoke-other-tenant",
},
)
assert hidden.status_code == 404
revoked = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "改用新制度",
"request_id": "revoke-enterprise-memory-a",
},
)
assert revoked.status_code == 200, revoked.text
replayed_revoke = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "改用新制度",
"request_id": "revoke-enterprise-memory-a",
},
)
assert replayed_revoke.status_code == 200
assert replayed_revoke.json() == revoked.json()
changed_revoke_payload = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "同键不同请求体",
"request_id": "revoke-enterprise-memory-a",
},
)
assert changed_revoke_payload.status_code == 409
with session_factory() as db:
entries = list(
db.scalars(
select(MemoryEntry)
.where(MemoryEntry.tenant_id == "tenant-a")
.order_by(MemoryEntry.generation.asc())
).all()
)
assert [entry.status for entry in entries] == ["suppressed", "revoked"]
assert entries[0].superseded_by_id == entries[1].id
assert entries[0].management_request_id == "create-enterprise-memory-a"
assert entries[0].management_payload_fingerprint.startswith("hmac-sha256:")
assert entries[1].management_request_id == "update-enterprise-memory-a"
assert entries[1].revoke_request_id == "revoke-enterprise-memory-a"
assert entries[1].revoke_payload_fingerprint.startswith("hmac-sha256:")
def test_department_memory_api_requires_stable_department_id() -> None:
client, _ = _build_client()
invalid = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "department",
"scope_id": "财务部",
"value": "火车",
"expires_in_days": 90,
"reason": "部门差旅基线",
"request_id": "create-department-invalid",
},
)
assert invalid.status_code == 422
created = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "department",
"scope_id": "department-finance",
"value": "火车",
"expires_in_days": 90,
"reason": "部门差旅基线",
"request_id": "create-department-finance",
},
)
assert created.status_code == 201, created.text
assert created.json()["scope_id"] == "department-finance"
assert "财务部" in created.json()["scope_label"]