feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
279
server/tests/test_expense_application_memory_admin_api.py
Normal file
279
server/tests/test_expense_application_memory_admin_api.py
Normal file
@@ -0,0 +1,279 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user