fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers (products/cost/predict/...) relied on assert_entity_scope, which treats a missing session token as the Bot service channel and lets the request through. Result: 46 user endpoints returned data without any token on a public site. Changes: - auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to require_auth otherwise); 401/403 semantics are explicit. - router-level Depends(require_auth_401) on products, cost, predict, notifications, permissions, tenant, alignment; endpoint-level on /auth/roles and /admin/cache/clear. - deps.py: document that get_entity_id/assert_entity_scope are NOT auth. - frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2, Dashboard x1). - tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401, token 200, cross-entity 403, bot channel unchanged); adapt test_predict / test_kpi_forecast to send session tokens like production. - scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
This commit is contained in:
@@ -9,11 +9,14 @@ from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||
from app.models import KPIDefinition, OperationLog, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
||||
# 不设全局权限,每个接口单独控制
|
||||
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
|
||||
# 原注释「不设全局权限,每个接口单独控制」保留语义——写接口仍各自叠加 require_role,
|
||||
# router 级只是「至少已认证」底线(补齐 /modes、GET /config 原无鉴权的缺口)。
|
||||
dependencies=[Depends(require_auth_401)],
|
||||
)
|
||||
|
||||
# 三种对齐模式定义
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, UserEntity, Entity, OperationLog
|
||||
from app.auth_middleware import create_token, require_auth, ROLES
|
||||
from app.auth_middleware import create_token, require_auth, require_auth_401, ROLES
|
||||
|
||||
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
|
||||
|
||||
@@ -111,8 +111,13 @@ def get_me(request: Request, current_user: User = Depends(require_auth), db: Ses
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles():
|
||||
"""返回角色列表(给前端用)"""
|
||||
def list_roles(_=Depends(require_auth_401)):
|
||||
"""返回角色列表(给前端用)
|
||||
|
||||
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):原无鉴权公开返回角色
|
||||
枚举(对外暴露系统角色模型 + 便于针对性撞库)。仓内检索确认无任何前端/脚本调用方
|
||||
(前端用 /permissions/modules 的 roles 字段),故直接补 require_auth_401。
|
||||
"""
|
||||
return {
|
||||
"data": [
|
||||
{"code": k, "name": v["name"], "priority": v["priority"]}
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models.cost_model import RESEARCH_PROJECT_TYPES
|
||||
from app.utils.cost_engine import (
|
||||
@@ -14,7 +15,12 @@ from app.utils.cost_engine import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.cost")
|
||||
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 本 router 原无任何鉴权依赖(端点仅 assert_entity_scope,无 token 走"Bot 服务通道"分支放行)
|
||||
# → 无 token 可读成本数据、可写标准成本。收口为 require_auth_401(无 token → 401)。
|
||||
# Bot 通道走 /api/cma/bot/cost/*(X-BOT-KEY)不受影响。
|
||||
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def _resolve_research_dims(data: dict, current=None) -> tuple:
|
||||
|
||||
@@ -3,10 +3,14 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import NotificationChannel, NotificationLog
|
||||
from app.auth_middleware import require_role
|
||||
from app.auth_middleware import require_role, require_auth_401
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
|
||||
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
|
||||
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def ch_to_dict(c):
|
||||
|
||||
@@ -7,9 +7,13 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import RolePermission
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||
|
||||
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:/modules 与 GET /config 无鉴权——无 token 可读全量角色权限矩阵(横向越权情报)。
|
||||
# 写端点 PUT /config 原有 require_role(ceo,it),保留(router 级为「至少已认证」底线)。
|
||||
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
# 模块定义(所有可配置的模块)
|
||||
MODULES = [
|
||||
|
||||
@@ -11,10 +11,16 @@ from app.utils.cash_forecast_engine import (
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.auth_middleware import require_auth_401
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger("cma.predict")
|
||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 本 router 原无任何鉴权依赖:无 token 可调用全部预测/模拟端点(含多个 CPU 密集的
|
||||
# POST 计算端点与 cash-forecast 落库),既泄露经营数据也构成计算资源滥用面。
|
||||
# 收口为 require_auth_401(无 token → 401)。Bot 通道(X-BOT-KEY)不走本 router。
|
||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
@router.post("/cvp")
|
||||
|
||||
@@ -4,10 +4,14 @@ from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import assert_entity_scope
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import ProductSales
|
||||
from collections import defaultdict
|
||||
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def _calc_quadrant(trend: float, margin: float) -> str:
|
||||
|
||||
@@ -3,9 +3,14 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import SystemConfig, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
||||
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
TENANT_KEY = "current_tenant"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, RolePermission
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import json
|
||||
|
||||
@@ -199,6 +200,23 @@ def require_role(*roles: str):
|
||||
return role_checker
|
||||
|
||||
|
||||
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)──────────────
|
||||
# FastAPI 的 HTTPBearer(auto_error=True) 对"缺 Authorization 头"返回 403;
|
||||
# 安全语义上「未认证」应为 401(401=未认证 / 403=无权)。
|
||||
# 本依赖用 auto_error=False 自行判空 → 缺 token 显式 401,有 token 仍完全委托 require_auth。
|
||||
_optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def require_auth_401(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
|
||||
return require_auth(credentials=credentials, db=db)
|
||||
|
||||
|
||||
def has_permission(user: User, module: str, db: Session = None) -> bool:
|
||||
routes, _ = _load_permissions(db)
|
||||
return module in routes.get(user.role, [])
|
||||
|
||||
@@ -116,6 +116,15 @@ def assert_entity_scope(
|
||||
- 会话 token 绑定实体存在(登录用户):explicit 与之不符 → 403(含 0=全部)
|
||||
- 无 token(Bot 服务通道):explicit 需存在且 active,否则 403(防越权注入)
|
||||
返回会话账套 entity_id(无 token → None,由调用方按原口径兜底)。
|
||||
|
||||
⚠️ 契约(cma-api-auth-surface-audit-20260914):本函数**不是鉴权**。
|
||||
「无 token → 按 Bot 服务通道放行」这一分支**仅对真正以 X-BOT-KEY 鉴权的 Bot 端点有效**
|
||||
(bot_bridge / bot_kpis / bot_iron_law / bot_suggestions / cash_vouchers_json 等,
|
||||
见 app/api/bot_bridge.py 的 verify_bot_key)。
|
||||
用户端点(前端 Bearer 调用)**必须**在 router 级或端点级挂 `require_auth` /
|
||||
`require_auth_401`;否则未挂鉴权的用户端点 = 公开可访问(无 token 直接取数/写数)。
|
||||
自查:`grep -n 'require_auth' app/api/<文件>.py` 无命中 且 该文件路由非 /api/cma/bot* →
|
||||
即存在鉴权缺口。
|
||||
"""
|
||||
session_entity = get_session_entity(request)
|
||||
if session_entity is not None:
|
||||
|
||||
+10
-15
@@ -10,7 +10,7 @@ from app.database import init_db, get_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
from app.auth_middleware import require_auth, require_auth_401
|
||||
from app.api.audit_log import bot_audit_middleware
|
||||
|
||||
load_dotenv()
|
||||
@@ -118,18 +118,8 @@ _ERP_CHANNEL_DISABLED_REASON = ("ERP直连同步通道已于2026-08-31停用(e
|
||||
|
||||
# ── admin 端点鉴权(2026-09-13)──────────────────────────────
|
||||
# 未带 token → 显式 401(缺少认证),校验逻辑仍完全委托 require_auth(无效 token → 401)。
|
||||
# 说明:FastAPI 的 HTTPBearer(auto_error=True) 对"缺 header"返回 403;本端点验收要求 401,
|
||||
# 故用 auto_error=False 的实例自行判空,不改动全局 require_auth(避免其他端点行为漂移)。
|
||||
_optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def require_auth_401(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
|
||||
return require_auth(credentials=credentials, db=db)
|
||||
# 依赖实现已上移到 app/auth_middleware.py 的 require_auth_401(用户端点统一入口,
|
||||
# cma-api-auth-surface-audit-20260914):原局部定义等价迁移,其他端点行为不变。
|
||||
|
||||
|
||||
@app.post("/api/cma/admin/erp-sync")
|
||||
@@ -184,8 +174,13 @@ def admin_check_alerts(entity_id: Optional[int] = None, user=Depends(require_aut
|
||||
|
||||
|
||||
@app.post("/api/cma/admin/cache/clear")
|
||||
def admin_clear_cache(module: str = None):
|
||||
"""清空缓存,指定module则只清该模块"""
|
||||
def admin_clear_cache(module: str = None, user=Depends(require_auth_401)):
|
||||
"""清空缓存,指定module则只清该模块
|
||||
|
||||
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):本端点原为无鉴权公开
|
||||
可写端点(无 token 亦可清空全站缓存 → 可被用于性能打击);补 require_auth_401,
|
||||
未带 token → 401。
|
||||
"""
|
||||
if module:
|
||||
delete_cache(module)
|
||||
return {"message": f"缓存已清空: {module}"}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CMA API 鉴权面审计器(cma-api-auth-surface-audit-20260914)
|
||||
|
||||
用途
|
||||
----
|
||||
枚举 FastAPI app 的**全部** APIRoute,逐个判定其鉴权姿态并分类:
|
||||
|
||||
A 类 — 合法 Bot 通道:以 X-BOT-KEY / bridge token 鉴权(verify_bot_key / verify_bridge_token)
|
||||
B 类 — 用户端点缺鉴权(缺口):既无 X-BOT-KEY 也无 require_auth 依赖
|
||||
C 类 — 显式公开:在 PUBLIC_PATHS 白名单内(登录/注册/验证码/官网留资/健康探针等)
|
||||
|
||||
判定依据是**依赖图**(route.dependant 递归),不是源码文本 grep——router 级
|
||||
dependencies=[Depends(require_auth_401)] 与端点级 Depends(require_auth) 都能识别。
|
||||
|
||||
用法(在 backend 目录下)
|
||||
-------------------------
|
||||
./venv/bin/python3 scripts/audit_api_auth_surface.py # 人类可读表格
|
||||
./venv/bin/python3 scripts/audit_api_auth_surface.py --json # 机器可读
|
||||
|
||||
退出码:0 = 无 B 类缺口;1 = 存在 B 类缺口(可直接当 CI 闸门用)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
|
||||
# 允许直接以脚本方式运行(把 backend 根加入 sys.path)
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND = os.path.dirname(_HERE)
|
||||
if _BACKEND not in sys.path:
|
||||
sys.path.insert(0, _BACKEND)
|
||||
|
||||
# 测试/离线环境变量兜底(不连接真实 MySQL:仅枚举路由,不触发 get_db)
|
||||
os.environ.setdefault("CMA_DB_USER", "audit")
|
||||
os.environ.setdefault("CMA_DB_PASS", "audit")
|
||||
os.environ.setdefault("CMA_DB_HOST", "localhost")
|
||||
os.environ.setdefault("CMA_DB_PORT", "3306")
|
||||
os.environ.setdefault("CMA_DB_NAME", "audit")
|
||||
|
||||
AUTH_STRICT = {"require_auth", "require_auth_401", "role_checker"}
|
||||
BOT_AUTH = {"verify_bot_key", "verify_bridge_token"}
|
||||
|
||||
# C 类:显式登记的公开端点((METHOD, path))
|
||||
PUBLIC_PATHS = {
|
||||
("GET", "/health"),
|
||||
("POST", "/api/cma/auth/login"),
|
||||
("POST", "/api/cma/auth/register"),
|
||||
("GET", "/api/cma/auth/login-entities"),
|
||||
("GET", "/api/cma/bot/ping"),
|
||||
("GET", "/api/cma/security/captcha/request"),
|
||||
("GET", "/api/cma/security/captcha/request2"),
|
||||
("POST", "/api/cma/security/captcha/verify"),
|
||||
("POST", "/api/cma/lead"),
|
||||
}
|
||||
|
||||
|
||||
def dep_names(route) -> set:
|
||||
"""递归收集 route 依赖图中所有可调用对象的名字"""
|
||||
acc = set()
|
||||
|
||||
def walk(dep, depth=0):
|
||||
if depth > 12:
|
||||
return
|
||||
fn = getattr(dep.call, "__name__", None)
|
||||
acc.add(fn if fn else dep.call.__class__.__name__)
|
||||
for sub in dep.dependencies:
|
||||
walk(sub, depth + 1)
|
||||
|
||||
for dep in route.dependant.dependencies:
|
||||
walk(dep)
|
||||
return acc
|
||||
|
||||
|
||||
def collect_routes(app) -> list:
|
||||
"""返回全部 APIRoute 的鉴权姿态行"""
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
rows = []
|
||||
for r in app.routes:
|
||||
if not isinstance(r, APIRoute):
|
||||
continue
|
||||
methods = sorted(m for m in r.methods if m not in ("HEAD", "OPTIONS"))
|
||||
deps = dep_names(r)
|
||||
auth = sorted(deps & AUTH_STRICT)
|
||||
bot = sorted(deps & BOT_AUTH)
|
||||
public = bool(methods) and all((m, r.path) in PUBLIC_PATHS for m in methods)
|
||||
if auth:
|
||||
cls = "AUTHED"
|
||||
elif bot:
|
||||
cls = "A" # Bot 通道
|
||||
elif public:
|
||||
cls = "C" # 显式公开
|
||||
else:
|
||||
cls = "B" # 缺口
|
||||
rows.append({
|
||||
"path": r.path,
|
||||
"methods": ",".join(methods),
|
||||
"handler": r.endpoint.__name__,
|
||||
"module": r.endpoint.__module__,
|
||||
"auth": auth,
|
||||
"bot_auth": bot,
|
||||
"class": cls,
|
||||
})
|
||||
rows.sort(key=lambda x: (x["class"], x["path"], x["methods"]))
|
||||
return rows
|
||||
|
||||
|
||||
def build_app():
|
||||
"""构建 app 实例(不连接真实 DB:仅导入模块)"""
|
||||
# 先让 database 模块指向内存 SQLite,避免任何意外真实连接
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app import database as db_module
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool)
|
||||
db_module.engine = engine
|
||||
db_module.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
from app.main import app
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = collect_routes(build_app())
|
||||
b = [r for r in rows if r["class"] == "B"]
|
||||
a = [r for r in rows if r["class"] == "A"]
|
||||
c = [r for r in rows if r["class"] == "C"]
|
||||
authed = [r for r in rows if r["class"] == "AUTHED"]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"total": len(rows), "authed": len(authed), "A": len(a),
|
||||
"B": len(b), "C": len(c), "rows": rows}, ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(f"CMA API 鉴权面审计 — 端点总数 {len(rows)}")
|
||||
print(f" AUTHED(require_auth/role) = {len(authed)}")
|
||||
print(f" A 类(合法 Bot 通道 X-BOT-KEY) = {len(a)}")
|
||||
print(f" C 类(显式公开) = {len(c)}")
|
||||
print(f" B 类(缺鉴权缺口) = {len(b)}")
|
||||
if b:
|
||||
print("\n=== B 类缺口(需补鉴权)===")
|
||||
for r in b:
|
||||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||||
if c:
|
||||
print("\n=== C 类(显式公开)===")
|
||||
for r in c:
|
||||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||||
return 1 if b else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,255 @@
|
||||
"""CMA API 鉴权面收口测试(cma-api-auth-surface-audit-20260914)
|
||||
|
||||
背景:项目Bot 实测发现部分用户端点无 token 亦可取数(products/matrix、cost/*、predict/*
|
||||
等)。根因:这些 router 未挂任何鉴权依赖,端点仅用 assert_entity_scope(它**不是鉴权**,
|
||||
无 token 时按「Bot 服务通道」放行)。
|
||||
|
||||
本文件锁定收口后的鉴权姿态:
|
||||
1. B 类(原缺鉴权用户端点)无 token → **401**(46 个端点逐条断言)
|
||||
2. C 类(显式公开端点)无 token 仍按原语义可用
|
||||
3. A 类(X-BOT-KEY Bot 通道)行为不变:带 key 200 / 无 key 401
|
||||
4. 既有 require_auth 端点(kpis/maps)无 token → 403(对照,不回归)
|
||||
5. 持 token 正常 200;跨实体(token entity=1 + entity_id=2)仍 403
|
||||
6. 结构性不变量:全量路由巡检无 B 类缺口(新增用户端点漏挂鉴权 → 本用例 FAIL)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, UserEntity
|
||||
from tests.conftest import create_test_user, auth_header
|
||||
|
||||
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||
|
||||
# ── B 类收口清单:原「无 token 亦可访问」的用户端点(46 个)──────────────
|
||||
# 无 token 必须 401。模板路径用具体 id=1(鉴权依赖先于路径/body 校验执行)。
|
||||
B_CLASS_ENDPOINTS = [
|
||||
("POST", "/api/cma/admin/cache/clear"),
|
||||
("GET", "/api/cma/alignment/config"),
|
||||
("GET", "/api/cma/alignment/modes"),
|
||||
("GET", "/api/cma/auth/roles"),
|
||||
("GET", "/api/cma/cost/abc/activities"),
|
||||
("POST", "/api/cma/cost/abc/activities"),
|
||||
("POST", "/api/cma/cost/abc/allocate"),
|
||||
("GET", "/api/cma/cost/abc/allocations"),
|
||||
("GET", "/api/cma/cost/actual-costs"),
|
||||
("POST", "/api/cma/cost/actual-costs"),
|
||||
("GET", "/api/cma/cost/breakdown"),
|
||||
("GET", "/api/cma/cost/comparison"),
|
||||
("GET", "/api/cma/cost/dashboard"),
|
||||
("GET", "/api/cma/cost/overview"),
|
||||
("GET", "/api/cma/cost/standard-costs"),
|
||||
("POST", "/api/cma/cost/standard-costs"),
|
||||
("PUT", "/api/cma/cost/standard-costs/1"),
|
||||
("DELETE", "/api/cma/cost/standard-costs/1"),
|
||||
("GET", "/api/cma/cost/variance"),
|
||||
("GET", "/api/cma/notifications/channels"),
|
||||
("POST", "/api/cma/notifications/channels"),
|
||||
("PUT", "/api/cma/notifications/channels/1"),
|
||||
("DELETE", "/api/cma/notifications/channels/1"),
|
||||
("POST", "/api/cma/notifications/channels/1/test"),
|
||||
("GET", "/api/cma/notifications/logs"),
|
||||
("GET", "/api/cma/permissions/config"),
|
||||
("GET", "/api/cma/permissions/modules"),
|
||||
("GET", "/api/cma/predict/accuracy"),
|
||||
("POST", "/api/cma/predict/cash-forecast"),
|
||||
("GET", "/api/cma/predict/cash-forecast/history"),
|
||||
("POST", "/api/cma/predict/cvp"),
|
||||
("POST", "/api/cma/predict/cvp-detailed"),
|
||||
("POST", "/api/cma/predict/growth-quality"),
|
||||
("POST", "/api/cma/predict/investment"),
|
||||
("GET", "/api/cma/predict/kpi-forecast"),
|
||||
("GET", "/api/cma/predict/kpi-forecast/finance"),
|
||||
("GET", "/api/cma/predict/kpi-forecast/sensitivity"),
|
||||
("POST", "/api/cma/predict/real-option"),
|
||||
("POST", "/api/cma/predict/relevant-decision"),
|
||||
("POST", "/api/cma/predict/scenario"),
|
||||
("POST", "/api/cma/predict/scenario-suggestion/generate"),
|
||||
("GET", "/api/cma/predict/scenario-suggestions"),
|
||||
("POST", "/api/cma/predict/sensitivity"),
|
||||
("GET", "/api/cma/products/matrix"),
|
||||
("GET", "/api/cma/tenant/current"),
|
||||
("POST", "/api/cma/tenant/switch"),
|
||||
]
|
||||
|
||||
# 种子端点(项目Bot 实测无 token 200 → 收口后必须 401;持 token 必须 200)
|
||||
SEED_NO_TOKEN_401 = [
|
||||
"/api/cma/products/matrix",
|
||||
"/api/cma/cost/dashboard",
|
||||
"/api/cma/cost/overview",
|
||||
"/api/cma/predict/accuracy",
|
||||
]
|
||||
|
||||
|
||||
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 _login(client: TestClient, entity_id: int = 1) -> str:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin", "password": "admin123", "entity_id": entity_id,
|
||||
})
|
||||
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
|
||||
return resp.json().get("token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(db: Session):
|
||||
"""测试用户 + entity1/entity2(授权走登录时的默认授予)"""
|
||||
create_test_user(db)
|
||||
_ensure_entity(db, 1)
|
||||
_ensure_entity(db, 2)
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. B 类:无 token → 401(逐端点)
|
||||
# ============================================================
|
||||
class TestBClassRequiresAuth:
|
||||
@pytest.mark.parametrize("method,path", B_CLASS_ENDPOINTS,
|
||||
ids=[f"{m} {p}" for m, p in B_CLASS_ENDPOINTS])
|
||||
def test_no_token_401(self, client: TestClient, method: str, path: str):
|
||||
resp = client.request(method, path)
|
||||
assert resp.status_code == 401, (
|
||||
f"{method} {path} 无 token 期望 401,实际 {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def test_seed_endpoints_401(self, client: TestClient):
|
||||
"""验收种子项:4 个原「无 token 200」端点收口后 401"""
|
||||
for path in SEED_NO_TOKEN_401:
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 401, f"{path} 期望 401,实际 {resp.status_code}"
|
||||
|
||||
def test_invalid_token_401(self, client: TestClient):
|
||||
"""伪造/失效 token 亦 401(不因鉴权收口而放宽)"""
|
||||
for path in SEED_NO_TOKEN_401:
|
||||
resp = client.get(path, headers=auth_header("deadbeef-not-a-real-token"))
|
||||
assert resp.status_code == 401, f"{path} 期望 401,实际 {resp.status_code}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. B 类:持 token → 200(功能不回归)
|
||||
# ============================================================
|
||||
class TestBClassWithTokenWorks:
|
||||
def test_seed_endpoints_200_with_token(self, client: TestClient, seeded):
|
||||
token = _login(client, 1)
|
||||
for path in SEED_NO_TOKEN_401:
|
||||
resp = client.get(path, headers=auth_header(token))
|
||||
assert resp.status_code == 200, (
|
||||
f"{path} 持 token 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def test_previously_authed_endpoints_still_work(self, client: TestClient, seeded):
|
||||
"""原已带鉴权的端点(alignment/tree、permissions/config PUT 之外)持 token 正常"""
|
||||
token = _login(client, 1)
|
||||
for path in ["/api/cma/alignment/config", "/api/cma/alignment/modes",
|
||||
"/api/cma/permissions/modules", "/api/cma/permissions/config",
|
||||
"/api/cma/notifications/channels", "/api/cma/notifications/logs",
|
||||
"/api/cma/tenant/current", "/api/cma/cost/standard-costs",
|
||||
"/api/cma/cost/abc/activities", "/api/cma/cost/abc/allocations"]:
|
||||
resp = client.get(path, headers=auth_header(token))
|
||||
assert resp.status_code == 200, (
|
||||
f"{path} 持 token 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. 跨实体不回归:token entity=1 + entity_id=2 → 403(文案同 get_entity_id)
|
||||
# ============================================================
|
||||
class TestCrossEntityStillForbidden:
|
||||
def test_cross_entity_403(self, client: TestClient, seeded):
|
||||
token = _login(client, 1)
|
||||
checks = [
|
||||
("/api/cma/products/matrix", {"entity_id": 2}),
|
||||
("/api/cma/cost/dashboard", {"entity_id": 2}),
|
||||
("/api/cma/cost/overview", {"entity_id": 2}),
|
||||
("/api/cma/predict/accuracy", {"entity_id": 2}),
|
||||
]
|
||||
for path, params in checks:
|
||||
resp = client.get(path, params=params, headers=auth_header(token))
|
||||
assert resp.status_code == 403, (
|
||||
f"{path} 跨实体期望 403,实际 {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
assert "无权访问企业" in resp.text
|
||||
|
||||
def test_same_entity_200(self, client: TestClient, seeded):
|
||||
token = _login(client, 1)
|
||||
for path in ["/api/cma/products/matrix", "/api/cma/cost/dashboard"]:
|
||||
resp = client.get(path, params={"entity_id": 1}, headers=auth_header(token))
|
||||
assert resp.status_code == 200, f"{path} 同实体期望 200,实际 {resp.status_code}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 4. A 类 Bot 通道行为不变
|
||||
# ============================================================
|
||||
class TestBotChannelUnchanged:
|
||||
def test_bot_endpoints_with_key_200(self, client: TestClient, seeded):
|
||||
for path in ["/api/cma/bot/kpis", "/api/cma/bot/overview",
|
||||
"/api/cma/bot/cost/actual", "/api/cma/bot/cost/standard",
|
||||
"/api/cma/bot/alerts", "/api/cma/bot/strategic-maps"]:
|
||||
resp = client.get(path, headers={**BOT_KEY, "X-Entity-Id": "1"})
|
||||
assert resp.status_code == 200, (
|
||||
f"{path} 带 X-BOT-KEY 期望 200,实际 {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
||||
def test_bot_endpoints_without_key_401(self, client: TestClient, seeded):
|
||||
for path in ["/api/cma/bot/kpis", "/api/cma/bot/overview"]:
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 401, (
|
||||
f"{path} 无 X-BOT-KEY 期望 401,实际 {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 5. C 类公开端点 & 既有 403 对照
|
||||
# ============================================================
|
||||
class TestPublicAndExistingBaseline:
|
||||
def test_public_endpoints_unchanged(self, client: TestClient, seeded):
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/api/cma/bot/ping").status_code == 200
|
||||
assert client.get("/api/cma/security/captcha/request").status_code == 200
|
||||
assert client.get("/api/cma/auth/login-entities").status_code == 200
|
||||
# 登录端点公开但密码错仍 401
|
||||
r = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin", "password": "wrong", "entity_id": 1})
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_existing_403_endpoints_unchanged(self, client: TestClient, seeded):
|
||||
"""对照:原 403 端点(kpis/maps/org-tree)不回归"""
|
||||
token = _login(client, 1)
|
||||
for path in ["/api/cma/kpis", "/api/cma/maps", "/api/cma/org/tree"]:
|
||||
assert client.get(path).status_code == 403, f"{path} 无 token 期望仍 403"
|
||||
assert client.get(path, headers=auth_header(token)).status_code == 200
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 6. 结构性不变量:全量路由巡检无 B 类缺口
|
||||
# ============================================================
|
||||
class TestAuthSurfaceInvariant:
|
||||
def test_no_gap_in_route_table(self):
|
||||
"""任何新用户端点漏挂 require_auth → 本用例 FAIL(防缺口回归)"""
|
||||
from scripts.audit_api_auth_surface import collect_routes, build_app
|
||||
|
||||
rows = collect_routes(build_app())
|
||||
gaps = [f'{r["methods"]} {r["path"]} ({r["module"]})'
|
||||
for r in rows if r["class"] == "B"]
|
||||
assert gaps == [], "发现无鉴权用户端点(B 类缺口):\n" + "\n".join(gaps)
|
||||
|
||||
def test_bot_and_auth_counts(self):
|
||||
"""A 类 Bot 通道 / C 类公开 / 已鉴权 端点计数快照(防误删鉴权或误加公开)"""
|
||||
from scripts.audit_api_auth_surface import collect_routes, build_app
|
||||
|
||||
rows = collect_routes(build_app())
|
||||
by = {}
|
||||
for r in rows:
|
||||
by[r["class"]] = by.get(r["class"], 0) + 1
|
||||
assert by.get("A", 0) >= 30, f"Bot 通道端点疑似减少: {by}"
|
||||
assert by.get("C", 0) == 9, f"公开端点集被改动(应为 9): {by}"
|
||||
assert by.get("AUTHED", 0) >= 384, f"已鉴权端点疑似减少: {by}"
|
||||
@@ -2,18 +2,53 @@
|
||||
|
||||
覆盖:线性回归预测 / 移动平均预测 / 置信区间 / 趋势方向判定 /
|
||||
边界情况(历史<2条、无历史、未来期数=0)/ entity_id 多租户隔离。
|
||||
|
||||
鉴权(cma-api-auth-surface-audit-20260914):/api/cma/predict/* 已收口为「用户端点必须
|
||||
带会话 Bearer」。`_kpi_forecast_auth` autouse fixture 统一注入账套1的测试 token;
|
||||
多账套隔离用例用 `_token_for_entity` 取对应账套 token 传 headers。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Entity, KPIValue
|
||||
from app.models import Entity, KPIValue, User, UserEntity
|
||||
from app.utils import kpi_forecast_engine as engine
|
||||
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BASE = "/api/cma/predict"
|
||||
|
||||
|
||||
def _token_for_entity(client: TestClient, db: Session, eid: int) -> str:
|
||||
"""为指定账套签发测试 token(自动补实体 + 授权)"""
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
db.add(Entity(id=eid, name=f"企业{eid}", short_name=f"E{eid}", status="active"))
|
||||
db.commit()
|
||||
user = db.query(User).filter(User.username == "testadmin").first()
|
||||
if user is None:
|
||||
user = create_test_user(db)
|
||||
db.commit()
|
||||
rel = db.query(UserEntity).filter(
|
||||
UserEntity.user_id == user.id, UserEntity.entity_id == eid).first()
|
||||
if not rel:
|
||||
db.add(UserEntity(user_id=user.id, entity_id=eid))
|
||||
db.commit()
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin", "password": "admin123", "entity_id": eid})
|
||||
assert resp.status_code == 200, f"登录 entity{eid} 失败: {resp.text[:200]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _kpi_forecast_auth(client: TestClient, db: Session):
|
||||
"""统一注入账套1的测试 token(显式传 headers= 的用例仍可覆盖)"""
|
||||
if db.query(User).filter(User.username == "testadmin").first() is None:
|
||||
create_test_user(db)
|
||||
db.commit()
|
||||
client.headers["Authorization"] = f"Bearer {get_token_for_user(client)}"
|
||||
yield
|
||||
|
||||
|
||||
# ── 测试数据工厂 ──
|
||||
|
||||
def _seed_history(db: Session, kpi, values: list, entity_id: int = 1, start_period: str = "2026-01"):
|
||||
@@ -266,20 +301,18 @@ class TestKpiForecastFinanceApi:
|
||||
|
||||
class TestEntityIsolation:
|
||||
def test_entity2_kpi_invisible_to_entity1(self, client: TestClient, db: Session):
|
||||
"""entity2 的KPI在 entity1 下查询 → 404(不串数据)"""
|
||||
"""entity2 的KPI在 entity1 会话下查询 → 404(不串数据)"""
|
||||
_seed_entity2(db)
|
||||
kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2", kpi_name="博海专属KPI",
|
||||
dimension="finance", entity_id=2)
|
||||
_seed_history(db, kpi2, [100, 110, 120, 130], entity_id=2)
|
||||
# 无token时 entity_id 走 query 参数 → entity1
|
||||
resp = client.get(f"{BASE}/kpi-forecast", params={
|
||||
"entity_id": 1, "kpi_code": "F_ISO_E2",
|
||||
})
|
||||
# 会话账套1:查不到 entity2 的 KPI → 404(鉴权收口后账套由 token 决定)
|
||||
resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"})
|
||||
assert resp.status_code == 404
|
||||
# entity2 自己能查到
|
||||
resp = client.get(f"{BASE}/kpi-forecast", params={
|
||||
"entity_id": 2, "kpi_code": "F_ISO_E2",
|
||||
})
|
||||
# 会话账套2 自己:能查到
|
||||
token2 = _token_for_entity(client, db, 2)
|
||||
resp = client.get(f"{BASE}/kpi-forecast", params={"kpi_code": "F_ISO_E2"},
|
||||
headers=auth_header(token2))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["entity_id"] == 2
|
||||
|
||||
@@ -298,7 +331,6 @@ class TestEntityIsolation:
|
||||
|
||||
def test_token_binds_entity(self, client: TestClient, db: Session):
|
||||
"""token绑定entity1时显式传entity_id=2 → 403 越权拦截"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="F_ISO_TK", kpi_name="TokenKPI")
|
||||
_seed_history(db, kpi, [1, 2, 3, 4])
|
||||
|
||||
@@ -4,17 +4,52 @@
|
||||
cvp / investment / sensitivity / scenario / cvp-detailed / cash-forecast /
|
||||
cash-forecast/history / accuracy / scenario-suggestions /
|
||||
scenario-suggestion/generate / real-option / growth-quality
|
||||
|
||||
鉴权(cma-api-auth-surface-audit-20260914):/api/cma/predict/* 已收口为「用户端点必须
|
||||
带会话 Bearer」。本文件聚焦业务逻辑,`_predict_auth` autouse fixture 统一注入账套1的
|
||||
测试 token 作为 client 默认头;需要其他账套的用例自行用 `_token_for_entity` 传 headers。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import CashForecast, ForecastAccuracy
|
||||
from app.models import CashForecast, ForecastAccuracy, Entity, User, UserEntity
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BASE = "/api/cma/predict"
|
||||
|
||||
|
||||
def _token_for_entity(client: TestClient, db: Session, eid: int) -> str:
|
||||
"""为指定账套签发测试 token(自动补实体 + 授权),供跨账套/自定义实体用例使用"""
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent:
|
||||
db.add(Entity(id=eid, name=f"企业{eid}", short_name=f"E{eid}", status="active"))
|
||||
db.commit()
|
||||
user = db.query(User).filter(User.username == "testadmin").first()
|
||||
if user is None:
|
||||
user = create_test_user(db)
|
||||
db.commit()
|
||||
rel = db.query(UserEntity).filter(
|
||||
UserEntity.user_id == user.id, UserEntity.entity_id == eid).first()
|
||||
if not rel:
|
||||
db.add(UserEntity(user_id=user.id, entity_id=eid))
|
||||
db.commit()
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin", "password": "admin123", "entity_id": eid})
|
||||
assert resp.status_code == 200, f"登录 entity{eid} 失败: {resp.text[:200]}"
|
||||
return resp.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _predict_auth(client: TestClient, db: Session):
|
||||
"""统一注入账套1的测试 token(显式传 headers= 的用例仍可覆盖,httpx 请求级优先)"""
|
||||
if db.query(User).filter(User.username == "testadmin").first() is None:
|
||||
create_test_user(db)
|
||||
db.commit()
|
||||
client.headers["Authorization"] = f"Bearer {get_token_for_user(client)}"
|
||||
yield
|
||||
|
||||
|
||||
class TestCvp:
|
||||
def test_cvp_basic(self, client: TestClient, db: Session):
|
||||
"""本量利分析"""
|
||||
@@ -117,7 +152,6 @@ class TestCashForecast:
|
||||
|
||||
def test_cash_forecast_history_empty(self, client: TestClient, db: Session):
|
||||
"""历史为空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/cash-forecast/history", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
@@ -125,7 +159,6 @@ class TestCashForecast:
|
||||
|
||||
def test_accuracy_empty(self, client: TestClient, db: Session):
|
||||
"""准确率为空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/accuracy", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
@@ -219,20 +252,23 @@ class TestGrowthQuality:
|
||||
assert "overall" in data or "level" in data or "score" in data
|
||||
|
||||
def test_growth_quality_entity2(self, client: TestClient, db: Session):
|
||||
"""博海增长质量"""
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2})
|
||||
"""博海增长质量(需绑定 entity2 的会话 token,鉴权收口后不再靠 body entity_id)"""
|
||||
token2 = _token_for_entity(client, db, 2)
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2},
|
||||
headers=auth_header(token2))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西博海网络科技"
|
||||
|
||||
def test_growth_quality_custom(self, client: TestClient, db: Session):
|
||||
"""自定义企业数据(低分场景)"""
|
||||
token99 = _token_for_entity(client, db, 99)
|
||||
resp = client.post(f"{BASE}/growth-quality", json={
|
||||
"entity_id": 99,
|
||||
"name": "测试企业",
|
||||
"rebateRate": 90, "trueGrossMargin": 5, "cashRatio": 3,
|
||||
"expenseGrowthRate": 3, "revenueGrowthRate": 1, "mgmtRatio": 500,
|
||||
})
|
||||
}, headers=auth_header(token99))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "测试企业"
|
||||
|
||||
@@ -482,7 +482,10 @@ async function loadData() {
|
||||
finSummary.value = (r as any)?.summary || {}
|
||||
dataRange.value = (r as any)?.period || ''
|
||||
} else if (userRole.value === 'it') {
|
||||
const channelRes = await axios.get('/api/cma/notifications/channels').then(r => r.data)
|
||||
// 鉴权收口(cma-api-auth-surface-audit-20260914):/notifications/channels 已要求 Bearer
|
||||
const channelRes = await axios.get('/api/cma/notifications/channels', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('cma_token')}` },
|
||||
}).then(r => r.data)
|
||||
enabledChannels.value = channelRes.data?.filter((c: any) => c.enabled).length || 0
|
||||
}
|
||||
} catch (e) { console.error('加载数据失败:', e) }
|
||||
|
||||
@@ -158,7 +158,7 @@ async function loadMapList() {
|
||||
try {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/maps', {
|
||||
headers: { 'Authorization': `'Bearer ' + token` },
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}).then(r => r.json())
|
||||
mapList.value = r.data || []
|
||||
} catch {}
|
||||
@@ -271,7 +271,11 @@ const devClass = (d: any) => {
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
|
||||
// 鉴权收口(cma-api-auth-surface-audit-20260914):/alignment/* 已要求 Bearer,补 token
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/alignment/config', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}).then(r => r.json())
|
||||
alignModes.value = r.modes || []
|
||||
alignConfigured.value = r.configured || false
|
||||
if (r.configured && r.mode) {
|
||||
|
||||
@@ -231,9 +231,12 @@ async function resetPermConfig() {
|
||||
|
||||
async function loadPermData() {
|
||||
try {
|
||||
// 鉴权收口(cma-api-auth-surface-audit-20260914):/permissions/* 已要求 Bearer,补 token
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const authHeaders = { 'Authorization': `Bearer ${token}` }
|
||||
const [modRes, cfgRes] = await Promise.all([
|
||||
fetch('/api/cma/permissions/modules').then(r => r.json()),
|
||||
fetch('/api/cma/permissions/config').then(r => r.json()),
|
||||
fetch('/api/cma/permissions/modules', { headers: authHeaders }).then(r => r.json()),
|
||||
fetch('/api/cma/permissions/config', { headers: authHeaders }).then(r => r.json()),
|
||||
])
|
||||
modules.value = modRes.modules || []
|
||||
actions.value = modRes.actions || []
|
||||
@@ -272,7 +275,11 @@ function feedsLabel(feeds: string[] | null) {
|
||||
|
||||
async function loadAlignmentConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/cma/alignment/config').then(r => r.json())
|
||||
// 鉴权收口(cma-api-auth-surface-audit-20260914):/alignment/* 已要求 Bearer,补 token
|
||||
const token = localStorage.getItem('cma_token')
|
||||
const r = await fetch('/api/cma/alignment/config', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}).then(r => r.json())
|
||||
alignmentModes.value = r.modes || []
|
||||
alignConfigured.value = r.configured || false
|
||||
currentMode.value = null
|
||||
|
||||
Reference in New Issue
Block a user