23dff69c62
- 新增 license_codes 码池表 + model.LicenseCode;licenses 加 tier 档位列 - LicenseService.Redeem:单事务 FOR UPDATE 校验码未用 → 时长叠加(可叠加,0=永久) → 写 type/tier/max_devices → 绑设备(超限整笔回滚) → 标记已用 → 即时失效 phase 缓存 路由仍 POST /license/activate,客户端零破坏 - util.GenerateRedeemCode/NormalizeCode:JIUKU-XXXX-XXXX 短码(crypto/rand) - cmd/gencode:平台批量生成兑换码并落库;删除 cmd/issue、cmd/genkey - 退役 ed25519 + HMAC:删 util/license_key、GenerateKey、License 全部 config 字段 及生产启动私钥校验;trial 改直接建行(无需私钥、去 Fatal) - tier 档位钩子默认 standard,分档消费模式后续设计 - 测试:Redeem 全场景(叠加/过期重置/永久/一码一次/无效/设备上限回滚) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
7.1 KiB
Go
180 lines
7.1 KiB
Go
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
|
||
"github.com/wangjia/jiu/backend/internal/model"
|
||
)
|
||
|
||
func TestCalcLicensePhase(t *testing.T) {
|
||
now := time.Now()
|
||
|
||
// nil = perpetual → normal
|
||
assert.Equal(t, PhaseNormal, CalcLicensePhase(nil))
|
||
|
||
// future expiry → normal
|
||
future := now.Add(10 * 24 * time.Hour)
|
||
assert.Equal(t, PhaseNormal, CalcLicensePhase(&future))
|
||
|
||
// just expired (1h ago) → grace
|
||
grace := now.Add(-1 * time.Hour)
|
||
assert.Equal(t, PhaseGrace, CalcLicensePhase(&grace))
|
||
|
||
// expired 6 days ago → grace (boundary)
|
||
grace6d := now.Add(-6 * 24 * time.Hour)
|
||
assert.Equal(t, PhaseGrace, CalcLicensePhase(&grace6d))
|
||
|
||
// expired 8 days ago → readonly
|
||
readonly := now.Add(-8 * 24 * time.Hour)
|
||
assert.Equal(t, PhaseReadOnly, CalcLicensePhase(&readonly))
|
||
|
||
// expired 14 days ago → readonly (boundary)
|
||
readonly14d := now.Add(-14 * 24 * time.Hour)
|
||
assert.Equal(t, PhaseReadOnly, CalcLicensePhase(&readonly14d))
|
||
|
||
// expired 16 days ago → locked
|
||
locked := now.Add(-16 * 24 * time.Hour)
|
||
assert.Equal(t, PhaseLocked, CalcLicensePhase(&locked))
|
||
}
|
||
|
||
// TestLicenseGuardUsesLiveDBPhase 验证 LicenseGuard 以「当前 DB 的授权状态」判 phase,
|
||
// 而非信任登录时嵌入 token 的快照(lic_exp)。这是「只读模式还是能改数据」的根因修复:
|
||
// 即便 token 快照仍是 normal(lic_exp 未来),DB 改成过期后写操作也应立即被拦截。
|
||
func TestLicenseGuardUsesLiveDBPhase(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
assert.NoError(t, err)
|
||
// sqlite 不支持 enum/json 列类型,AutoMigrate 会失败,按 testutil 方式用原始 SQL 建表。
|
||
assert.NoError(t, db.Exec(`CREATE TABLE licenses (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
created_at DATETIME, updated_at DATETIME, deleted_at DATETIME,
|
||
shop_id INTEGER NOT NULL,
|
||
license_key TEXT, type TEXT, tier TEXT DEFAULT 'standard', expires_at DATETIME,
|
||
is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3,
|
||
features TEXT, device_id TEXT, activated_at DATETIME
|
||
)`).Error)
|
||
now := time.Now()
|
||
tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal
|
||
|
||
// 每个 shop 一条有效授权,DB 到期时间各异;用不同 shopID 规避缓存串扰。
|
||
seed := func(shopID uint64, dbExpiresAt *time.Time) {
|
||
licensePhaseCache.Delete(shopID)
|
||
assert.NoError(t, db.Create(&model.License{
|
||
ShopID: shopID, IsActive: true, ExpiresAt: dbExpiresAt,
|
||
}).Error)
|
||
}
|
||
|
||
// invoke 跑一次带 LicenseGuard 的请求,返回(是否被拦截, 状态码)。
|
||
invoke := func(shopID uint64, method string) (bool, int) {
|
||
w := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(w)
|
||
c.Request = httptest.NewRequest(method, "/products", nil)
|
||
c.Set(CtxShopID, shopID)
|
||
c.Set(CtxLicenseExpiresAt, &tokenFuture)
|
||
LicenseGuard(db)(c)
|
||
return c.IsAborted(), w.Code
|
||
}
|
||
|
||
// DB 未过期 → 写放行
|
||
future := now.Add(10 * 24 * time.Hour)
|
||
seed(1001, &future)
|
||
aborted, _ := invoke(1001, http.MethodPost)
|
||
assert.False(t, aborted, "DB 未过期应放行写操作")
|
||
|
||
// DB 过期 10 天(只读期)→ 即便 token 说 normal,POST 也被 403 拦截
|
||
expired10d := now.Add(-10 * 24 * time.Hour)
|
||
seed(1002, &expired10d)
|
||
aborted, code := invoke(1002, http.MethodPost)
|
||
assert.True(t, aborted, "DB 只读期应拦截写操作")
|
||
assert.Equal(t, http.StatusForbidden, code)
|
||
|
||
// 只读期 GET 放行
|
||
aborted, _ = invoke(1002, http.MethodGet)
|
||
assert.False(t, aborted, "只读期 GET 应放行")
|
||
|
||
// DB 过期 20 天(锁定期)→ 连 GET 也 403
|
||
expired20d := now.Add(-20 * 24 * time.Hour)
|
||
seed(1003, &expired20d)
|
||
aborted, code = invoke(1003, http.MethodGet)
|
||
assert.True(t, aborted, "锁定期应拦截所有请求")
|
||
assert.Equal(t, http.StatusForbidden, code)
|
||
}
|
||
|
||
// TestLicenseGuardRevokedAndInvalidation 覆盖 #5/#6:
|
||
// - 主动停用(is_active=0,且未过期)应锁定写操作,而非回退到 token 快照继续放行;
|
||
// - 从未配置授权的门店仍回退 token 快照,避免误锁;
|
||
// - InvalidateLicensePhase 让授权变更绕过 30s 缓存即时生效。
|
||
func TestLicenseGuardRevokedAndInvalidation(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
assert.NoError(t, err)
|
||
assert.NoError(t, db.Exec(`CREATE TABLE licenses (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
created_at DATETIME, updated_at DATETIME, deleted_at DATETIME,
|
||
shop_id INTEGER NOT NULL,
|
||
license_key TEXT, type TEXT, tier TEXT DEFAULT 'standard', expires_at DATETIME,
|
||
is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3,
|
||
features TEXT, device_id TEXT, activated_at DATETIME
|
||
)`).Error)
|
||
|
||
now := time.Now()
|
||
tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal
|
||
|
||
invoke := func(shopID uint64, method string) (bool, int) {
|
||
w := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(w)
|
||
c.Request = httptest.NewRequest(method, "/products", nil)
|
||
c.Set(CtxShopID, shopID)
|
||
c.Set(CtxLicenseExpiresAt, &tokenFuture)
|
||
LicenseGuard(db)(c)
|
||
return c.IsAborted(), w.Code
|
||
}
|
||
|
||
// #6 主动停用:未过期但 is_active=0 → 即便 token 说 normal,写/读都应被锁定。
|
||
// 注意 GORM bool 零值陷阱:Create 时 IsActive:false 会被列默认值 1 覆盖,
|
||
// 生产中停用也总是经 Update 落地,故这里同样建后再 Update。
|
||
future := now.Add(10 * 24 * time.Hour)
|
||
InvalidateLicensePhase(2001)
|
||
lic2001 := model.License{ShopID: 2001, IsActive: true, ExpiresAt: &future}
|
||
assert.NoError(t, db.Create(&lic2001).Error)
|
||
assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic2001.ID).Update("is_active", false).Error)
|
||
aborted, code := invoke(2001, http.MethodGet)
|
||
assert.True(t, aborted, "被停用授权应锁定(连 GET 也拦)")
|
||
assert.Equal(t, http.StatusForbidden, code)
|
||
|
||
// #6 从未配置授权:无任何 license 行 → 回退 token 快照(normal)→ 放行,避免误锁。
|
||
InvalidateLicensePhase(2002)
|
||
aborted, _ = invoke(2002, http.MethodPost)
|
||
assert.False(t, aborted, "未配置授权的门店应回退 token 快照放行")
|
||
|
||
// #5 缓存失效:先有有效授权(缓存为 normal 放行),再停用并 Invalidate → 立即锁定。
|
||
InvalidateLicensePhase(2003)
|
||
lic := model.License{ShopID: 2003, IsActive: true, ExpiresAt: &future}
|
||
assert.NoError(t, db.Create(&lic).Error)
|
||
aborted, _ = invoke(2003, http.MethodPost) // 写入缓存 normal
|
||
assert.False(t, aborted, "有效授权应放行写操作")
|
||
|
||
assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic.ID).Update("is_active", false).Error)
|
||
// 不失效缓存:30s 内仍按旧 normal 放行
|
||
aborted, _ = invoke(2003, http.MethodPost)
|
||
assert.False(t, aborted, "未失效缓存时停用应仍受 30s 缓存保护")
|
||
// 失效缓存后:重新查库 → 锁定即时生效
|
||
InvalidateLicensePhase(2003)
|
||
aborted, code = invoke(2003, http.MethodPost)
|
||
assert.True(t, aborted, "Invalidate 后停用应即时生效")
|
||
assert.Equal(t, http.StatusForbidden, code)
|
||
}
|