diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 8123edf0..694995f6 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -262,7 +262,8 @@ def update_plan( if data["kr_id"] is None: plan.kr_id = None else: - kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first() + # 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan:158 既有写法;2026-09-18 batch3) + kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first() if not kr_ent: raise HTTPException(404, "关联KR不存在") plan.kr_id = kr_ent.id @@ -299,9 +300,19 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = De @router.get("/stats") -def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)): - """行动计划统计""" - query = db.query(ActionPlan) +def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth), + entity_id: int = Depends(get_entity_id)): + """行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3) + + 口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套 + 用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套) + 的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤, + 保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的 + 存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。 + """ + query = (db.query(ActionPlan) + .join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id) + .filter(KPIDefinition.entity_id == entity_id)) if current_user.role == "business": query = query.filter( (ActionPlan.assignee == current_user.username) | diff --git a/backend/app/api/cost.py b/backend/app/api/cost.py index 4acbc63d..960bfc76 100644 --- a/backend/app/api/cost.py +++ b/backend/app/api/cost.py @@ -180,6 +180,12 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db), # GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心; # 现按会话账套过滤(AbcActivity.entity_id)。 # OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。 +# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口 +# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入 +# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1; +# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例) +# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation +# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族) # ============================================================ @router.get("/abc/activities") @@ -193,9 +199,11 @@ def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends( @router.post("/abc/activities") -def create_abc_activity(data: dict, db: Session = Depends(get_db)): - """创建ABC作业中心""" +def create_abc_activity(data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): + """创建ABC作业中心(账套隔离:按会话账套写入归属)""" act = AbcActivity( + entity_id=entity_id, activity_code=data["activity_code"], activity_name=data["activity_name"], activity_desc=data.get("activity_desc"), @@ -211,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)): @router.post("/abc/allocate") -def do_allocate(data: dict, db: Session = Depends(get_db)): - """执行ABC成本分配""" +def do_allocate(data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): + """执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)""" + act = (db.query(AbcActivity) + .filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id) + .first()) + if not act: + raise HTTPException(404, "作业中心不存在") result = allocate_cost( activity_id=data["activity_id"], period=data.get("period", datetime.now().strftime("%Y-%m")), product_code=data["product_code"], product_name=data.get("product_name", ""), driver_consumed=data["driver_consumed"], + entity_id=entity_id, ) return result @@ -226,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)): @router.get("/abc/allocations") def list_allocations(period: Optional[str] = Query(None), product_code: Optional[str] = Query(None), - db: Session = Depends(get_db)): - """查询ABC分配记录""" - query = db.query(AbcAllocation) + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id)): + """查询ABC分配记录(账套隔离:只返回本账套)""" + query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id) if period: query = query.filter(AbcAllocation.period == period) if product_code: diff --git a/backend/app/utils/cost_engine.py b/backend/app/utils/cost_engine.py index 0fece6e5..056f414f 100644 --- a/backend/app/utils/cost_engine.py +++ b/backend/app/utils/cost_engine.py @@ -158,11 +158,21 @@ def calc_driver_rate(activity_id: int) -> dict: def allocate_cost(activity_id: int, period: str, product_code: str, - product_name: str, driver_consumed: float) -> dict: - """按动因分配成本到产品""" + product_name: str, driver_consumed: float, + entity_id: Optional[int] = None) -> dict: + """按动因分配成本到产品 + + 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3): + - entity_id 非 None 时,作业中心查询按本账套过滤(跨账套 activity_id 查不到 → 返回错误字典, + 端点侧已先行 404); + - 新建 AbcAllocation 显式写入 entity_id(不传则维持原语义:不写归属,落模型默认)。 + """ db = get_session_local()() try: - act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first() + q_act = db.query(AbcActivity).filter(AbcActivity.id == activity_id) + if entity_id is not None: + q_act = q_act.filter(AbcActivity.entity_id == entity_id) + act = q_act.first() if not act or act.driver_rate == 0: # 自动计算分配率 if act and act.driver_volume > 0: @@ -171,7 +181,7 @@ def allocate_cost(activity_id: int, period: str, product_code: str, if not act or act.driver_rate == 0: return {"error": "分配率未设置"} allocated = round(driver_consumed * act.driver_rate, 2) - alloc = AbcAllocation( + alloc_kwargs = dict( period=period, activity_id=activity_id, product_code=product_code, @@ -179,6 +189,9 @@ def allocate_cost(activity_id: int, period: str, product_code: str, driver_consumed=driver_consumed, allocated_cost=allocated, ) + if entity_id is not None: + alloc_kwargs["entity_id"] = entity_id + alloc = AbcAllocation(**alloc_kwargs) db.add(alloc) db.commit() return { diff --git a/backend/tests/test_cross_entity_isolation_batch3_20260918.py b/backend/tests/test_cross_entity_isolation_batch3_20260918.py new file mode 100644 index 00000000..f2390f52 --- /dev/null +++ b/backend/tests/test_cross_entity_isolation_batch3_20260918.py @@ -0,0 +1,412 @@ +"""CMA 跨实体隔离收口(第三批)账套隔离回归测试 — 2026-09-18 + +任务:project-20260918-entity-isolation-batch3(项目Bot 派单) +缺陷(本单范围,均与 batch2 同类残留): + +A. 写型未接账套 + 1. POST /api/cma/cost/abc/activities 函数签名无 get_entity_id 依赖;AbcActivity(...) 不写归属 + → 落模型默认 entity_id=1(非 e1 账套新建的作业中心落进 e1) + 2. POST /api/cma/cost/abc/allocate 无账套依赖;cost_engine.allocate_cost() 内 AbcAllocation(...) + 不写 entity_id → 分配记录全落 e1;activity_id 也不校验归属 + +B. 读型未接账套 + 3. GET /api/cma/cost/abc/allocations 查询只按 period/product_code 过滤 → 跨账套读泄漏 + (与 batch2 已修的 GET /abc/activities 同族) + 4. GET /api/cma/action-plans/stats 统计 db.query(ActionPlan) 无账套过滤 + → 非 e1 账套用户统计数含其它账套计划(同文件既有先例=join KPIDefinition) + +C. 归属校验缺失(写型) + 5. PUT /api/cma/action-plans/{plan_id} plan_id 已按账套校验(batch2),但 kr_id 分支 + db.query(KR).filter(KR.id == data["kr_id"]) 无账套校验 + → 可把本账套计划挂到对端账套的 KR(同文件 create_plan:158 有先例) + +本用例覆盖(每条均双向 + 同账套正对照;改前必失败条目见回报 §三 RED 原文): +- 写型:会话账套新建 ABC 作业中心 → 库内该行 entity_id = 会话账套;对端账套看不到; +- 写型:本账套 activity_id 分配 → 200 且新建 abc_allocations 行 entity_id = 会话账套; + 对端 activity_id → 404 且不产生任何分配行; +- 读型:GET /abc/allocations 只返回本账套行; +- 归属:PUT /{plan_id} 传对端 kr_id → 404 且对端 KR/本账套计划均未被改动;同账套改挂 → 200; +- 统计:GET /action-plans/stats 只统计本账套计划; +- 鉴权底线:无 token → 401(改前改后都应通过)。 +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import ( + AbcActivity, + AbcAllocation, + ActionPlan, + Entity, + KR, + KPIDefinition, + Objective, +) +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +COST = "/api/cma/cost" +PLANS = "/api/cma/action-plans" + +# 账套 1/2 各自的资源编码前缀(activity_code 库内唯一) +E1_KPI = "ZZ_B3_E1_KPI_001" +E2_KPI = "ZZ_B3_E2_KPI_001" +E1_ACT = "ZZ_B3_E1_ACT_001" +E2_ACT = "ZZ_B3_E2_ACT_001" + + +# ────────────────────────────────────────────── +# 夹具 +# ────────────────────────────────────────────── + +def _ensure_entity(db: Session, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition: + kpi = KPIDefinition( + entity_id=eid, + kpi_code=code, + kpi_name=f"{code}-名称", + dimension="finance", + category="revenue_growth", + formula="-", + data_source_type="excel", + data_source="Excel导入", + data_owner="财务部", + frequency="monthly", + unit="元", + target_value=0, + kpi_level="operational", + status="active", + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _login(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, f"登录 entity{entity_id} 失败: {resp.text[:300]}" + return resp.json()["token"] + + +def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity: + """直插作业中心(显式 entity_id,用于构造「对端账套既有数据」)""" + act = AbcActivity( + entity_id=eid, activity_code=code, activity_name=f"{code}-作业", + cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10, + driver_rate=100.0, status="active", + ) + db.add(act) + db.commit() + db.refresh(act) + return act + + +def _mk_allocation(db: Session, activity: AbcActivity, eid: int, product_code: str) -> AbcAllocation: + alloc = AbcAllocation( + entity_id=eid, period="2026-06", activity_id=activity.id, + product_code=product_code, product_name=f"{product_code}-产品", + driver_consumed=3, allocated_cost=300.0, + ) + db.add(alloc) + db.commit() + db.refresh(alloc) + return alloc + + +def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan: + plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title, + assignee=kw.get("assignee", "张三"), priority="medium", + status=kw.get("status", "pending"), progress=0) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def _mk_objective_kr(db: Session, eid: int, title: str) -> KR: + obj = Objective(entity_id=eid, title=f"{title}-目标", quarter="2026Q3", status="active") + db.add(obj) + db.commit() + db.refresh(obj) + kr = KR(entity_id=eid, objective_id=obj.id, title=title, operator=">=", + weight=50, target_value=100) + db.add(kr) + db.commit() + db.refresh(kr) + return kr + + +def _seed_two_entities(db: Session): + _ensure_entity(db, 1, "酣客") + _ensure_entity(db, 2, "博海") + create_test_user(db) + k1 = _mk_kpi(db, E1_KPI, 1) + k2 = _mk_kpi(db, E2_KPI, 2) + return k1, k2 + + +# ────────────────────────────────────────────── +# A1. 写型:POST /cost/abc/activities 按会话账套写入 +# ────────────────────────────────────────────── + +def test_create_abc_activity_writes_session_entity(client: TestClient, db: Session): + """博海(e2)会话新建作业中心 → 库内该行 entity_id=2;酣客(e1)会话看不到它""" + _seed_two_entities(db) + + token2 = _login(client, 2) + r = client.post(f"{COST}/abc/activities", headers=auth_header(token2), json={ + "activity_code": E2_ACT, + "activity_name": "e2 新建作业", + "cost_driver": "动因", + "driver_unit": "次", + "total_cost": 2000.0, + "driver_volume": 20, + }) + assert r.status_code == 200, r.text + new_id = r.json()["id"] + + db.expire_all() + row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first() + assert row is not None, "作业中心未落库" + assert row.entity_id == 2, ( + f"会话账套=2 新建的作业中心落库归属 entity_id={row.entity_id}(缺陷:未写归属→模型默认 1)" + ) + + # e1 会话看不到 e2 的作业中心;e2 会话能看到 + codes1 = _abc_codes(client, _login(client, 1)) + codes2 = _abc_codes(client, _login(client, 2)) + assert E2_ACT not in codes1, f"酣客(e1)会话看到了博海(e2)新建的作业中心: {sorted(codes1)}" + assert E2_ACT in codes2, f"博海(e2)会话看不到自己新建的作业中心: {sorted(codes2)}" + + +def test_create_abc_activity_e1_still_defaults_to_own_entity(client: TestClient, db: Session): + """酣客(e1)会话新建 → entity_id=1 且对端(e2)看不到(同账套正对照,防「一律写死某账套」)""" + _seed_two_entities(db) + + token1 = _login(client, 1) + r = client.post(f"{COST}/abc/activities", headers=auth_header(token1), json={ + "activity_code": E1_ACT, + "activity_name": "e1 新建作业", + "cost_driver": "动因", + "driver_unit": "次", + "total_cost": 1000.0, + "driver_volume": 10, + }) + assert r.status_code == 200, r.text + new_id = r.json()["id"] + + db.expire_all() + row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first() + assert row.entity_id == 1, f"会话账套=1 新建行归属 {row.entity_id}" + + codes2 = _abc_codes(client, _login(client, 2)) + assert E1_ACT not in codes2, f"博海(e2)会话看到了酣客(e1)作业中心: {sorted(codes2)}" + + +def _abc_codes(client: TestClient, token: str) -> set: + r = client.get(f"{COST}/abc/activities", headers=auth_header(token)) + assert r.status_code == 200, r.text + return {row["activity_code"] for row in r.json()["data"]} + + +# ────────────────────────────────────────────── +# A2. 写型:POST /cost/abc/allocate 按会话账套 + activity_id 归属校验 +# ────────────────────────────────────────────── + +def test_allocate_cross_entity_activity_rejected(client: TestClient, db: Session): + """酣客(e1)会话用博海(e2)的 activity_id 分配 → 404,且不产生任何分配行""" + _seed_two_entities(db) + other = _mk_activity(db, E2_ACT, 2) + token1 = _login(client, 1) + + r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={ + "activity_id": other.id, + "period": "2026-06", + "product_code": "ZZ_B3_E1_PROD", + "product_name": "跨账套产品", + "driver_consumed": 5, + }) + assert r.status_code == 404, ( + f"跨账套 allocate 应 404,实际 {r.status_code} {r.text[:200]}" + ) + + db.expire_all() + assert db.query(AbcAllocation).count() == 0, "跨账套 allocate 竟写入了分配记录" + # 对端作业中心未被改动(分配率计算会回写 driver_rate) + db.refresh(other) + assert other.driver_rate == 100.0, "跨账套 allocate 竟改动了对端作业中心" + + +def test_allocate_writes_session_entity(client: TestClient, db: Session): + """双向:e1/e2 各自用本账套 activity_id 分配 → 200 且新建行 entity_id = 会话账套""" + _seed_two_entities(db) + act1 = _mk_activity(db, E1_ACT, 1) + act2 = _mk_activity(db, E2_ACT, 2) + + # e2 会话分配本账套作业中心 + token2 = _login(client, 2) + r = client.post(f"{COST}/abc/allocate", headers=auth_header(token2), json={ + "activity_id": act2.id, + "period": "2026-06", + "product_code": "ZZ_B3_E2_PROD", + "product_name": "e2 产品", + "driver_consumed": 4, + }) + assert r.status_code == 200, f"同账套 allocate 应 200,实际 {r.status_code} {r.text[:200]}" + assert r.json()["allocated_cost"] == 400.0 # 4 × 100.0 + + db.expire_all() + row2 = (db.query(AbcAllocation) + .filter(AbcAllocation.product_code == "ZZ_B3_E2_PROD").first()) + assert row2 is not None, "e2 会话分配未落库" + assert row2.entity_id == 2, ( + f"e2 会话分配行归属 entity_id={row2.entity_id}(缺陷:AbcAllocation 未写归属→默认 1)" + ) + + # e1 会话分配本账套作业中心(反向对照) + token1 = _login(client, 1) + r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={ + "activity_id": act1.id, + "period": "2026-06", + "product_code": "ZZ_B3_E1_PROD", + "product_name": "e1 产品", + "driver_consumed": 2, + }) + assert r.status_code == 200, r.text + + db.expire_all() + row1 = (db.query(AbcAllocation) + .filter(AbcAllocation.product_code == "ZZ_B3_E1_PROD").first()) + assert row1 is not None and row1.entity_id == 1, f"e1 分配行归属异常: {row1.entity_id if row1 else None}" + + +# ────────────────────────────────────────────── +# B3. 读型:GET /cost/abc/allocations 按会话账套 +# ────────────────────────────────────────────── + +def _alloc_products(client: TestClient, token: str) -> set: + r = client.get(f"{COST}/abc/allocations", headers=auth_header(token)) + assert r.status_code == 200, r.text + return {row["product_code"] for row in r.json()["data"]} + + +def test_allocations_list_isolated_by_entity(client: TestClient, db: Session): + """酣客(e1)会话只返本账套分配行,不含博海(e2)行;e2 会话同理(双向)""" + _seed_two_entities(db) + act1 = _mk_activity(db, E1_ACT, 1) + act2 = _mk_activity(db, E2_ACT, 2) + _mk_allocation(db, act1, 1, "ZZ_B3_E1_ALLOC") + _mk_allocation(db, act2, 2, "ZZ_B3_E2_ALLOC") + + prods1 = _alloc_products(client, _login(client, 1)) + prods2 = _alloc_products(client, _login(client, 2)) + + assert prods1 == {"ZZ_B3_E1_ALLOC"}, f"e1 会话分配记录集应为本账套,实际 {sorted(prods1)}" + assert prods2 == {"ZZ_B3_E2_ALLOC"}, f"e2 会话分配记录集应为本账套,实际 {sorted(prods2)}" + + +# ────────────────────────────────────────────── +# C5. PUT /action-plans/{plan_id} 的 kr_id 归属校验 +# ────────────────────────────────────────────── + +def test_update_plan_kr_id_cross_entity_404(client: TestClient, db: Session): + """酣客(e1)计划改挂博海(e2)KR → 404,且计划 kr_id 未变、对端 KR 未被改动""" + k1, k2 = _seed_two_entities(db) + mine = _mk_plan(db, k1.id, 1, "e1 计划") + kr2 = _mk_objective_kr(db, 2, "e2 KR") + token1 = _login(client, 1) + + r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1), + json={"kr_id": kr2.id}) + assert r.status_code == 404, ( + f"跨账套 kr_id 应 404,实际 {r.status_code} {r.text[:200]}" + ) + + db.expire_all() + after = db.query(ActionPlan).filter(ActionPlan.id == mine.id).first() + assert after.kr_id is None, f"跨账套 kr_id 竟被写入: kr_id={after.kr_id}" + assert after.objective_id is None, f"跨账套改挂竟改动了 objective_id={after.objective_id}" + + db.refresh(kr2) + assert kr2.entity_id == 2 and kr2.title == "e2 KR", "对端 KR 被改动" + + +def test_update_plan_kr_id_same_entity_200(client: TestClient, db: Session): + """同账套正对照:酣客(e1)计划改挂本账套 KR → 200 且 kr_id/objective_id 生效""" + k1, _ = _seed_two_entities(db) + mine = _mk_plan(db, k1.id, 1, "e1 计划") + kr1 = _mk_objective_kr(db, 1, "e1 KR") + token1 = _login(client, 1) + + r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1), + json={"kr_id": kr1.id}) + assert r.status_code == 200, f"同账套 kr_id 应 200,实际 {r.status_code} {r.text[:200]}" + body = r.json() + assert body["kr_id"] == kr1.id + assert body["objective_id"] == kr1.objective_id + + +# ────────────────────────────────────────────── +# B4. GET /action-plans/stats 按会话账套 +# ────────────────────────────────────────────── + +def _stats(client: TestClient, token: str) -> dict: + r = client.get(f"{PLANS}/stats", headers=auth_header(token)) + assert r.status_code == 200, r.text + return r.json() + + +def test_plan_stats_isolated_by_entity(client: TestClient, db: Session): + """酣客(e1)会话统计只含本账套计划(e2 造 2 条做对照)""" + k1, k2 = _seed_two_entities(db) + _mk_plan(db, k1.id, 1, "e1 待开始") + _mk_plan(db, k1.id, 1, "e1 已完成", status="completed") + _mk_plan(db, k2.id, 2, "e2 待开始") + _mk_plan(db, k2.id, 2, "e2 进行中", status="in_progress") + + s1 = _stats(client, _login(client, 1)) + assert s1["total"] == 2, f"e1 统计应只含本账套 2 条,实际 {s1}" + assert s1["pending"] == 1 and s1["completed"] == 1 and s1["in_progress"] == 0 + + +def test_plan_stats_reverse_direction(client: TestClient, db: Session): + """博海(e2)会话统计只含本账套计划(防止「一律统计 e1」或全局口径)""" + k1, k2 = _seed_two_entities(db) + _mk_plan(db, k1.id, 1, "e1 待开始") + _mk_plan(db, k1.id, 1, "e1 待开始2") + _mk_plan(db, k2.id, 2, "e2 待开始") + + s2 = _stats(client, _login(client, 2)) + assert s2["total"] == 1, f"e2 统计应只含本账套 1 条,实际 {s2}" + assert s2["pending"] == 1 + + +# ────────────────────────────────────────────── +# 鉴权底线(改前改后都应通过) +# ────────────────────────────────────────────── + +def test_batch3_endpoints_without_token_401(client: TestClient, db: Session): + """无 Bearer token → 401(四个端点鉴权底线不变)""" + _seed_two_entities(db) + assert client.post(f"{COST}/abc/activities", json={ + "activity_code": "ZZ_B3_NOTOKEN", "activity_name": "x", + "cost_driver": "d", "total_cost": 1, "driver_volume": 1, + }).status_code == 401 + assert client.post(f"{COST}/abc/allocate", json={ + "activity_id": 1, "product_code": "p", "driver_consumed": 1, + }).status_code == 401 + assert client.get(f"{COST}/abc/allocations").status_code == 401 + assert client.get(f"{PLANS}/stats").status_code == 401