280 lines
9.3 KiB
Python
280 lines
9.3 KiB
Python
|
|
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"]
|