feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用
会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库): - refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话 - 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计) - 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED - 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine 授权实时 phase + 心跳回带: - LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登 - /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁 首次使用自动试用 + code-review 修复: - 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务) - ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级 - trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态) - 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
svc *service.AuthService
|
||||
svc *service.AuthService
|
||||
licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{svc: svc}
|
||||
func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler {
|
||||
return &AuthHandler{svc: svc, licSvc: licSvc}
|
||||
}
|
||||
|
||||
// Login POST /api/v1/auth/login
|
||||
@@ -118,7 +119,20 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401
|
||||
// Ping POST /api/v1/auth/ping —— 心跳:会话已撤销则中间件直接 401(触发登出),
|
||||
// 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info
|
||||
// (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。
|
||||
func (h *AuthHandler) Ping(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
if h.licSvc == nil {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
// 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
|
||||
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
@@ -29,7 +32,7 @@ func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
|
||||
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
@@ -43,7 +46,7 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -74,7 +77,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
func TestAuthHandler_Login_MissingFields(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -120,7 +123,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
@@ -160,7 +163,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -181,7 +184,7 @@ func TestAuthHandler_Login_DisabledUser(t *testing.T) {
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -205,7 +208,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
testutil.CreateTestShop(db, "AH006")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -227,7 +230,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -242,7 +245,7 @@ func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_MissingToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -263,7 +266,7 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -294,3 +297,42 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
assert.Equal(t, "manager", userInfo["username"])
|
||||
}
|
||||
}
|
||||
|
||||
// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。
|
||||
func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
priv, _, err := util.GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
config.C.License.Ed25519PrivateKey = priv
|
||||
|
||||
shop := testutil.CreateTestShop(db, "AHPING")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
authSvc := service.NewAuthService(db)
|
||||
// 登录触发首登自动 trial。
|
||||
_, _, err = authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
licSvc := service.NewLicenseService(db)
|
||||
h := NewAuthHandler(authSvc, licSvc)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/ping", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxShopID, shop.ID) // 模拟 JWT 中间件注入 shop_id
|
||||
h.Ping(c)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/auth/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(t, true, data["ok"])
|
||||
lic, ok := data["license"].(map[string]interface{})
|
||||
require.True(t, ok, "心跳应回带 license 概况")
|
||||
assert.Equal(t, "trial", lic["type"])
|
||||
assert.NotEmpty(t, lic["phase"])
|
||||
}
|
||||
|
||||
@@ -58,29 +58,13 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
|
||||
|
||||
// Info GET /api/v1/license/info — 当前门店授权概况
|
||||
func (h *LicenseHandler) Info(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
lic, err := h.svc.ShopInfo(shopID)
|
||||
if err != nil {
|
||||
util.RespondSuccess(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
deviceCount, err := h.svc.CountDevices(lic.ID)
|
||||
view, err := h.svc.ShopInfoView(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
phase := middleware.CalcLicensePhase(lic.ExpiresAt)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"id": lic.ID,
|
||||
"type": lic.Type,
|
||||
"is_active": lic.IsActive,
|
||||
"max_devices": lic.MaxDevices,
|
||||
"device_count": deviceCount,
|
||||
"expires_at": lic.ExpiresAt,
|
||||
"phase": phase,
|
||||
}})
|
||||
// view 为 nil 表示无有效授权 → data:null(与原行为一致)。
|
||||
util.RespondSuccess(c, view)
|
||||
}
|
||||
|
||||
// Devices GET /api/v1/license/devices — 已绑定设备列表
|
||||
|
||||
@@ -40,7 +40,7 @@ func (h *SessionHandler) ForceLogout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
shopID := middleware.GetShopID(c)
|
||||
if err := h.svc.ForceLogout(shopID, id); err != nil {
|
||||
if err := h.svc.ForceLogout(shopID, id, middleware.GetUserID(c)); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// setupSessionSecurityRouter 挂 JWT 中间件 + 用户管理路由 + 一个受保护 GET,
|
||||
// 用于验证「改密/禁用即时下线」与中间件对 is_active 的兜底校验。
|
||||
func setupSessionSecurityRouter(db *gorm.DB) *gin.Engine {
|
||||
userH := NewUserHandler(db)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT(db))
|
||||
api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
users := api.Group("/users")
|
||||
users.PUT("/:id", userH.Update)
|
||||
users.PUT("/:id/reset-password", userH.ResetPassword)
|
||||
return r
|
||||
}
|
||||
|
||||
// #2 管理员重置某用户密码 → 该用户全部活跃会话立即失效。
|
||||
func TestResetPassword_RevokesUserSessions(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC01")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
// 目标用户登录,拿到带 sid 的 access token(建出真实会话行)。
|
||||
pair, _, err := svc.Login("SEC01", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
|
||||
// 改密前:目标 token 可用。
|
||||
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
|
||||
|
||||
// 管理员重置目标用户密码。
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
w := makeRequest(r, "PUT", "/api/v1/users/2/reset-password", adminToken, jsonBody("password", "newpass123"))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// 改密后:目标 token 立即 401 SESSION_REVOKED。
|
||||
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
|
||||
|
||||
// 会话审计字段。
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Equal(t, "pwd_reset", sess.RevokedReason)
|
||||
require.NotNil(t, sess.RevokedBy)
|
||||
assert.Equal(t, admin.ID, *sess.RevokedBy)
|
||||
}
|
||||
|
||||
// #3 管理员停用某用户 → 该用户活跃会话被吊销,原 token 立即 401。
|
||||
func TestDisableUser_RevokesSessions(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC02")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SEC02", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
w := makeRequest(r, "PUT", "/api/v1/users/2", adminToken, jsonBody("is_active", false))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
|
||||
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Equal(t, "disabled", sess.RevokedReason)
|
||||
}
|
||||
|
||||
// #3 兜底:直接改库把 is_active=0(不动会话)→ 中间件 JOIN 校验返回 401 USER_DISABLED。
|
||||
func TestMiddleware_RejectsDisabledUserOnDirectDBFlip(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC03")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SEC03", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
|
||||
|
||||
// 绕过 handler 直接改库:会话仍为活跃(revoked_at 为空),仅 is_active=0。
|
||||
require.NoError(t, db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", target.ID).Error)
|
||||
|
||||
w := makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "USER_DISABLED", parseResponse(w)["code"])
|
||||
|
||||
// 会话本身未被吊销,证明拦截来自中间件对 is_active 的兜底校验。
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Nil(t, sess.RevokedAt)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,13 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates)
|
||||
}
|
||||
// 禁用用户时立即吊销其全部活跃会话,使其在线设备下次请求即被拦下。
|
||||
if req.IsActive != nil && !*req.IsActive {
|
||||
if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "disabled"); err != nil {
|
||||
// 吊销失败不阻断本次更新(用户已停用,refresh 也已被 is_active 拦截)。
|
||||
c.Error(err) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u)
|
||||
util.RespondSuccess(c, u)
|
||||
}
|
||||
@@ -126,5 +134,9 @@ func (h *UserHandler) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
h.db.Model(&u).Update("password_hash", string(hash))
|
||||
// 改密后吊销该用户全部活跃会话,旧设备 token 立即失效(防止凭旧凭据继续访问)。
|
||||
if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "pwd_reset"); err != nil {
|
||||
c.Error(err) //nolint:errcheck
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码已重置"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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 应被拦截")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user