Files
jiu/backend/internal/handler/write_access_test.go
T
wangjia e41085a878 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>
2026-06-19 07:34:04 +08:00

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