- Login.vue: no prefilled default creds; no anonymous entity enumeration on mount - deploy.sh: readiness polling (0.5s/30s cap) replaces fixed sleep 3; login smoke reads same-source 600 secret file, missing file = explicit abort - 19 verify/check/gen_report scripts: shared _admin_creds() loader (CMA_ADMIN_ENV -> ops .secrets -> profile copy); no hardcoded creds - tests: single TEST_USER_PASSWORD fixture constant; docs: pointer to 600 secret file; removed 4 legacy .bak + pycache residue
256 lines
12 KiB
Python
256 lines
12 KiB
Python
"""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, TEST_USER_PASSWORD
|
||
|
||
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": TEST_USER_PASSWORD, "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}"
|