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) }