Files
cma-management/backend/tests/test_map_versions.py
T
Hermes CI Fix 40d522cd34 fix(security): retire default-credential surface (yanxue-20260914-cma-defaultcred-code-fix)
- 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
2026-09-14 17:39:53 +08:00

219 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
战略地图版本管理完整实现测试(project-20260908-map-versions-page)
覆盖: 列表摘要/详情全量/回滚自动备份/备注编辑/跨entity 404/不存在404
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, User, UserEntity, StrategicMap, StrategicMapVersion
from tests.conftest import create_test_user, get_token_for_user, auth_header, TEST_USER_PASSWORD
def _create_second_entity(db: Session, user: User) -> int:
db.add(Entity(id=2, name="第二企业", short_name="企业2", status="active"))
db.add(UserEntity(user_id=user.id, entity_id=2, granted_by=1))
db.commit()
return 2
def _token_for_entity(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": TEST_USER_PASSWORD, "entity_id": entity_id,
})
assert resp.status_code == 200
data = resp.json()
return data.get("token") or data.get("access_token")
def _create_published_map(client: TestClient, token: str, title: str) -> int:
"""建模板地图 → 发布 → 返回 map_id(自动产生 v1.0 快照)"""
create_resp = client.post(
"/api/cma/maps/create-with-template",
headers=auth_header(token),
json={"title": title},
)
assert create_resp.status_code == 200
map_id = create_resp.json()["id"]
pub_resp = client.put(
f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"status": "published"},
)
assert pub_resp.status_code == 200
return map_id
class TestMapVersions:
"""战略地图版本 endpoints"""
def test_list_versions_empty_then_publish_has_summary(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
# 未发布空列表
m = client.post("/api/cma/maps/create-with-template", headers=auth_header(token), json={"title": "版本测试A"})
map_id = m.json()["id"]
empty = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token))
assert empty.status_code == 200
assert empty.json()["data"] == []
# 发布后 v1.0 快照(列表带摘要字段)
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token), json={"status": "published"})
ver = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token))
assert ver.status_code == 200
rows = ver.json()["data"]
assert len(rows) == 1
r = rows[0]
assert r["version"] == "v1.0"
assert r["is_current"] is True
assert r["dimension_count"] == 4
assert r["objective_count"] > 0
assert isinstance(r["kpi_count"], int)
assert r["comment"] == "发布 v1.0"
def test_get_version_detail_has_full_content(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "版本详情B")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
detail = client.get(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token))
assert detail.status_code == 200
d = detail.json()
assert isinstance(d["dimensions"], list) and len(d["dimensions"]) == 4
assert isinstance(d["canvas_data"], dict)
assert d["version"] == "v1.0"
def test_rollback_backs_up_current_and_restores_content(self, client, db):
"""核心安全: 回滚前自动备份当前内容为新版本; 回滚后内容=目标版本"""
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "回滚安全C")
# 发布后快照 v1.0(内容=模板初始)
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
v1_id = versions[0]["id"]
v1_content = client.get(f"/api/cma/maps/{map_id}/versions/{v1_id}", headers=auth_header(token)).json()
# 修改地图内容为全新结构(published 状态下保存,不触发自动快照)
new_dims = [{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
"objectives": [{"name": "回滚前最新目标", "icon": "target",
"kpis": ["F_REVENUE"], "krs": [
{"name": "KR新", "kpi_code": "F_REVENUE", "weight": 100, "target_value": "100"}
]}]}]
upd = client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": new_dims, "canvas_data": {"connections": [{"from": "a", "to": "b"}]}})
assert upd.status_code == 200
# 回滚到 v1.0 → 应自动备份当前(v1.0→v1.1) 且内容还原为 v1.0
rb = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token))
assert rb.status_code == 200
data = rb.json()
assert data["version"] == "v1.0"
assert data["backup_version"] == "v1.1"
# 列表: 备份(v1.1 最新) + v1.0
after = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
assert len(after) == 2
assert {x["version"] for x in after} == {"v1.0", "v1.1"}
backup = next(x for x in after if x["version"] == "v1.1")
assert backup["comment"] == "回滚前自动备份"
# 当前地图内容 == v1.0 快照内容(语义还原;列表读路径会经 map_objectives 合并补 description/icon 默认键,按业务字段归一化比较)
maps = client.get("/api/cma/maps", headers=auth_header(token)).json()["data"]
cur = next(x for x in maps if x["id"] == map_id)
assert cur["version"] == "v1.0"
def norm(dims):
return [
{
"key": d.get("key"),
"objectives": [
{k: o.get(k) for k in ("name", "kpis", "krs")}
for o in d.get("objectives", [])
],
}
for d in dims
]
assert norm(cur["dimensions"]) == norm(v1_content["dimensions"])
assert cur["canvas_data"] == v1_content["canvas_data"]
def test_rollback_again_uses_next_free_backup_version(self, client, db):
"""连续两次回滚同一目标: 备份版本号不撞号(v1.1 → v1.2)"""
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "回滚两次D")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
v1_id = versions[0]["id"]
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": [{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
"objectives": []}]})
rb1 = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token)).json()
assert rb1["backup_version"] == "v1.1"
# 再次改内容再回滚 → 备份 v1.2(不撞 v1.1)
client.put(f"/api/cma/maps/{map_id}", headers=auth_header(token),
json={"dimensions": [{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
"objectives": []}]})
rb2 = client.post(f"/api/cma/maps/{map_id}/versions/{v1_id}/rollback", headers=auth_header(token)).json()
assert rb2["backup_version"] == "v1.2"
def test_update_comment(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "备注编辑E")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
resp = client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token),
json={"comment": "2026H2 定稿版"})
assert resp.status_code == 200
assert resp.json()["comment"] == "2026H2 定稿版"
rows = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
assert rows[0]["comment"] == "2026H2 定稿版"
def test_update_comment_requires_field(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "备注必填F")
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token)).json()["data"]
vid = versions[0]["id"]
resp = client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token), json={})
assert resp.status_code == 400
def test_cross_entity_all_versions_endpoints_404(self, client, db):
"""跨账套: 列表/详情/备注/回滚 全部 404(地图归属+版本 entity 双重隔离)"""
create_test_user(db)
token1 = get_token_for_user(client)
map_id = _create_published_map(client, token1, "隔离G")
user = db.query(User).filter(User.username == "testadmin").first()
_create_second_entity(db, user)
token2 = _token_for_entity(client, 2)
versions = client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token1)).json()["data"]
vid = versions[0]["id"]
assert client.get(f"/api/cma/maps/{map_id}/versions", headers=auth_header(token2)).status_code == 404
assert client.get(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token2)).status_code == 404
assert client.put(f"/api/cma/maps/{map_id}/versions/{vid}", headers=auth_header(token2),
json={"comment": "越权"}).status_code == 404
assert client.post(f"/api/cma/maps/{map_id}/versions/{vid}/rollback", headers=auth_header(token2)).status_code == 404
def test_rollback_unknown_version_404(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
map_id = _create_published_map(client, token, "不存在H")
resp = client.post(f"/api/cma/maps/{map_id}/versions/99999/rollback", headers=auth_header(token))
assert resp.status_code == 404
def test_rollback_other_maps_version_404(self, client, db):
"""vid 属于另一张地图 → 404(map_id + vid 双条件匹配)"""
create_test_user(db)
token = get_token_for_user(client)
map_a = _create_published_map(client, token, "地图A-I")
map_b = _create_published_map(client, token, "地图B-I")
va = client.get(f"/api/cma/maps/{map_a}/versions", headers=auth_header(token)).json()["data"][0]
resp = client.post(f"/api/cma/maps/{map_b}/versions/{va['id']}/rollback", headers=auth_header(token))
assert resp.status_code == 404