from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime from pathlib import Path from types import MethodType import pytest from auth_helpers import install_legacy_header_auth_override from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine, event, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext, get_db from app.api.v1.endpoints.agent_asset_releases import _actor as release_actor from app.api.v1.endpoints.agent_assets import router as agent_assets_router from app.db.base import Base from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVersion from app.models.financial_record import ExpenseClaim from app.models.tenant import Tenant from app.schemas.agent_asset import AgentAssetRiskRuleScenarioTestRequest from app.services.agent_asset_access import stable_user_principal from app.services.agent_asset_onlyoffice_security import ( AgentAssetOnlyOfficeSessionService, ) from app.services.agent_assets import AgentAssetService from app.services.settings import OnlyOfficeRuntimeConfig def _factory() -> sessionmaker[Session]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) return sessionmaker(bind=engine, autoflush=False, autocommit=False) def _seed(db: Session) -> dict[str, AgentAsset]: db.add_all( [ Tenant(tenant_id="platform", tenant_code="platform", name="平台管理域"), Tenant(tenant_id="tenant-a", tenant_code="tenant-a", name="租户 A"), Tenant(tenant_id="tenant-b", tenant_code="tenant-b", name="租户 B"), ] ) assets = { "platform": AgentAsset( tenant_id="platform", scope="platform", asset_type="rule", code="rule.shared", name="平台规则", domain="expense", owner="platform", status="draft", current_version="v1.0.0", working_version="v1.0.0", ), "tenant-a": AgentAsset( tenant_id="tenant-a", scope="tenant", asset_type="rule", code="rule.tenant", name="租户 A 规则", domain="expense", owner="tenant-a", status="draft", current_version="v1.0.0", working_version="v1.0.0", ), "tenant-b": AgentAsset( tenant_id="tenant-b", scope="tenant", asset_type="rule", code="rule.tenant", name="租户 B 规则", domain="expense", owner="tenant-b", status="draft", current_version="v1.0.0", working_version="v1.0.0", ), } db.add_all(assets.values()) db.flush() for asset in assets.values(): db.add( AgentAssetVersion( tenant_id=asset.tenant_id, scope=asset.scope, asset_id=asset.id, version="v1.0.0", content="# v1", content_type="markdown", created_by="seed", ) ) db.commit() return assets def _client(factory: sessionmaker[Session], *, auth_override: bool = True) -> TestClient: app = FastAPI() app.include_router(agent_assets_router) if auth_override: install_legacy_header_auth_override(app) def override_db() -> Generator[Session, None, None]: with factory() as db: yield db app.dependency_overrides[get_db] = override_db return TestClient(app) def _headers( tenant_id: str, *, employee_id: str = "employee-a", is_admin: bool = False, ) -> dict[str, str]: return { "X-Auth-Username": f"user-{tenant_id}", "X-Auth-Name": "Mutable Display Name", "X-Auth-Employee-Id": employee_id, "X-Auth-Role-Codes": "finance,manager", "X-Auth-Is-Admin": "true" if is_admin else "false", "X-Auth-Tenant-Id": tenant_id, } @pytest.fixture(autouse=True) def _skip_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "app.services.agent_foundation.AgentFoundationService.ensure_foundation_ready", lambda _self: None, ) def test_asset_reads_are_authenticated_and_tenant_scoped() -> None: factory = _factory() with factory() as db: assets = _seed(db) ids = {key: value.id for key, value in assets.items()} client = _client(factory) listing = client.get("/agent-assets", headers=_headers("tenant-a")) assert listing.status_code == 200 visible_ids = {item["id"] for item in listing.json()} assert ids["platform"] in visible_ids assert ids["tenant-a"] in visible_ids assert ids["tenant-b"] not in visible_ids assert ( client.get( f"/agent-assets/{ids['tenant-b']}", headers=_headers("tenant-a") ).status_code == 404 ) assert ( client.get( f"/agent-assets/{ids['tenant-b']}/versions", headers=_headers("tenant-a"), ).status_code == 404 ) anonymous = _client(factory, auth_override=False).get("/agent-assets") assert anonymous.status_code == 401 def test_version_write_uses_stable_principal_and_blocks_cross_tenant_or_platform() -> None: factory = _factory() with factory() as db: assets = _seed(db) ids = {key: value.id for key, value in assets.items()} client = _client(factory) body = { "version": "v1.0.1", "content": "# tenant update", "content_type": "markdown", "created_by": "spoofed-display-name", } cross_tenant = client.post( f"/agent-assets/{ids['tenant-b']}/versions", json=body, headers=_headers("tenant-a"), ) assert cross_tenant.status_code == 404 platform_by_editor = client.post( f"/agent-assets/{ids['platform']}/versions", json=body, headers=_headers("tenant-a"), ) assert platform_by_editor.status_code == 400 own = client.post( f"/agent-assets/{ids['tenant-a']}/versions", json=body, headers=_headers("tenant-a", employee_id="employee-stable"), ) assert own.status_code == 201 assert own.json()["created_by"] == "employee:employee-stable" assert own.json()["tenant_id"] == "tenant-a" assert own.json()["scope"] == "tenant" platform_admin = client.post( f"/agent-assets/{ids['platform']}/versions", json=body, headers=_headers("tenant-a", is_admin=True), ) assert platform_admin.status_code == 201 assert platform_admin.json()["scope"] == "platform" def test_scenario_samples_filter_tenant_in_sql_and_persist_target_tenant() -> None: factory = _factory() with factory() as db: assets = _seed(db) platform_asset = assets["platform"] db.add_all( [ _claim("tenant-a", "A-001"), _claim("tenant-b", "B-001"), ] ) db.commit() statements: list[str] = [] @event.listens_for(db.get_bind(), "before_cursor_execute") def _capture(_conn, _cursor, statement, _parameters, _context, _executemany): if "FROM expense_claims" in statement: statements.append(statement) current_user = CurrentUserContext( username="reviewer-a", name="显示名一", role_codes=["manager"], is_admin=True, tenant_id="tenant-a", employee_id="employee-reviewer-a", ) service = AgentAssetService(db, current_user=current_user) def _load(_self, _asset_id: str, _version: str | None): return platform_asset, "v1.0.0", {} def _run(_self, _manifest: dict, claim: ExpenseClaim): return {"claim_id": claim.id, "hit": False, "severity": "none"} service._load_risk_rule_for_test = MethodType(_load, service) service._run_claim_scenario = MethodType(_run, service) result = service.run_risk_rule_scenario_test( platform_asset.id, AgentAssetRiskRuleScenarioTestRequest( target_tenant_id="tenant-a", intent="最近 30 天", ), actor="employee:employee-reviewer-a", ) assert result.result_json["total_count"] == 1 assert statements assert "expense_claims.tenant_id = ?" in statements[0] run = db.scalar(select(AgentAssetTestRun).where(AgentAssetTestRun.id == result.id)) assert run is not None assert run.tenant_id == "tenant-a" assert run.scope == "tenant" assert run.input_json["target_tenant_id"] == "tenant-a" with pytest.raises(LookupError, match="Asset not found"): service.run_risk_rule_scenario_test( platform_asset.id, AgentAssetRiskRuleScenarioTestRequest( target_tenant_id="tenant-b", intent="最近 30 天", ), actor="employee:employee-reviewer-a", ) def test_release_reviewer_identity_is_stable_when_display_name_changes() -> None: first = CurrentUserContext( username="reviewer", name="显示名一", role_codes=["manager"], is_admin=False, tenant_id="tenant-a", employee_id="employee-reviewer", ) renamed = CurrentUserContext( username="reviewer-renamed", name="显示名二", role_codes=["manager"], is_admin=False, tenant_id="tenant-a", employee_id="employee-reviewer", ) assert stable_user_principal(first) == "employee:employee-reviewer" assert stable_user_principal(first) == stable_user_principal(renamed) assert release_actor(first) == release_actor(renamed) def test_onlyoffice_callback_is_tenant_bound_and_one_time( monkeypatch: pytest.MonkeyPatch, ) -> None: factory = _factory() jwt_secret = "agent-asset-onlyoffice-test-secret-32-bytes" with factory() as db: assets = _seed(db) tenant_asset_id = assets["tenant-a"].id other_asset_id = assets["tenant-b"].id tokens = AgentAssetOnlyOfficeSessionService( db, jwt_secret=jwt_secret, ).issue( tenant_id="tenant-a", resource_scope="tenant", asset_id=tenant_asset_id, document_key="tenant-a-document-key", document_version="v1.0.0", document_fingerprint="tenant-a-fingerprint", writable=True, actor="employee:onlyoffice-editor", ) monkeypatch.setattr( "app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings", lambda: OnlyOfficeRuntimeConfig( enabled=True, public_url="https://onlyoffice.example.com", backend_url="https://backend.example.com", jwt_secret=jwt_secret, ), ) def _save_scoped_callback(self, *, claimed, download_url): assert download_url == "https://onlyoffice.example.com/download/tenant-a.xlsx" assert claimed.tenant_id == "tenant-a" assert self.repository.get(tenant_asset_id) is not None assert self.repository.get(other_asset_id) is None monkeypatch.setattr( AgentAssetService, "_save_current_rule_spreadsheet_callback", _save_scoped_callback, ) client = _client(factory) payload = { "status": 2, "url": "https://onlyoffice.example.com/download/tenant-a.xlsx", "key": "tenant-a-document-key", } missing_token = client.post( f"/agent-assets/{tenant_asset_id}/spreadsheet/onlyoffice/callback", json=payload, ) assert missing_token.status_code == 422 cross_asset = client.post( f"/agent-assets/{other_asset_id}/spreadsheet/onlyoffice/callback", params={"access_token": tokens.callback_token}, json=payload, ) assert cross_asset.status_code == 401 first = client.post( f"/agent-assets/{tenant_asset_id}/spreadsheet/onlyoffice/callback", params={"access_token": tokens.callback_token}, json=payload, ) assert first.status_code == 200 replay = client.post( f"/agent-assets/{tenant_asset_id}/spreadsheet/onlyoffice/callback", params={"access_token": tokens.callback_token}, json=payload, ) assert replay.status_code == 409 def test_tenant_security_migration_has_fixed_ancestry_and_fail_closed_downgrade() -> None: migration = Path( "/app/server/alembic/versions/20260717_0026_agent_asset_tenant_security.py" ).read_text(encoding="utf-8") assert 'revision: str = "20260717_0026"' in migration assert 'down_revision: str | None = "20260717_0025"' in migration assert "uq_agent_assets_tenant_scope_code" in migration assert "agent_asset_onlyoffice_sessions" in migration assert "target_tenant_id" in migration assert "_require_lossless_downgrade()" in migration assert "_require_no_onlyoffice_sessions()" in migration def _claim(tenant_id: str, claim_no: str) -> ExpenseClaim: return ExpenseClaim( tenant_id=tenant_id, claim_no=claim_no, employee_name="测试员工", department_name="测试部门", expense_type="差旅费", reason="真实场景测试", location="北京", amount=100, currency="CNY", invoice_count=0, occurred_at=datetime.now(UTC), status="submitted", )