Files
wangjia ca7595b113 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
2026-07-03 09:57:52 +08:00

107 lines
3.5 KiB
Go
Raw Permalink 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.
package handler
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// setupGuardedRouter 复刻 router.go:98 的真实链路:
// JWT → ReadOnly → LicenseGuard,后接真实 product handler。
// 用于「写权限鉴权」的全链路集成测试(后端侧前后端契约)。
func setupGuardedRouter(db *gorm.DB) *gin.Engine {
productH := NewProductHandler(db)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT(db), middleware.ReadOnly(), middleware.LicenseGuard(db))
products := api.Group("/products")
products.GET("", productH.List)
products.POST("", productH.Create)
return r
}
// seedLicense 给店铺写入一条有效授权,daysAgo>0 表示已过期天数,<=0 表示未过期(剩余天数)。
func seedLicense(t *testing.T, db *gorm.DB, shopID uint64, daysAgo int) {
exp := time.Now().Add(-time.Duration(daysAgo) * 24 * time.Hour)
assert.NoError(t, db.Create(&model.License{
ShopID: shopID,
IsActive: true,
Type: "trial",
LicenseKey: fmt.Sprintf("KEY-%d-%d", shopID, time.Now().UnixNano()),
ExpiresAt: &exp,
}).Error)
}
// TestWriteAccessMatrix 钉死「角色 × 授权阶段」对写/读操作的 403/200 契约。
// 这是前端唯一依赖的契约,任何回归都会被这张表抓到。
func TestWriteAccessMatrix(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testutil.SetupTestDB()
r := setupGuardedRouter(db)
// 每档一个独立店铺,避免 LicenseGuard 的 30s 缓存按 shopID 串扰。
type tc struct {
name string
role string
daysExpired int // >0 已过期天数;-30 表示未过期
wantPostFwd bool // POST 是否应放行(非 403)
wantGetFwd bool // GET 是否应放行
wantCode string // 期望 403 body 的 code(空则不校验)
wantPhase string // 期望 403 body 的 phase(空则不校验)
}
cases := []tc{
{"operator_normal", "operator", -30, true, true, "", ""},
{"operator_grace", "operator", 3, true, true, "", ""},
{"operator_readonly", "operator", 10, false, true, "", "readonly"},
{"operator_locked", "operator", 20, false, false, "", "locked"},
{"readonly_role", "readonly", -30, false, true, "READONLY_USER", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
shop := testutil.CreateTestShop(db, c.name)
shopID := shop.ID
user := testutil.CreateTestUser(db, shopID, "u_"+c.name, "password123", c.role)
seedLicense(t, db, shopID, c.daysExpired)
token := getAuthToken(user.ID, shopID, c.role)
// POST /products
wPost := makeRequest(r, http.MethodPost, "/api/v1/products", token, jsonBody("name", "集成测试酒"))
if c.wantPostFwd {
assert.NotEqual(t, http.StatusForbidden, wPost.Code, "POST 应放行")
} else {
assert.Equal(t, http.StatusForbidden, wPost.Code, "POST 应被拦截")
body := parseResponse(wPost)
if c.wantCode != "" {
assert.Equal(t, c.wantCode, body["code"])
}
if c.wantPhase != "" {
assert.Equal(t, c.wantPhase, body["phase"])
}
}
// GET /products
wGet := makeRequest(r, http.MethodGet, "/api/v1/products", token, nil)
if c.wantGetFwd {
assert.NotEqual(t, http.StatusForbidden, wGet.Code, "GET 应放行")
} else {
assert.Equal(t, http.StatusForbidden, wGet.Code, "GET 应被拦截")
}
})
}
}