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