"""OpenCode 审查 2026-09-07 修复验证 — ai_analysis/dashboard/bot_kpis/cost/kpis 多租户隔离越权用例 覆盖: - ai_analysis kpi_analysis 跨企业 kpi_id → 404 - ai_analysis generate_rule_suggestions 跨企业预警不进建议 - ai_analysis review-plans / ask 按账套过滤(统计口径 + 不 500) - dashboard summary alert_count 按账套 - bot_kpis update 跨企业写 → 404 / 同企业写 → 200 - cost dashboard/overview 按账套(ActualCost 多企业共用确认) - kpis create 白名单:status/important_flag 注入被忽略 """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, AISuggestion, Entity, UserEntity, ActualCost from app.api import ai_analysis from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header, TEST_USER_PASSWORD def _ensure_entity(db: Session, eid: int) -> Entity: ent = db.query(Entity).filter(Entity.id == eid).first() if not ent: ent = Entity(id=eid, name=f"企业{eid}", short_name=f"E{eid}", status="active") db.add(ent) db.commit() db.refresh(ent) return ent def _seed_kpi(db: Session, entity_id: int, code: str) -> KPIDefinition: return create_test_kpi(db, kpi_code=code, entity_id=entity_id, dimension="finance", status="active", target_value=100.0) def _seed_user(db: Session, entity_id: int): """默认测试用户,登录指定账套""" create_test_user(db) _ensure_entity(db, entity_id) # 无授权记录 → login 会默认授予全部 active 企业(_ensure_default_grants) db.commit() def _token(client: TestClient, entity_id: int) -> str: """登录并返回指定账套的 token(get_token_for_user 固定 entity1,这里手动登录)""" resp = client.post("/api/cma/auth/login", json={ "username": "testadmin", "password": TEST_USER_PASSWORD, "entity_id": entity_id, }) assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}" return resp.json().get("token") # ============================================================ # ai_analysis # ============================================================ class TestAiAnalysisIsolation: def test_kpi_analysis_cross_entity_404(self, client: TestClient, db: Session): """跨企业 kpi_id 调 kpi-analysis → 404(不泄漏他企 KPI 数据)""" _seed_user(db, 1) _ensure_entity(db, 2) k_e2 = _seed_kpi(db, entity_id=2, code="MT_AI_E2") db.commit() token1 = _token(client, 1) r = client.get(f"/api/cma/ai/kpi-analysis/{k_e2.id}", headers=auth_header(token1)) assert r.status_code == 404, f"跨企业 kpi 应 404: {r.text[:200]}" def test_kpi_analysis_own_entity_ok(self, client: TestClient, db: Session): """本企业 kpi_id → 200(回归保护)""" _seed_user(db, 1) k_e1 = _seed_kpi(db, entity_id=1, code="MT_AI_E1") db.commit() token1 = _token(client, 1) r = client.get(f"/api/cma/ai/kpi-analysis/{k_e1.id}", headers=auth_header(token1)) assert r.status_code == 200, r.text[:300] def test_generate_suggestions_cross_entity_alert_excluded(self, db: Session): """generate_rule_suggestions(entity=1) 不消费 entity2 的 pending 预警""" _ensure_entity(db, 2) k1 = _seed_kpi(db, entity_id=1, code="MT_SUG_E1") k2 = _seed_kpi(db, entity_id=2, code="MT_SUG_E2") db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="e1预警", status="pending", entity_id=1)) a2 = KPIAlert(kpi_id=k2.id, alert_level="red", alert_message="e2预警", status="pending", entity_id=2) db.add(a2) db.commit() ai_analysis.generate_rule_suggestions(db, entity_id=1, source="test", user_id=None) db.commit() # entity1 的预警 → 生成 alert 类建议 sugs = db.query(AISuggestion).filter(AISuggestion.source == "test").all() alert_sugs = [s for s in sugs if s.suggestion_type == "action_plan" and s.target_type == "alert"] assert len(alert_sugs) >= 1, "本账套 pending 预警应生成建议" # 无一建议引用 entity2 的预警 id / KPI sug_alert_ids = {s.suggestion_data.get("alert_id") for s in alert_sugs} sug_kpi_ids = {s.suggestion_data.get("kpi_id") for s in alert_sugs} assert a2.id not in sug_alert_ids, "跨企业预警不应进入建议" assert k2.id not in sug_kpi_ids, "跨企业 KPI 不应进入建议" def test_review_plans_entity_scoped(self, client: TestClient, db: Session): """review-plans 只复盘本账套 ActionPlan(stats.total 口径隔离)""" _seed_user(db, 1) _ensure_entity(db, 2) k1 = _seed_kpi(db, entity_id=1, code="MT_PL_E1") k2 = _seed_kpi(db, entity_id=2, code="MT_PL_E2") db.add(ActionPlan(entity_id=1, alert_id=None, kpi_id=k1.id, title="e1计划", assignee="张三", priority="high", status="in_progress", progress=50, created_by="系统")) db.add(ActionPlan(entity_id=2, alert_id=None, kpi_id=k2.id, title="e2计划", assignee="李四", priority="high", status="pending", progress=0, created_by="系统")) db.commit() token1 = _token(client, 1) r = client.post("/api/cma/ai/review-plans", headers=auth_header(token1)) assert r.status_code == 200, r.text[:300] stats = r.json()["stats"] assert stats["total"] == 1, f"应只统计本账套1条,实际 {stats}" def test_ask_question_no_crash_cross_entity(self, client: TestClient, db: Session): """ask 上下文含他企 KPI 时不 500 且正常返回(entity 过滤后无泄漏)""" _seed_user(db, 1) _ensure_entity(db, 2) k2 = _seed_kpi(db, entity_id=2, code="MT_ASK_E2") db.add(KPIValue(kpi_id=k2.id, period="2026-06", actual_value=88.0, entity_id=2)) db.commit() token1 = _token(client, 1) r = client.post("/api/cma/ai/ask", headers=auth_header(token1), json={"question": "企业营收情况如何"}) assert r.status_code == 200, r.text[:300] assert "answer" in r.json() # ============================================================ # dashboard summary alert_count # ============================================================ class TestDashboardAlertCountIsolation: def test_alert_count_per_entity(self, client: TestClient, db: Session): """summary alert_count 按账套:entity1 只数自己的 pending 预警""" from app.utils import cache as cache_util cache_util.delete("dashboard") # 防跨测试缓存污染 _seed_user(db, 1) _ensure_entity(db, 2) k1 = _seed_kpi(db, entity_id=1, code="MT_DB_E1") k2 = _seed_kpi(db, entity_id=2, code="MT_DB_E2") db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="e1", status="pending", entity_id=1)) db.add(KPIAlert(kpi_id=k2.id, alert_level="red", alert_message="e2a", status="pending", entity_id=2)) db.add(KPIAlert(kpi_id=k2.id, alert_level="yellow", alert_message="e2b", status="resolved", entity_id=2)) db.commit() token1 = _token(client, 1) token2 = _token(client, 2) r1 = client.get("/api/cma/dashboard/summary", headers=auth_header(token1)) assert r1.status_code == 200, r1.text[:200] assert r1.json()["alert_count"] == 1, f"entity1 应只数自己的1条: {r1.json()['alert_count']}" cache_util.delete("dashboard") r2 = client.get("/api/cma/dashboard/summary", headers=auth_header(token2)) assert r2.status_code == 200, r2.text[:200] assert r2.json()["alert_count"] == 1, f"entity2 应只数自己的 pending 1条(不含resolved): {r2.json()['alert_count']}" # ============================================================ # bot_kpis 归属校验 # ============================================================ class TestBotKpisOwnership: def _bot_kpi(self, db: Session, entity_id: int, code: str) -> KPIDefinition: return create_test_kpi(db, kpi_code=code, entity_id=entity_id, status="active", bot_source="finance-bot", kpi_name=code, target_value=100.0) def test_cross_entity_update_404(self, client: TestClient, db: Session): """entity1 用户改 entity2 的 Bot KPI → 404""" _seed_user(db, 1) _ensure_entity(db, 2) k2 = self._bot_kpi(db, entity_id=2, code="MT_BOT_E2") db.commit() token1 = _token(client, 1) r = client.post(f"/api/cma/bot-kpis/{k2.id}/value", headers=auth_header(token1), json={"actual_value": 55, "period": "2026-09"}) assert r.status_code == 404, f"跨企业改 Bot KPI 应 404: {r.status_code} {r.text[:200]}" def test_same_entity_update_ok(self, client: TestClient, db: Session): """本企业改自己的 Bot KPI → 200(回归保护)""" _seed_user(db, 1) k1 = self._bot_kpi(db, entity_id=1, code="MT_BOT_E1") db.commit() token1 = _token(client, 1) r = client.post(f"/api/cma/bot-kpis/{k1.id}/value", headers=auth_header(token1), json={"actual_value": 66, "period": "2026-09"}) assert r.status_code == 200, r.text[:300] # ============================================================ # cost 多账套(ActualCost 共用表确认 → 过滤) # ============================================================ class TestCostIsolation: def _seed_actual(self, db: Session, entity_id: int, code: str, amount: float, period: str = "2026-06"): ac = ActualCost(entity_id=entity_id, period=period, product_code=code, product_name=code, cost_type="material", item_name="材料", actual_quantity=1.0, actual_price=amount, actual_cost=amount, source="manual") db.add(ac) def test_cost_dashboard_entity_filtered(self, client: TestClient, db: Session): """cost/dashboard 产品列表/总额按账套过滤(ActualCost 多企业共用)""" _seed_user(db, 1) _ensure_entity(db, 2) self._seed_actual(db, 1, "PROD_E1", 100.0) self._seed_actual(db, 2, "PROD_E2", 200.0) db.commit() token1 = _token(client, 1) token2 = _token(client, 2) r1 = client.get("/api/cma/cost/dashboard?period=2026-06", headers=auth_header(token1)) assert r1.status_code == 200, r1.text[:300] codes1 = {p["code"] for p in r1.json()["products"]} assert codes1 == {"PROD_E1"}, f"entity1 不应看到 entity2 产品: {codes1}" r2 = client.get("/api/cma/cost/dashboard?period=2026-06", headers=auth_header(token2)) assert r2.status_code == 200, r2.text[:300] codes2 = {p["code"] for p in r2.json()["products"]} assert codes2 == {"PROD_E2"}, f"entity2 不应看到 entity1 产品: {codes2}" def test_cost_overview_entity_filtered(self, client: TestClient, db: Session): """cost/overview manual_cost 按账套过滤""" _seed_user(db, 1) _ensure_entity(db, 2) self._seed_actual(db, 1, "OV_E1", 100.0) self._seed_actual(db, 2, "OV_E2", 300.0) db.commit() token1 = _token(client, 1) token2 = _token(client, 2) r1 = client.get("/api/cma/cost/overview?period=2026-06", headers=auth_header(token1)) assert r1.status_code == 200, r1.text[:300] assert r1.json()["manual_cost"] == 100.0, f"entity1 manual_cost 应100: {r1.json()}" r2 = client.get("/api/cma/cost/overview?period=2026-06", headers=auth_header(token2)) assert r2.status_code == 200, r2.text[:300] assert r2.json()["manual_cost"] == 300.0, f"entity2 manual_cost 应300: {r2.json()}" # ============================================================ # kpis create 白名单(mass-assignment 残留项) # ============================================================ class TestKpiCreateWhitelist: def test_create_injected_sensitive_fields_ignored(self, client: TestClient, db: Session): """create 带 status/important_flag 注入 → 被忽略,落库为默认值""" _seed_user(db, 1) token1 = _token(client, 1) resp = client.post("/api/cma/kpis", headers=auth_header(token1), json={ "kpi_code": "F_MT_INJ_001", "kpi_name": "注入测试指标", "dimension": "finance", "target_value": 100.0, "unit": "%", "formula": "测试公式", "data_source": "测试系统", "data_owner": "测试管理员", "status": "disabled", # 敏感字段:注入应被忽略 → 默认 active "important_flag": 1, # 敏感字段:注入应被忽略 → 默认 0 "entity_id": 999, # 归属字段:强制覆盖为 token 企业 1 }) assert resp.status_code == 200, resp.text[:300] data = resp.json() assert data["status"] == "active", f"status 注入应被忽略: {data.get('status')}" assert data.get("important_flag", 0) == 0, f"important_flag 注入应被忽略: {data.get('important_flag')}" row = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_MT_INJ_001").first() assert row is not None assert row.entity_id == 1, f"entity_id 应强制为 token 企业1: {row.entity_id}"