Files
jiu/backend/internal/middleware/license_guard_test.go
T
wangjia 23dff69c62 feat(backend): 授权改为时长兑换券体系 + 退役 ed25519/HMAC + 平台生码工具
- 新增 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>
2026-06-19 12:14:27 +08:00

180 lines
7.1 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 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 快照仍是 normallic_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 说 normalPOST 也被 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)
}