Files
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

334 lines
9.8 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/testutil"
)
func init() {
gin.SetMode(gin.TestMode)
testutil.InitConfig()
}
func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AUTHTEST")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
testutil.CreateTestUser(db, shop.ID, "disabled", "password123", "operator")
// 禁用该用户
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID)
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
r.POST("/api/v1/auth/refresh", h.Refresh)
return r, nil
}
func TestAuthHandler_Login_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AH001")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"shop_code": "AH001",
"username": "admin",
"password": "password123",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
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.NotEmpty(t, data["access_token"])
assert.NotEmpty(t, data["refresh_token"])
}
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AH002")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"shop_code": "AH002",
"username": "admin",
"password": "wrongpassword",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Login_MissingFields(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
// 缺少必填字段
body := map[string]string{
"shop_code": "AH003",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAuthHandler_Refresh_Success(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AH004")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
r.POST("/api/v1/auth/refresh", h.Refresh)
// 先登录获取 token
loginBody := map[string]string{
"shop_code": "AH004",
"username": "admin",
"password": "password123",
}
loginBytes, _ := json.Marshal(loginBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(loginBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var loginResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &loginResp)
data := loginResp["data"].(map[string]interface{})
refreshToken := data["refresh_token"].(string)
// 刷新 token
refreshBody := map[string]string{"refresh_token": refreshToken}
refreshBytes, _ := json.Marshal(refreshBody)
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(refreshBytes))
req2.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
var resp map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &resp)
newData := resp["data"].(map[string]interface{})
assert.NotEmpty(t, newData["access_token"])
}
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/refresh", h.Refresh)
body := map[string]string{"refresh_token": "invalid.token.here"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Login_DisabledUser(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AH005")
user := testutil.CreateTestUser(db, shop.ID, "disabled_user", "password123", "operator")
db.Model(user).Update("is_active", false)
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"shop_code": "AH005",
"username": "disabled_user",
"password": "password123",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
db := testutil.SetupTestDB()
testutil.CreateTestShop(db, "AH006")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"shop_code": "NONEXISTENT",
"username": "admin",
"password": "password123",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Login_EmptyBody(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAuthHandler_Refresh_MissingToken(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/refresh", h.Refresh)
body := map[string]string{}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
// 缺少 refresh_token,应返回 4xx
assert.True(t, w.Code >= 400 && w.Code < 500)
}
func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "AH007")
testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc, nil)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"shop_code": "AH007",
"username": "manager",
"password": "password123",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
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{})
// token 不为空
assert.NotEmpty(t, data["access_token"])
assert.NotEmpty(t, data["refresh_token"])
// 包含 shop_id
assert.NotNil(t, data["shop_id"])
// 包含用户信息
userInfo, ok := data["user"].(map[string]interface{})
if ok {
assert.Equal(t, "manager", userInfo["username"])
}
}
// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。
func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) {
db := testutil.SetupTestDB()
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"])
}