2026-07-17 14:14:08 +08:00
|
|
|
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
|
2026-07-20 10:30:22 +08:00
|
|
|
from app.services.agent_asset_access import AgentAssetAccessScope, stable_user_principal
|
2026-07-17 14:14:08 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 10:30:22 +08:00
|
|
|
def test_platform_tenant_scope_requires_real_admin_and_is_platform_only() -> None:
|
|
|
|
|
non_admin = CurrentUserContext(
|
|
|
|
|
username="platform-manager",
|
|
|
|
|
name="平台经理",
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
is_admin=False,
|
|
|
|
|
tenant_id="platform",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(PermissionError, match="有效租户"):
|
|
|
|
|
AgentAssetAccessScope.from_user(non_admin)
|
|
|
|
|
|
|
|
|
|
platform_admin = CurrentUserContext(
|
|
|
|
|
username="platform-admin",
|
|
|
|
|
name="平台管理员",
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
is_admin=True,
|
|
|
|
|
tenant_id="platform",
|
|
|
|
|
)
|
|
|
|
|
access_scope = AgentAssetAccessScope.from_user(platform_admin)
|
|
|
|
|
|
|
|
|
|
factory = _factory()
|
|
|
|
|
with factory() as db:
|
|
|
|
|
assets = _seed(db)
|
|
|
|
|
visible_assets = list(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(AgentAsset).where(access_scope.visibility_clause(AgentAsset))
|
|
|
|
|
).all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert [asset.id for asset in visible_assets] == [assets["platform"].id]
|
|
|
|
|
assert access_scope.can_write(assets["platform"]) is True
|
|
|
|
|
assert access_scope.can_write(assets["tenant-a"]) is False
|
|
|
|
|
access_scope.require_write(assets["platform"])
|
|
|
|
|
with pytest.raises(LookupError, match="Asset not found"):
|
|
|
|
|
access_scope.require_write(assets["tenant-a"])
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 14:14:08 +08:00
|
|
|
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",
|
|
|
|
|
)
|