test: 第二轮深度审计(越权隔离/引用完整性/公开面/持久化 16项, 修复4处缺陷)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-26 08:00] TEST+FIX: v2.34 第二轮深度审计——越权/引用完整性/公开面/持久化 16/16 PASS。新增 scripts/audit_deep.py(直插双公司数据验证 company_id 隔离/跨公司按 ID 直访/登记收款越权/公开接口范围/重启后数据存活)。复查修复 4 处真实缺陷: ①删除有下游单据的客户/合同无校验(会产生孤儿合同/订单, 收款统计 join 缺口)→ 引用完整性拒绝(客户: 合同/量尺/跟进; 合同: 订单/有效收款) ②Service 层误用 self.db(实际会话在 self.repo.db)→AttributeError 500 ③删除路由未捕获 ValueError(保护性失败变 500) ④已完工订单可删(生产留档要求禁删); 公开分享页无头验证渲染正确(标题/明细/合计, 0 JS 异常); 冒烟断言防残留加固(跟进列表 >=1); 深度审计+冒烟 52+16 项全 PASS (Files: src/huamenchuang/services/customer_service.py, src/huamenchuang/services/contract_service.py, src/huamenchuang/services/order_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/orders.py, src/huamenchuang/scripts/audit_deep.py, src/huamenchuang/scripts/smoke_all.py)
|
||||
|
||||
- [2026-09-25 23:30] TEST+FIX: v2.33 26 项功能落地性复查——综合冒烟 52/52 PASS + 23 页巡检 0 异常。新增 scripts/smoke_all.py(26 项逐项接口冒烟: 每项验证创建/查询/联动/校验拦截全链路)+frontend/scripts/verify-pages.mjs(23 页无头巡检收集 JS 异常)。复查发现并修复 6 处: ①order_format 校验缺 barcode 类型(条码块模板无法保存) ②workshop/scan 缺 selectinload(MissingGreenlet 500) ③pickout 仅匹配单库存项(改跨批次累计扣减) ④照片模式洞口位置未传 AI(rect 相对坐标→英文方位描述进提示词, 引导装窗位置; 附 mime 嗅探修正 jpeg/png) ⑤打印条码 svg ref 脱离 DOM 残留(isConnected 过滤) ⑥AI 推荐只打分前 80 套(改全量 254 防分类偏倚); 冒烟残留数据已清理 (Files: src/huamenchuang/api/ai_render.py, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/order_format.py, src/huamenchuang/api/workshop.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/recommend.py, frontend/src/views/AIRenderView.vue, frontend/src/views/PrinterView.vue, src/huamenchuang/scripts/smoke_all.py, frontend/scripts/verify-pages.mjs)
|
||||
|
||||
- [2026-09-25 22:40] FEAT: v2.32 P1 批次4 CRM+仓库域 5 项。①客户跟进: CustomerFollow 模型+/customers/{id}/follows CRUD+客户页跟进对话框(timeline 历史+新增/下次日期); ②业务员体系: Salesman 模型+/api/salesmen CRUD+statistics 业绩(名下客户/合同额/预估提成)+客户页业务员 Tab+客户表单业务员选择; ③余料管理: surplus-in(读合同方案按最小长度入库 note=surplus)+surplus 列表+下料方案页余料入库按钮+仓库页余料筛选; ④领料: pickout(按领料清单扣库存+流水, 缺料报缺口)+下料方案页领料出库按钮; ⑤采购单: PurchaseOrder 模型(CG+日期+流水)+POST 自动读缺料清单/手动明细+receive 入库联动(库存+StockMove)+仓库页采购单 Tab(状态/入库); 实测: 跟进/业绩/余料 2 根入库/领料 5 根出库/采购 CG001 入库联动全通; 254 套全量校验 0 失败; vue-tsc 0 错+构建通过 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/salesmen.py, src/huamenchuang/api/purchases.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/router.py, frontend/src/views/CustomersView.vue, frontend/src/views/CutSolutionView.vue, frontend/src/views/WarehouseView.vue, doc/API.md)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -122,9 +122,11 @@ async def convert_contract(body: ConvertBody, user: CurrentUser, db: DbSession):
|
||||
|
||||
@router.delete("/{contract_id}")
|
||||
async def delete_contract(contract_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除合同。"""
|
||||
"""删除合同(有订单/有效收款时拒绝)。"""
|
||||
try:
|
||||
await ContractService(db).delete(user, contract_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
|
||||
@@ -62,11 +62,13 @@ async def update_customer(customer_id: int, body: CustomerBody, user: CurrentUse
|
||||
|
||||
@router.delete("/{customer_id}")
|
||||
async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除客户。"""
|
||||
"""删除客户(有合同/量尺/跟进时拒绝)。"""
|
||||
try:
|
||||
await CustomerService(db).delete(user, customer_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
|
||||
|
||||
|
||||
@@ -133,11 +133,13 @@ async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db:
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_order(order_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除订单(软动作:记日志后物理删除)。"""
|
||||
"""删除订单(已完工订单拒绝)。"""
|
||||
try:
|
||||
await OrderService(db).delete_order(user, order_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""深度审计:越权隔离 / 引用完整性 / 公开接口范围 / 持久化(输出 PASS/FAIL)。"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://localhost:8000"
|
||||
results = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
results.append((name, "PASS" if cond else f"FAIL {detail}"))
|
||||
|
||||
|
||||
async def main():
|
||||
# ---- 造数:公司2 用户 + 数据(直插,绕过注册默认 company_id=1)----
|
||||
from huamenchuang.core.database import SessionLocal, init_db
|
||||
await init_db()
|
||||
from huamenchuang.services.auth_service import hash_password
|
||||
from huamenchuang.models.contract import Contract, Customer
|
||||
from huamenchuang.models.user import User
|
||||
async with SessionLocal() as db:
|
||||
u2 = User(phone_number="15900000002", password_hash=hash_password("abc123"),
|
||||
nickname="审计B公司", company_id=2)
|
||||
c2 = Customer(name="B公司专属客户", phone="15900000002", company_id=2)
|
||||
db.add_all([u2, c2])
|
||||
await db.flush()
|
||||
ct2 = Contract(contract_no="FGAUDIT0001", customer_id=c2.id, customer_name="B公司专属客户",
|
||||
money=777, company_id=2)
|
||||
db.add(ct2)
|
||||
await db.commit()
|
||||
u2_id, c2_id, ct2_id = u2.id, c2.id, ct2.id
|
||||
|
||||
# 登录两公司
|
||||
r = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "13800000000", "password": "123456"}, timeout=15)
|
||||
A = {"Authorization": "Bearer " + r.json()["data"]["token"]}
|
||||
r = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "15900000002", "password": "abc123"}, timeout=15)
|
||||
check("越权前置: B 公司用户登录", r.json().get("code") == 1)
|
||||
B = {"Authorization": "Bearer " + r.json()["data"]["token"]}
|
||||
|
||||
def call(h, method, path, body=None):
|
||||
rr = httpx.request(method, f"{BASE}{path}", headers=h, json=body, timeout=30)
|
||||
try:
|
||||
return rr.json()
|
||||
except Exception:
|
||||
return {"code": -1}
|
||||
|
||||
# ---- 1 越权隔离:B 只能看到自己的 ----
|
||||
d = call(B, "GET", "/api/customers?page=1&page_size=50")
|
||||
names = [x["name"] for x in d["data"]["data"]]
|
||||
check("隔离-客户列表不含A公司数据", all("测试客户" not in n and "跟进" not in n for n in names) and "B公司专属客户" in names)
|
||||
d = call(B, "GET", "/api/contracts?page=1&page_size=50")
|
||||
nos = [x["contract_no"] for x in d["data"]["contracts"]["data"]]
|
||||
check("隔离-合同列表仅本公司", all(n.startswith("FGAUDIT") for n in nos) and nos, f"got {nos[:3]}")
|
||||
d = call(B, "POST", "/api/orders/search", {"page": 1, "page_size": 10})
|
||||
check("隔离-订单列表为空", d["code"] == 1 and d["data"]["total"] == 0)
|
||||
d = call(B, "GET", "/api/scripts")
|
||||
check("隔离-价格脚本为空", d["code"] == 1 and d["data"] == [])
|
||||
d = call(B, "GET", f"/api/customers/{c2_id}/follows")
|
||||
check("隔离-本公司数据可访问", d["code"] == 1)
|
||||
|
||||
# ---- 2 跨公司按 ID 直访(应"不存在") ----
|
||||
# A 的第一份合同/客户 id
|
||||
d = call(A, "GET", "/api/contracts?page=1&page_size=1")
|
||||
a_ct = d["data"]["contracts"]["data"][0]
|
||||
d = call(B, "GET", "/api/contracts?page=1&page_size=50")
|
||||
a_ct_id = [x["id"] for x in json.loads(json.dumps(d["data"]["contracts"]["data"])) ] # B 只有自己的
|
||||
d = call(B, "GET", "/api/customers?page=1&page_size=1")
|
||||
a_cust_id = a_ct["customer_id"]
|
||||
if a_cust_id:
|
||||
d = call(B, "GET", f"/api/customers/{a_cust_id}/follows")
|
||||
# A 客户的跟进接口——按 id 直访(不校验归属 → 只读不泄露数据则宽可接受;这里验证列表为空或不存在)
|
||||
check("跨公司-客户跟进无泄露", d["code"] == 1 and d["data"] == [] or d["code"] == 0)
|
||||
d = call(A, "GET", "/api/customers?page=1&page_size=5")
|
||||
a_ids = [x["id"] for x in d["data"]["data"]]
|
||||
if a_ids:
|
||||
d = call(B, "GET", f"/api/customers/{a_ids[0]}/follows")
|
||||
check("跨公司-跟进列表不泄露A数据", d["code"] != 1 or d["data"] == [])
|
||||
# A 的收款合同 id
|
||||
d = call(B, "POST", "/api/contracts/{ct}/receipts".replace("{ct}", str(a_ct["id"])), {"amount": 1})
|
||||
check("跨公司-不能给A合同登记收款", d["code"] == 0)
|
||||
|
||||
# ---- 3 引用完整性 ----
|
||||
d = call(A, "DELETE", f"/api/contracts/{a_ct['id']}")
|
||||
has_orders = "订单" in d.get("message", "")
|
||||
check("引用-有订单合同删除被拒", d["code"] == 0 and has_orders, f"resp={d.get('message')}")
|
||||
cust_with_contract = a_ct["customer_id"]
|
||||
if cust_with_contract:
|
||||
d = call(A, "DELETE", f"/api/customers/{cust_with_contract}")
|
||||
check("引用-有合同客户删除被拒", d["code"] == 0 and "合同" in d.get("message", ""), f"resp={d.get('message')}")
|
||||
# 已完工订单删除被拒
|
||||
d = call(A, "POST", "/api/orders/search", {"page": 1, "page_size": 20, "status": 3})
|
||||
finished = d["data"]["data"]
|
||||
if finished:
|
||||
d = call(A, "DELETE", f"/api/orders/{finished[0]['id']}")
|
||||
check("引用-已完工订单删除被拒", d["code"] == 0 and "已完工" in d.get("message", ""), f"resp={d.get('message')}")
|
||||
else:
|
||||
check("引用-已完工订单删除被拒", True, "(无已完工订单样本, 跳过)")
|
||||
|
||||
# ---- 4 公开接口范围 ----
|
||||
d = call(A, "POST", "/api/shares", {"type": "quote", "title": "审计", "payload": {"total": 5}})
|
||||
stoken = d["data"]["token"]
|
||||
r = httpx.get(f"{BASE}/api/shares/{stoken}", timeout=15) # 无登录
|
||||
check("公开-分享可匿名访问", r.json()["code"] == 1)
|
||||
r = httpx.get(f"{BASE}/api/customers?page=1", timeout=15) # 无登录
|
||||
body = r.json()
|
||||
check("公开-业务接口匿名被拒", body.get("code") in (401, "401") or "登录" in str(body.get("message", "")), f"resp={str(body)[:80]}")
|
||||
r = httpx.get(f"{BASE}/api/shares/nonexistent-token-xyz", timeout=15)
|
||||
check("公开-无效分享返回失败", r.json()["code"] == 0)
|
||||
|
||||
# ---- 5 持久化(上轮创建的数据重启后仍在) ----
|
||||
d = call(A, "GET", "/api/customers?page=1&page_size=50")
|
||||
names = [x["name"] for x in d["data"]["data"]]
|
||||
check("持久化-跟进测试客户存活", any("跟进测试客户" in n for n in names), f"names={names[:5]}")
|
||||
d = call(A, "GET", "/api/sunroom/designs")
|
||||
check("持久化-阳光房设计存活", d["code"] == 1 and any("阳光房" in (x.get("name") or "") for x in d["data"]))
|
||||
d = call(A, "GET", "/api/salesmen")
|
||||
check("持久化-业务员存活", d["code"] == 1 and any(s["name"] == "李业务" for s in d["data"]))
|
||||
|
||||
# ---- 清理审计数据 ----
|
||||
async with SessionLocal() as db:
|
||||
from sqlalchemy import delete as _del
|
||||
await db.execute(_del(Contract).where(Contract.id == ct2_id))
|
||||
await db.execute(_del(Customer).where(Customer.id == c2_id))
|
||||
await db.execute(_del(User).where(User.id == u2_id))
|
||||
await db.commit()
|
||||
|
||||
print(f"\n===== 深度审计 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====")
|
||||
for name, r in results:
|
||||
print(f" [{'✓' if r == 'PASS' else '✗'}] {name} {r if r != 'PASS' else ''}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -181,7 +181,7 @@ def main():
|
||||
d = call("POST", f"/api/customers/{cid2}/follows", {"content": "冒烟跟进", "next_date": "2026-10-01"})
|
||||
check("P1-23 客户跟进", d.get("code") == 1)
|
||||
d = call("GET", f"/api/customers/{cid2}/follows")
|
||||
check("P1-23b 跟进列表", d.get("code") == 1 and len(d["data"]) == 1)
|
||||
check("P1-23b 跟进列表", d.get("code") == 1 and len(d["data"]) >= 1 and d["data"][-1]["content"] == "冒烟跟进")
|
||||
|
||||
# 24 业务员
|
||||
d = call("GET", "/api/salesmen/statistics")
|
||||
|
||||
@@ -41,4 +41,18 @@ class ContractService:
|
||||
contract = await self.repo.get(contract_id, user.company_id)
|
||||
if contract is None:
|
||||
raise KeyError("合同不存在")
|
||||
# 引用完整性: 有订单/收款禁止直接删(避免孤儿订单与财务统计缺口)
|
||||
from sqlalchemy import func, select
|
||||
from huamenchuang.models.business import ContractReceipt
|
||||
from huamenchuang.models.order import Order
|
||||
db = self.repo.db
|
||||
o_count = (await db.execute(select(func.count()).select_from(Order).where(
|
||||
Order.company_id == user.company_id, Order.contract_id == contract_id))).scalar_one()
|
||||
if o_count:
|
||||
raise ValueError(f"该合同下有 {o_count} 个订单,请先删除订单")
|
||||
r_count = (await db.execute(select(func.count()).select_from(ContractReceipt).where(
|
||||
ContractReceipt.company_id == user.company_id, ContractReceipt.contract_id == contract_id,
|
||||
ContractReceipt.status == 1))).scalar_one()
|
||||
if r_count:
|
||||
raise ValueError(f"该合同有 {r_count} 条有效收款记录,请先作废收款")
|
||||
await self.repo.delete(contract)
|
||||
|
||||
@@ -25,4 +25,22 @@ class CustomerService:
|
||||
customer = await self.repo.get(customer_id, user.company_id)
|
||||
if customer is None:
|
||||
raise KeyError("客户不存在")
|
||||
# 引用完整性: 有下游单据禁止直接删(避免孤儿合同/量尺/跟进)
|
||||
cid = customer.id
|
||||
from sqlalchemy import func, select
|
||||
from huamenchuang.models.business import CustomerFollow, MeasureOrder
|
||||
from huamenchuang.models.contract import Contract
|
||||
db = self.repo.db
|
||||
c_count = (await db.execute(select(func.count()).select_from(Contract).where(
|
||||
Contract.company_id == user.company_id, Contract.customer_id == cid))).scalar_one()
|
||||
if c_count:
|
||||
raise ValueError(f"该客户名下有 {c_count} 份合同,请先处理合同再删除")
|
||||
m_count = (await db.execute(select(func.count()).select_from(MeasureOrder).where(
|
||||
MeasureOrder.company_id == user.company_id, MeasureOrder.customer_id == cid))).scalar_one()
|
||||
if m_count:
|
||||
raise ValueError(f"该客户有 {m_count} 张量尺单,请先删除量尺单")
|
||||
f_count = (await db.execute(select(func.count()).select_from(CustomerFollow).where(
|
||||
CustomerFollow.company_id == user.company_id, CustomerFollow.customer_id == cid))).scalar_one()
|
||||
if f_count:
|
||||
raise ValueError(f"该客户有 {f_count} 条跟进记录,请先删除跟进记录")
|
||||
await self.repo.delete(customer)
|
||||
|
||||
@@ -97,6 +97,8 @@ class OrderService:
|
||||
|
||||
async def delete_order(self, user: User, order_id: int) -> None:
|
||||
order = await self.get_detail(user, order_id)
|
||||
if order.status == Order.STATUS_FINISHED:
|
||||
raise ValueError("已完工订单不可删除(生产留档)")
|
||||
await self.repo.add_log(order.id, user.id, "delete", f"删除订单 {order.order_no}")
|
||||
await self.repo.delete(order)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user