ca7595b113
- 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
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
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 应被拦截")
|
||
}
|
||
})
|
||
}
|
||
}
|