feat(backend): 出入库汇总近30天滚动窗口 + 跨租户安全加固
- summaryBounds:本月/近30天滚动双口径(stock-in/out Summary ?window=rolling30) - security(SEC-001):新增 ownership.go ensureShopRef 写入侧防线(stock-in/out/finance/ 盘点建单的 warehouse/partner/product 外键归属校验);读取侧全部 Preload 补 shop_id 作用域,finance Summary JOIN 补租户条件;回归测试 CrossTenantRefs - security(SEC-002):release 模式 JWT 密钥为空/默认值时拒绝启动 - gofmt 对齐若干 model/cmd 文件 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -164,6 +164,20 @@ func TestStockInHandler_Summary(t *testing.T) {
|
||||
assert.Equal(t, float64(2), s["month_count"])
|
||||
assert.Equal(t, float64(400), s["month_amount"])
|
||||
assert.Equal(t, float64(1), s["pending_count"])
|
||||
|
||||
// rolling30 口径:40 天前的单落「前一窗」,今天的 2 笔在近 30 天窗
|
||||
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID,
|
||||
"order_date": time.Now().AddDate(0, 0, -40).Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_name": "B", "quantity": 1.0, "unit_price": 50.0}},
|
||||
})
|
||||
w = makeRequest(r, "GET", "/api/v1/stock-in/summary?window=rolling30", token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
s = parseResponse(w)
|
||||
assert.Equal(t, float64(2), s["month_count"])
|
||||
assert.Equal(t, float64(400), s["month_amount"])
|
||||
assert.Equal(t, float64(1), s["last_month_count"])
|
||||
assert.Equal(t, float64(50), s["last_month_amount"])
|
||||
}
|
||||
|
||||
func TestStockInHandler_DetailFilter(t *testing.T) {
|
||||
@@ -801,3 +815,50 @@ func TestStockInHandler_List_FilterByStatus(t *testing.T) {
|
||||
resp = parseResponse(w)
|
||||
assert.Equal(t, float64(2), resp["total"].(float64))
|
||||
}
|
||||
|
||||
// SEC-001 回归:跨租户外键引用必须被拒绝;存量脏引用(他店 partner_id)
|
||||
// 读取时 Preload 不得把他店对象带出。
|
||||
func TestStockInHandler_CrossTenantRefs(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shopA := testutil.CreateTestShop(db, "SEC1A")
|
||||
shopB := testutil.CreateTestShop(db, "SEC1B")
|
||||
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
|
||||
whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
|
||||
whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB")
|
||||
partnerB := &model.Partner{
|
||||
TenantBase: model.TenantBase{ShopID: shopB.ID},
|
||||
Name: "B店机密供应商", Type: "supplier", Phone: "13800000000"}
|
||||
require.NoError(t, db.Create(partnerB).Error)
|
||||
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 1) 引用他店仓库 → 400
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
|
||||
"warehouse_id": whB.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop warehouse must be rejected")
|
||||
|
||||
// 2) 引用他店往来单位 → 400
|
||||
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
|
||||
"warehouse_id": whA.ID,
|
||||
"partner_id": partnerB.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop partner must be rejected")
|
||||
|
||||
// 3) 存量脏引用:直接落库一张 A 店单指向 B 店 partner,GET 不得带出 B 店对象
|
||||
dirty := &model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopA.ID},
|
||||
WarehouseID: whA.ID, PartnerID: &partnerB.ID,
|
||||
OrderNo: "SEC1-DIRTY", Status: "draft", OperatorID: userA.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(dirty).Error)
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", dirty.ID), tokenA, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
body := w.Body.String()
|
||||
assert.NotContains(t, body, "B店机密供应商", "cross-shop partner PII must not leak via preload")
|
||||
assert.NotContains(t, body, "13800000000")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user