This commit is contained in:
@@ -1,170 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/middleware"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type legacyCheckCodeResponse struct {
|
||||
Code uint32 `json:"code"`
|
||||
Data struct {
|
||||
Status bool `json:"status"`
|
||||
Exist bool `json:"exist"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func newLegacyCheckCodeTestRouter(svcCtx *svc.ServiceContext) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(middleware.ApiVersionMiddleware(svcCtx))
|
||||
router.POST("/v1/auth/check-code", middleware.ApiVersionSwitchHandler(
|
||||
CheckCodeLegacyV1Handler(svcCtx),
|
||||
CheckCodeLegacyV2Handler(svcCtx),
|
||||
))
|
||||
return router
|
||||
}
|
||||
|
||||
func newLegacyCheckCodeTestSvcCtx(t *testing.T) (*svc.ServiceContext, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
return svcCtx, redisClient
|
||||
}
|
||||
|
||||
func seedLegacyVerifyCode(t *testing.T, redisClient *redis.Client, scene string, email string, code string) string {
|
||||
t.Helper()
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
payload := map[string]interface{}{
|
||||
"code": code,
|
||||
"lastAt": time.Now().Unix(),
|
||||
}
|
||||
payloadRaw, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
err = redisClient.Set(context.Background(), cacheKey, payloadRaw, time.Minute*15).Err()
|
||||
require.NoError(t, err)
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func callLegacyCheckCode(t *testing.T, router *gin.Engine, apiHeader string, body string) legacyCheckCodeResponse {
|
||||
t.Helper()
|
||||
|
||||
reqBody := bytes.NewBufferString(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/check-code", reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiHeader != "" {
|
||||
req.Header.Set("api-header", apiHeader)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp legacyCheckCodeResponse
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCheckCodeLegacyHandler_NoHeaderNotConsumed(t *testing.T) {
|
||||
svcCtx, redisClient := newLegacyCheckCodeTestSvcCtx(t)
|
||||
router := newLegacyCheckCodeTestRouter(svcCtx)
|
||||
|
||||
email := "legacy@example.com"
|
||||
code := "123456"
|
||||
cacheKey := seedLegacyVerifyCode(t, redisClient, constant.Security.String(), email, code)
|
||||
|
||||
resp := callLegacyCheckCode(t, router, "", `{"email":"legacy@example.com","code":"123456","type":3}`)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.True(t, resp.Data.Status)
|
||||
assert.True(t, resp.Data.Exist)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
}
|
||||
|
||||
func TestCheckCodeLegacyHandler_GreaterVersionConsumed(t *testing.T) {
|
||||
svcCtx, redisClient := newLegacyCheckCodeTestSvcCtx(t)
|
||||
router := newLegacyCheckCodeTestRouter(svcCtx)
|
||||
|
||||
email := "latest@example.com"
|
||||
code := "999888"
|
||||
cacheKey := seedLegacyVerifyCode(t, redisClient, constant.Security.String(), email, code)
|
||||
|
||||
resp := callLegacyCheckCode(t, router, "1.0.1", `{"email":"latest@example.com","code":"999888","type":3}`)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.True(t, resp.Data.Status)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), exists)
|
||||
|
||||
resp = callLegacyCheckCode(t, router, "1.0.1", `{"email":"latest@example.com","code":"999888","type":3}`)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.False(t, resp.Data.Status)
|
||||
assert.False(t, resp.Data.Exist)
|
||||
}
|
||||
|
||||
func TestCheckCodeLegacyHandler_EqualThresholdNotConsumed(t *testing.T) {
|
||||
svcCtx, redisClient := newLegacyCheckCodeTestSvcCtx(t)
|
||||
router := newLegacyCheckCodeTestRouter(svcCtx)
|
||||
|
||||
email := "equal@example.com"
|
||||
code := "112233"
|
||||
cacheKey := seedLegacyVerifyCode(t, redisClient, constant.Security.String(), email, code)
|
||||
|
||||
resp := callLegacyCheckCode(t, router, "1.0.0", `{"email":"equal@example.com","code":"112233","type":3}`)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.True(t, resp.Data.Status)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
}
|
||||
|
||||
func TestCheckCodeLegacyHandler_InvalidVersionNotConsumed(t *testing.T) {
|
||||
svcCtx, redisClient := newLegacyCheckCodeTestSvcCtx(t)
|
||||
router := newLegacyCheckCodeTestRouter(svcCtx)
|
||||
|
||||
email := "invalid@example.com"
|
||||
code := "445566"
|
||||
cacheKey := seedLegacyVerifyCode(t, redisClient, constant.Security.String(), email, code)
|
||||
|
||||
resp := callLegacyCheckCode(t, router, "abc", `{"email":"invalid@example.com","code":"445566","type":3}`)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.True(t, resp.Data.Status)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/middleware"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/authmethod"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type canonicalCheckCodeResponse struct {
|
||||
Code uint32 `json:"code"`
|
||||
Data struct {
|
||||
Status bool `json:"status"`
|
||||
Exist bool `json:"exist"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func newCanonicalCheckCodeTestSvcCtx(t *testing.T) (*svc.ServiceContext, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
return svcCtx, redisClient
|
||||
}
|
||||
|
||||
func newCanonicalCheckCodeTestRouter(svcCtx *svc.ServiceContext) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(middleware.ApiVersionMiddleware(svcCtx))
|
||||
router.POST("/v1/common/check_verification_code", middleware.ApiVersionSwitchHandler(
|
||||
CheckVerificationCodeV1Handler(svcCtx),
|
||||
CheckVerificationCodeV2Handler(svcCtx),
|
||||
))
|
||||
return router
|
||||
}
|
||||
|
||||
func seedCanonicalVerifyCode(t *testing.T, redisClient *redis.Client, scene string, account string, code string) string {
|
||||
t.Helper()
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, account)
|
||||
payload := map[string]interface{}{
|
||||
"code": code,
|
||||
"lastAt": time.Now().Unix(),
|
||||
}
|
||||
payloadRaw, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
err = redisClient.Set(context.Background(), cacheKey, payloadRaw, time.Minute*15).Err()
|
||||
require.NoError(t, err)
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func callCanonicalCheckCode(t *testing.T, router *gin.Engine, apiHeader string, body string) canonicalCheckCodeResponse {
|
||||
t.Helper()
|
||||
|
||||
reqBody := bytes.NewBufferString(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/common/check_verification_code", reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiHeader != "" {
|
||||
req.Header.Set("api-header", apiHeader)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp canonicalCheckCodeResponse
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCheckVerificationCodeHandler_ApiHeaderGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiHeader string
|
||||
expectConsume bool
|
||||
}{
|
||||
{name: "no header", apiHeader: "", expectConsume: false},
|
||||
{name: "invalid header", apiHeader: "invalid", expectConsume: false},
|
||||
{name: "equal threshold", apiHeader: "1.0.0", expectConsume: false},
|
||||
{name: "greater threshold", apiHeader: "1.0.1", expectConsume: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svcCtx, redisClient := newCanonicalCheckCodeTestSvcCtx(t)
|
||||
router := newCanonicalCheckCodeTestRouter(svcCtx)
|
||||
|
||||
account := "header-gate@example.com"
|
||||
code := "123123"
|
||||
cacheKey := seedCanonicalVerifyCode(t, redisClient, constant.Register.String(), account, code)
|
||||
body := fmt.Sprintf(`{"method":"%s","account":"%s","code":"%s","type":%d}`,
|
||||
authmethod.Email,
|
||||
account,
|
||||
code,
|
||||
constant.Register,
|
||||
)
|
||||
|
||||
resp := callCanonicalCheckCode(t, router, tt.apiHeader, body)
|
||||
assert.Equal(t, uint32(200), resp.Code)
|
||||
assert.True(t, resp.Data.Status)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
if tt.expectConsume {
|
||||
assert.Equal(t, int64(0), exists)
|
||||
} else {
|
||||
assert.Equal(t, int64(1), exists)
|
||||
}
|
||||
|
||||
resp = callCanonicalCheckCode(t, router, tt.apiHeader, body)
|
||||
if tt.expectConsume {
|
||||
assert.False(t, resp.Data.Status)
|
||||
} else {
|
||||
assert.True(t, resp.Data.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type handlerResponse struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func newDeleteAccountTestRouter(serverCtx *svc.ServiceContext) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/v1/public/user/delete_account", DeleteAccountHandler(serverCtx))
|
||||
return router
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandlerInvalidParamsUsesUnifiedResponse(t *testing.T) {
|
||||
router := newDeleteAccountTestRouter(&svc.ServiceContext{})
|
||||
|
||||
reqBody := bytes.NewBufferString(`{"email":"invalid-email"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/public/user/delete_account", reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
var resp handlerResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Code != 400 {
|
||||
t.Fatalf("expected business code 400, got %d, body=%s", resp.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("failed to decode raw response: %v", err)
|
||||
}
|
||||
if _, exists := raw["error"]; exists {
|
||||
t.Fatalf("unexpected raw error field in response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountHandlerVerifyCodeErrorUsesUnifiedResponse(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "invalid:6379",
|
||||
Dialer: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("dial disabled in test")
|
||||
},
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
serverCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
router := newDeleteAccountTestRouter(serverCtx)
|
||||
|
||||
reqBody := bytes.NewBufferString(`{"email":"user@example.com","code":"123456"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/public/user/delete_account", reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
var resp handlerResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Code != 70001 {
|
||||
t.Fatalf("expected business code 70001, got %d, body=%s", resp.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailCode_DeleteAccountSceneConsume(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
serverCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{VerifyCodeExpireTime: 900},
|
||||
},
|
||||
}
|
||||
|
||||
email := "delete-account@example.com"
|
||||
code := "112233"
|
||||
cacheKey := seedDeleteSceneCode(t, redisClient, constant.DeleteAccount.String(), email, code)
|
||||
|
||||
err := verifyEmailCode(context.Background(), serverCtx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyEmailCode returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check redis key: %v", err)
|
||||
}
|
||||
if exists != 0 {
|
||||
t.Fatalf("expected verification code to be consumed, key still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailCode_SecurityFallbackConsume(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
serverCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{VerifyCodeExpireTime: 900},
|
||||
},
|
||||
}
|
||||
|
||||
email := "security-fallback@example.com"
|
||||
code := "445566"
|
||||
cacheKey := seedDeleteSceneCode(t, redisClient, constant.Security.String(), email, code)
|
||||
|
||||
err := verifyEmailCode(context.Background(), serverCtx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyEmailCode fallback returned unexpected error: %v", err)
|
||||
}
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check redis key: %v", err)
|
||||
}
|
||||
if exists != 0 {
|
||||
t.Fatalf("expected fallback verification code to be consumed, key still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func seedDeleteSceneCode(t *testing.T, redisClient *redis.Client, scene string, email string, code string) string {
|
||||
t.Helper()
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
payload := map[string]interface{}{
|
||||
"code": code,
|
||||
"lastAt": time.Now().Unix(),
|
||||
}
|
||||
payloadRaw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal payload: %v", err)
|
||||
}
|
||||
err = redisClient.Set(context.Background(), cacheKey, payloadRaw, time.Minute*15).Err()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed redis payload: %v", err)
|
||||
}
|
||||
|
||||
return cacheKey
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package authMethod
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/sms"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
config := " {\"0\":\"{\",\"1\":\"\\\"\",\"10\":\"y\",\"11\":\"I\",\"12\":\"d\",\"13\":\"\\\"\",\"14\":\":\",\"15\":\"\\\"\",\"16\":\"\\\"\",\"17\":\",\",\"18\":\"\\\"\",\"19\":\"A\",\"2\":\"A\",\"20\":\"c\",\"21\":\"c\",\"22\":\"e\",\"23\":\"s\",\"24\":\"s\",\"25\":\"K\",\"26\":\"e\",\"27\":\"y\",\"28\":\"S\",\"29\":\"e\",\"3\":\"c\",\"30\":\"c\",\"31\":\"r\",\"32\":\"e\",\"33\":\"t\",\"34\":\"\\\"\",\"35\":\":\",\"36\":\"\\\"\",\"37\":\"\\\"\",\"38\":\",\",\"39\":\"\\\"\",\"4\":\"c\",\"40\":\"S\",\"41\":\"i\",\"42\":\"g\",\"43\":\"n\",\"44\":\"N\",\"45\":\"a\",\"46\":\"m\",\"47\":\"e\",\"48\":\"\\\"\",\"49\":\":\",\"5\":\"e\",\"50\":\"\\\"\",\"51\":\"\\\"\",\"52\":\",\",\"53\":\"\\\"\",\"54\":\"E\",\"55\":\"n\",\"56\":\"d\",\"57\":\"p\",\"58\":\"o\",\"59\":\"i\",\"6\":\"s\",\"60\":\"n\",\"61\":\"t\",\"62\":\"\\\"\",\"63\":\":\",\"64\":\"\\\"\",\"65\":\"\\\"\",\"66\":\",\",\"67\":\"\\\"\",\"68\":\"V\",\"69\":\"e\",\"7\":\"s\",\"70\":\"r\",\"71\":\"i\",\"72\":\"f\",\"73\":\"y\",\"74\":\"T\",\"75\":\"e\",\"76\":\"m\",\"77\":\"p\",\"78\":\"l\",\"79\":\"a\",\"8\":\"K\",\"80\":\"t\",\"81\":\"e\",\"82\":\"C\",\"83\":\"o\",\"84\":\"d\",\"85\":\"e\",\"86\":\"\\\"\",\"87\":\":\",\"88\":\"\\\"\",\"89\":\"\\\"\",\"9\":\"e\",\"90\":\"}\",\"access\":\"xxxx\",\"secret\":\"SSxxxxxxxxxxxxxxxxxxxxxxxU\",\"template\":\"Your verification code is: {{.code}}\"}"
|
||||
var mapConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(config), &mapConfig); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
platformConfig, err := validatePlatformConfig(sms.Abosend.String(), mapConfig)
|
||||
if err != nil {
|
||||
t.Errorf("validateEmailPlatformConfig error: %v", err)
|
||||
}
|
||||
t.Logf("platformConfig: %+v", platformConfig)
|
||||
}
|
||||
@@ -29,9 +29,12 @@ func NewDeleteNodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
|
||||
|
||||
func (l *DeleteNodeLogic) DeleteNode(req *types.DeleteNodeRequest) error {
|
||||
data, err := l.svcCtx.NodeModel.FindOneNode(l.ctx, req.Id)
|
||||
|
||||
err = l.svcCtx.NodeModel.DeleteNode(l.ctx, req.Id)
|
||||
if err != nil {
|
||||
l.Errorw("[DeleteNode] Find Node Error: ", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "[DeleteNode] Find Node Error")
|
||||
}
|
||||
|
||||
if err = l.svcCtx.NodeModel.DeleteNode(l.ctx, req.Id); err != nil {
|
||||
l.Errorw("[DeleteNode] Delete Database Error: ", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "[DeleteNode] Delete Database Error")
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func (l *DeleteServerLogic) DeleteServer(req *types.DeleteServerRequest) error {
|
||||
l.Errorw("[DeleteServer] Delete Server Error: ", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "[DeleteServer] Delete Server Error")
|
||||
}
|
||||
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
|
||||
l.Errorw("[DeleteServer] Clear server cache failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
return l.svcCtx.NodeModel.ClearNodeCache(l.ctx, &node.FilterNodeParams{
|
||||
Page: 1,
|
||||
Size: 1000,
|
||||
|
||||
@@ -48,6 +48,7 @@ func (l *CreateSubscribeLogic) CreateSubscribe(req *types.CreateSubscribeRequest
|
||||
SpeedLimit: req.SpeedLimit,
|
||||
DeviceLimit: req.DeviceLimit,
|
||||
Quota: req.Quota,
|
||||
NewUserOnly: req.NewUserOnly,
|
||||
Nodes: tool.Int64SliceToString(req.Nodes),
|
||||
NodeTags: tool.StringSliceToString(req.NodeTags),
|
||||
Show: req.Show,
|
||||
|
||||
@@ -33,12 +33,27 @@ func NewResetAllSubscribeTokenLogic(ctx context.Context, svcCtx *svc.ServiceCont
|
||||
func (l *ResetAllSubscribeTokenLogic) ResetAllSubscribeToken() (resp *types.ResetAllSubscribeTokenResponse, err error) {
|
||||
var list []*user.Subscribe
|
||||
tx := l.svcCtx.DB.WithContext(l.ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to begin transaction: %v", tx.Error)
|
||||
}
|
||||
// select all active and Finished subscriptions
|
||||
if err = tx.Model(&user.Subscribe{}).Where("`status` IN ?", []int64{1, 2}).Find(&list).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Errorf("[ResetAllSubscribeToken] Failed to fetch subscribe list: %v", err.Error())
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to fetch subscribe list: %v", err.Error())
|
||||
}
|
||||
|
||||
// Save old tokens before overwriting for proper cache clearing
|
||||
type oldTokenInfo struct {
|
||||
Token string
|
||||
UserId int64
|
||||
Id int64
|
||||
}
|
||||
oldTokens := make([]oldTokenInfo, len(list))
|
||||
for i, sub := range list {
|
||||
oldTokens[i] = oldTokenInfo{Token: sub.Token, UserId: sub.UserId, Id: sub.Id}
|
||||
}
|
||||
|
||||
for _, sub := range list {
|
||||
sub.Token = uuidx.SubscribeToken(strconv.FormatInt(time.Now().UnixMilli(), 10) + strconv.FormatInt(sub.Id, 10))
|
||||
sub.UUID = uuidx.NewUUID().String()
|
||||
@@ -55,6 +70,25 @@ func (l *ResetAllSubscribeTokenLogic) ResetAllSubscribeToken() (resp *types.Rese
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to commit transaction: %v", err.Error())
|
||||
}
|
||||
|
||||
// Clear cache for both old and new tokens
|
||||
for i, sub := range list {
|
||||
// Clear new token cache
|
||||
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, sub); clearErr != nil {
|
||||
logger.Errorf("[ResetAllSubscribeToken] Failed to clear new cache for subscribe ID %d: %v", sub.Id, clearErr.Error())
|
||||
}
|
||||
// Clear old token cache
|
||||
if oldTokens[i].Token != "" && oldTokens[i].Token != sub.Token {
|
||||
oldSub := &user.Subscribe{
|
||||
Id: oldTokens[i].Id,
|
||||
UserId: oldTokens[i].UserId,
|
||||
Token: oldTokens[i].Token,
|
||||
}
|
||||
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, oldSub); clearErr != nil {
|
||||
logger.Errorf("[ResetAllSubscribeToken] Failed to clear old cache for subscribe ID %d: %v", sub.Id, clearErr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &types.ResetAllSubscribeTokenResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
|
||||
@@ -56,6 +56,7 @@ func (l *UpdateSubscribeLogic) UpdateSubscribe(req *types.UpdateSubscribeRequest
|
||||
SpeedLimit: req.SpeedLimit,
|
||||
DeviceLimit: req.DeviceLimit,
|
||||
Quota: req.Quota,
|
||||
NewUserOnly: req.NewUserOnly,
|
||||
Nodes: tool.Int64SliceToString(req.Nodes),
|
||||
NodeTags: tool.StringSliceToString(req.NodeTags),
|
||||
Show: req.Show,
|
||||
|
||||
@@ -64,11 +64,18 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
||||
l.Errorw("ClearSubscribeCache failed:", logger.Field("error", err.Error()), logger.Field("userSubscribeId", userSub.Id))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "ClearSubscribeCache failed: %v", err.Error())
|
||||
}
|
||||
// Clear subscribe cache
|
||||
// Clear old subscribe plan cache
|
||||
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, userSub.SubscribeId); err != nil {
|
||||
l.Errorw("failed to clear subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId))
|
||||
l.Errorw("failed to clear old subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", userSub.SubscribeId))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear subscribe cache: %v", err.Error())
|
||||
}
|
||||
// Clear new subscribe plan cache if plan changed
|
||||
if req.SubscribeId != userSub.SubscribeId {
|
||||
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, req.SubscribeId); err != nil {
|
||||
l.Errorw("failed to clear new subscribe cache", logger.Field("error", err.Error()), logger.Field("subscribeId", req.SubscribeId))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "failed to clear new subscribe cache: %v", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
|
||||
l.Errorf("ClearServerAllCache error: %v", err.Error())
|
||||
|
||||
@@ -171,6 +171,17 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string,
|
||||
logger.Field("user_id", userId),
|
||||
)
|
||||
|
||||
// Clear user cache to reflect new device
|
||||
userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, userId)
|
||||
if findErr == nil {
|
||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
|
||||
l.Errorw("failed to clear user cache after device creation",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("error", clearErr.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -208,7 +219,7 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
}
|
||||
|
||||
var users []*user.User
|
||||
err := l.svcCtx.DB.Where("id in (?)", []int64{oldUserId, newUserId}).Find(&users).Error
|
||||
err := l.svcCtx.DB.Where("id in (?)", []int64{oldUserId, newUserId}).Preload("AuthMethods").Find(&users).Error
|
||||
if err != nil {
|
||||
l.Errorw("failed to query users for rebinding",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
|
||||
@@ -47,31 +47,29 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
|
||||
// Verify Code
|
||||
if req.Code != "202511" {
|
||||
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
|
||||
// Check User
|
||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
||||
loginStatus := false
|
||||
|
||||
defer func() {
|
||||
if userInfo.Id != 0 && loginStatus {
|
||||
if userInfo != nil && userInfo.Id != 0 && loginStatus {
|
||||
loginLog := log.Login{
|
||||
Method: "email",
|
||||
LoginIP: req.IP,
|
||||
|
||||
@@ -42,7 +42,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
var userInfo *user.User
|
||||
// Record login status
|
||||
defer func(svcCtx *svc.ServiceContext) {
|
||||
if userInfo.Id != 0 {
|
||||
if userInfo != nil && userInfo.Id != 0 {
|
||||
loginLog := log.Login{
|
||||
Method: "email",
|
||||
LoginIP: req.IP,
|
||||
@@ -67,19 +67,18 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
}(l.svcCtx)
|
||||
|
||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
|
||||
if userInfo.DeletedAt.Valid {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.As(err, &gorm.ErrRecordNotFound) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email not exist: %v", req.Email)
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||
}
|
||||
|
||||
if userInfo.DeletedAt.Valid {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/apiversion"
|
||||
"github.com/perfect-panel/server/pkg/authmethod"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckVerificationCodeCanonicalConsume(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
code := "123456"
|
||||
scene := constant.Register.String()
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
|
||||
|
||||
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
|
||||
req := &types.CheckVerificationCodeRequest{
|
||||
Method: authmethod.Email,
|
||||
Account: email,
|
||||
Code: code,
|
||||
Type: uint8(constant.Register),
|
||||
}
|
||||
|
||||
resp, err := logic.CheckVerificationCode(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Status)
|
||||
assert.True(t, resp.Exist)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), exists)
|
||||
|
||||
resp, err = logic.CheckVerificationCode(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.False(t, resp.Status)
|
||||
assert.False(t, resp.Exist)
|
||||
}
|
||||
|
||||
func TestCheckVerificationCodeLegacyNoConsumeAndType3Mapping(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
email := "legacy@example.com"
|
||||
code := "654321"
|
||||
scene := constant.Security.String()
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
|
||||
|
||||
legacyReq := &types.LegacyCheckVerificationCodeRequest{
|
||||
Email: email,
|
||||
Code: code,
|
||||
Type: 3,
|
||||
}
|
||||
|
||||
normalizedReq, type3Mapped, err := NormalizeLegacyCheckVerificationCodeRequest(legacyReq)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, type3Mapped)
|
||||
assert.Equal(t, uint8(constant.Security), normalizedReq.Type)
|
||||
assert.Equal(t, authmethod.Email, normalizedReq.Method)
|
||||
assert.Equal(t, email, normalizedReq.Account)
|
||||
|
||||
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
|
||||
legacyBehavior := VerifyCodeCheckBehavior{
|
||||
Source: "legacy",
|
||||
Consume: false,
|
||||
LegacyType3Mapped: true,
|
||||
AllowSceneFallback: true,
|
||||
}
|
||||
|
||||
resp, err := logic.CheckVerificationCodeWithBehavior(normalizedReq, legacyBehavior)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Status)
|
||||
assert.True(t, resp.Exist)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
|
||||
resp, err = logic.CheckVerificationCodeWithBehavior(normalizedReq, legacyBehavior)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Status)
|
||||
|
||||
resp, err = logic.CheckVerificationCode(normalizedReq)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Status)
|
||||
|
||||
exists, err = redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), exists)
|
||||
}
|
||||
|
||||
func TestCheckVerificationCodeLegacySceneFallback(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
email := "fallback@example.com"
|
||||
code := "778899"
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
|
||||
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
|
||||
|
||||
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
|
||||
req := &types.CheckVerificationCodeRequest{
|
||||
Method: authmethod.Email,
|
||||
Account: email,
|
||||
Code: code,
|
||||
Type: uint8(constant.Security),
|
||||
}
|
||||
|
||||
resp, err := logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
|
||||
Source: "legacy",
|
||||
Consume: false,
|
||||
AllowSceneFallback: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Status)
|
||||
|
||||
resp, err = logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
|
||||
Source: "legacy",
|
||||
Consume: false,
|
||||
AllowSceneFallback: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.False(t, resp.Status)
|
||||
}
|
||||
|
||||
func setEmailCodePayload(t *testing.T, redisClient *redis.Client, cacheKey string, code string, lastAt int64) {
|
||||
t.Helper()
|
||||
|
||||
payload := CacheKeyPayload{
|
||||
Code: code,
|
||||
LastAt: lastAt,
|
||||
}
|
||||
value, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
err = redisClient.Set(context.Background(), cacheKey, value, time.Minute*15).Err()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCheckVerificationCodeWithApiHeaderGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectConsume bool
|
||||
}{
|
||||
{name: "missing header", header: "", expectConsume: false},
|
||||
{name: "invalid header", header: "invalid", expectConsume: false},
|
||||
{name: "equal threshold", header: "1.0.0", expectConsume: false},
|
||||
{name: "greater threshold", header: "1.0.1", expectConsume: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
t.Cleanup(func() {
|
||||
redisClient.Close()
|
||||
miniRedis.Close()
|
||||
})
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
Redis: redisClient,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{
|
||||
VerifyCodeExpireTime: 900,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
email := "gate@example.com"
|
||||
code := "101010"
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
|
||||
setEmailCodePayload(t, redisClient, cacheKey, code, time.Now().Unix())
|
||||
|
||||
logic := NewCheckVerificationCodeLogic(context.Background(), svcCtx)
|
||||
req := &types.CheckVerificationCodeRequest{
|
||||
Method: authmethod.Email,
|
||||
Account: email,
|
||||
Code: code,
|
||||
Type: uint8(constant.Register),
|
||||
}
|
||||
|
||||
resp, err := logic.CheckVerificationCodeWithBehavior(req, VerifyCodeCheckBehavior{
|
||||
Source: "canonical",
|
||||
Consume: apiversion.UseLatest(tt.header, apiversion.DefaultThreshold),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.True(t, resp.Status)
|
||||
|
||||
exists, err := redisClient.Exists(context.Background(), cacheKey).Result()
|
||||
require.NoError(t, err)
|
||||
if tt.expectConsume {
|
||||
assert.Equal(t, int64(0), exists)
|
||||
} else {
|
||||
assert.Equal(t, int64(1), exists)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func extractFamilyEntitlementCode(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var codeErr *xerr.CodeError
|
||||
if stderrors.As(pkgerrors.Cause(err), &codeErr) {
|
||||
return codeErr.GetErrCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestBuildEntitlementContext(t *testing.T) {
|
||||
t.Run("default self entitlement", func(t *testing.T) {
|
||||
entitlement := buildEntitlementContext(1001, nil)
|
||||
require.Equal(t, int64(1001), entitlement.EffectiveUserID)
|
||||
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
|
||||
require.Equal(t, int64(0), entitlement.OwnerUserID)
|
||||
require.False(t, entitlement.ReadOnly)
|
||||
})
|
||||
|
||||
t.Run("active family member uses owner entitlement", func(t *testing.T) {
|
||||
entitlement := buildEntitlementContext(1001, &familyEntitlementRelation{
|
||||
Role: modelUser.FamilyRoleMember,
|
||||
FamilyStatus: modelUser.FamilyStatusActive,
|
||||
OwnerUserID: 2001,
|
||||
})
|
||||
require.Equal(t, int64(2001), entitlement.EffectiveUserID)
|
||||
require.Equal(t, EntitlementSourceFamilyOwner, entitlement.Source)
|
||||
require.Equal(t, int64(2001), entitlement.OwnerUserID)
|
||||
require.True(t, entitlement.ReadOnly)
|
||||
})
|
||||
|
||||
t.Run("owner relation keeps self entitlement", func(t *testing.T) {
|
||||
entitlement := buildEntitlementContext(2001, &familyEntitlementRelation{
|
||||
Role: modelUser.FamilyRoleOwner,
|
||||
FamilyStatus: modelUser.FamilyStatusActive,
|
||||
OwnerUserID: 2001,
|
||||
})
|
||||
require.Equal(t, int64(2001), entitlement.EffectiveUserID)
|
||||
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
|
||||
require.False(t, entitlement.ReadOnly)
|
||||
})
|
||||
|
||||
t.Run("disabled family keeps self entitlement", func(t *testing.T) {
|
||||
entitlement := buildEntitlementContext(1001, &familyEntitlementRelation{
|
||||
Role: modelUser.FamilyRoleMember,
|
||||
FamilyStatus: 0,
|
||||
OwnerUserID: 2001,
|
||||
})
|
||||
require.Equal(t, int64(1001), entitlement.EffectiveUserID)
|
||||
require.Equal(t, EntitlementSourceSelf, entitlement.Source)
|
||||
require.False(t, entitlement.ReadOnly)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDenyReadonlyEntitlement(t *testing.T) {
|
||||
require.NoError(t, denyReadonlyEntitlement(&EntitlementContext{ReadOnly: false}))
|
||||
|
||||
err := denyReadonlyEntitlement(&EntitlementContext{
|
||||
Source: EntitlementSourceFamilyOwner,
|
||||
ReadOnly: true,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, xerr.FamilyOwnerOperationForbidden, extractFamilyEntitlementCode(err))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func buildInviteResolverForTest(t *testing.T, cfg config.Config) (*InviteLinkResolver, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
redisServer, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
redisServer.Close()
|
||||
})
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisServer.Addr(),
|
||||
DB: 0,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = redisClient.Close()
|
||||
})
|
||||
|
||||
serviceCtx := &svc.ServiceContext{
|
||||
Config: cfg,
|
||||
Redis: redisClient,
|
||||
}
|
||||
|
||||
resolver := NewInviteLinkResolver(context.Background(), serviceCtx)
|
||||
return resolver, redisServer
|
||||
}
|
||||
|
||||
func TestInviteLinkResolverResolveInviteLink(t *testing.T) {
|
||||
t.Run("kutt disabled returns long link", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.TargetURL = "https://example.com/register"
|
||||
|
||||
resolver, _ := buildInviteResolverForTest(t, cfg)
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
require.Equal(t, "https://example.com/register?ic=abc123", link)
|
||||
})
|
||||
|
||||
t.Run("cache hit returns cached short link", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.Enable = true
|
||||
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
|
||||
cfg.Kutt.ApiKey = "token"
|
||||
cfg.Kutt.TargetURL = "https://example.com/register"
|
||||
|
||||
resolver, redisServer := buildInviteResolverForTest(t, cfg)
|
||||
redisServer.Set(inviteShortLinkCachePrefix+"abc123", "https://sho.rt/cached")
|
||||
|
||||
called := 0
|
||||
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
|
||||
called++
|
||||
return "", errors.New("should not call createShortLink on cache hit")
|
||||
}
|
||||
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
require.Equal(t, "https://sho.rt/cached", link)
|
||||
require.Equal(t, 0, called)
|
||||
})
|
||||
|
||||
t.Run("cache miss kutt success returns short link and writes cache", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.Enable = true
|
||||
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
|
||||
cfg.Kutt.ApiKey = "token"
|
||||
cfg.Kutt.TargetURL = "https://example.com/register"
|
||||
|
||||
resolver, _ := buildInviteResolverForTest(t, cfg)
|
||||
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
|
||||
return "https://sho.rt/new", nil
|
||||
}
|
||||
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
require.Equal(t, "https://sho.rt/new", link)
|
||||
|
||||
cached := resolver.getCachedShortLink("abc123")
|
||||
require.Equal(t, "https://sho.rt/new", cached)
|
||||
})
|
||||
|
||||
t.Run("kutt failure falls back to long link", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.Enable = true
|
||||
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
|
||||
cfg.Kutt.ApiKey = "token"
|
||||
cfg.Kutt.TargetURL = "https://example.com/register"
|
||||
|
||||
resolver, _ := buildInviteResolverForTest(t, cfg)
|
||||
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
|
||||
return "", errors.New("kutt request failed")
|
||||
}
|
||||
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
require.Equal(t, "https://example.com/register?ic=abc123", link)
|
||||
})
|
||||
|
||||
t.Run("long link preserves existing query string", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.TargetURL = "https://example.com/register?channel=ios"
|
||||
|
||||
resolver, _ := buildInviteResolverForTest(t, cfg)
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
parsed, err := url.Parse(link)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https", parsed.Scheme)
|
||||
require.Equal(t, "example.com", parsed.Host)
|
||||
require.Equal(t, "/register", parsed.Path)
|
||||
require.Equal(t, "ios", parsed.Query().Get("channel"))
|
||||
require.Equal(t, "abc123", parsed.Query().Get("ic"))
|
||||
})
|
||||
|
||||
t.Run("kutt target preserves existing query string", func(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Kutt.Enable = true
|
||||
cfg.Kutt.ApiURL = "https://kutt.local/api/v2"
|
||||
cfg.Kutt.ApiKey = "token"
|
||||
cfg.Kutt.TargetURL = "https://example.com/register?channel=ios"
|
||||
|
||||
resolver, _ := buildInviteResolverForTest(t, cfg)
|
||||
capturedTargetURL := ""
|
||||
resolver.createShortLink = func(ctx context.Context, targetURL, domain string) (string, error) {
|
||||
capturedTargetURL = targetURL
|
||||
return "https://sho.rt/query", nil
|
||||
}
|
||||
|
||||
link := resolver.ResolveInviteLink("abc123")
|
||||
require.Equal(t, "https://sho.rt/query", link)
|
||||
|
||||
parsed, err := url.Parse(capturedTargetURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ios", parsed.Query().Get("channel"))
|
||||
require.Equal(t, "abc123", parsed.Query().Get("ic"))
|
||||
})
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolvePurchaseRoute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("single mode disabled", func(t *testing.T) {
|
||||
called := false
|
||||
decision, err := ResolvePurchaseRoute(ctx, false, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision)
|
||||
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
|
||||
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
|
||||
require.False(t, called)
|
||||
})
|
||||
|
||||
t.Run("single mode but empty user", func(t *testing.T) {
|
||||
decision, err := ResolvePurchaseRoute(ctx, true, 0, 100, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision)
|
||||
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
|
||||
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
|
||||
})
|
||||
|
||||
t.Run("single mode no anchor", func(t *testing.T) {
|
||||
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision)
|
||||
require.Equal(t, PurchaseRouteNewPurchase, decision.Route)
|
||||
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
|
||||
})
|
||||
|
||||
t.Run("single mode routed to renewal", func(t *testing.T) {
|
||||
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
|
||||
return &user.Subscribe{
|
||||
Id: 11,
|
||||
SubscribeId: 100,
|
||||
OrderId: 7,
|
||||
Token: "token",
|
||||
}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decision)
|
||||
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
|
||||
require.Equal(t, int64(100), decision.ResolvedSubscribeID)
|
||||
require.NotNil(t, decision.Anchor)
|
||||
require.Equal(t, int64(11), decision.Anchor.Id)
|
||||
})
|
||||
|
||||
t.Run("single mode plan mismatch", func(t *testing.T) {
|
||||
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
|
||||
return &user.Subscribe{
|
||||
Id: 11,
|
||||
SubscribeId: 200,
|
||||
}, nil
|
||||
})
|
||||
require.ErrorIs(t, err, ErrSingleModePlanMismatch)
|
||||
require.Nil(t, decision)
|
||||
})
|
||||
|
||||
t.Run("single mode anchor query error", func(t *testing.T) {
|
||||
queryErr := errors.New("query failed")
|
||||
decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) {
|
||||
return nil, queryErr
|
||||
})
|
||||
require.ErrorIs(t, err, queryErr)
|
||||
require.Nil(t, decision)
|
||||
})
|
||||
}
|
||||
@@ -131,9 +131,8 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||
)
|
||||
return err
|
||||
}
|
||||
// update user cache
|
||||
return l.svcCtx.UserModel.UpdateUserCache(l.ctx, userInfo)
|
||||
}
|
||||
// Note: user cache will be updated after transaction commits
|
||||
if sub.Inventory != -1 {
|
||||
sub.Inventory++
|
||||
if e := l.svcCtx.SubscribeModel.Update(l.ctx, sub, tx); e != nil {
|
||||
@@ -151,6 +150,19 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||
logger.Errorf("[CloseOrder] Transaction failed: %v", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Update user cache after transaction commits successfully
|
||||
if orderInfo.GiftAmount > 0 && orderInfo.UserId != 0 {
|
||||
if userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId); findErr == nil {
|
||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
|
||||
l.Errorw("[CloseOrder] failed to clear user cache",
|
||||
logger.Field("error", clearErr.Error()),
|
||||
logger.Field("user_id", orderInfo.UserId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
@@ -108,6 +109,23 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
||||
}
|
||||
}
|
||||
|
||||
// check new user only restriction
|
||||
if !isSingleModeRenewal && sub.NewUserOnly != nil && *sub.NewUserOnly {
|
||||
if time.Since(u.CreatedAt) > 24*time.Hour {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
var historyCount int64
|
||||
if e := l.svcCtx.DB.Model(&order.Order{}).
|
||||
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
|
||||
u.Id, targetSubscribeID, []uint8{2, 5}).
|
||||
Count(&historyCount).Error; e != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user purchase history error: %v", e.Error())
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "already purchased new user plan")
|
||||
}
|
||||
}
|
||||
|
||||
var discount float64 = 1
|
||||
if sub.Discount != "" {
|
||||
var dis []types.SubscribeDiscount
|
||||
|
||||
@@ -270,6 +270,23 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
}
|
||||
|
||||
// check new user only restriction inside transaction to prevent race condition
|
||||
if orderInfo.Type == 1 && sub.NewUserOnly != nil && *sub.NewUserOnly {
|
||||
if time.Since(u.CreatedAt) > 24*time.Hour {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
var historyCount int64
|
||||
if e := db.Model(&order.Order{}).
|
||||
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
|
||||
u.Id, targetSubscribeID, []int{2, 5}).
|
||||
Count(&historyCount).Error; e != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user purchase history error: %v", e.Error())
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "already purchased new user plan")
|
||||
}
|
||||
}
|
||||
|
||||
// update user gift amount and create deduction record
|
||||
if orderInfo.GiftAmount > 0 {
|
||||
// deduct gift amount from user
|
||||
@@ -319,7 +336,11 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("[Purchase] Database insert error", logger.Field("error", err.Error()), logger.Field("orderInfo", orderInfo))
|
||||
|
||||
// Propagate business errors (e.g. SubscribeNewUserOnly, SubscribeQuotaLimit) directly.
|
||||
var codeErr *xerr.CodeError
|
||||
if errors.As(err, &codeErr) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
|
||||
}
|
||||
// Deferred task
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package subscribe
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFillUserSubscribeInfoEntitlementFields(t *testing.T) {
|
||||
sub := &types.UserSubscribeInfo{}
|
||||
entitlement := &commonLogic.EntitlementContext{
|
||||
EffectiveUserID: 3001,
|
||||
Source: commonLogic.EntitlementSourceFamilyOwner,
|
||||
OwnerUserID: 3001,
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
fillUserSubscribeInfoEntitlementFields(sub, entitlement)
|
||||
|
||||
require.Equal(t, commonLogic.EntitlementSourceFamilyOwner, sub.EntitlementSource)
|
||||
require.Equal(t, int64(3001), sub.EntitlementOwnerUserId)
|
||||
require.True(t, sub.ReadOnly)
|
||||
}
|
||||
|
||||
func TestNormalizeSubscribeNodeTags(t *testing.T) {
|
||||
tags := normalizeSubscribeNodeTags("美国, 日本, , 美国, ,日本")
|
||||
require.Equal(t, []string{"美国", "日本"}, tags)
|
||||
|
||||
empty := normalizeSubscribeNodeTags("")
|
||||
require.Nil(t, empty)
|
||||
}
|
||||
@@ -45,6 +45,9 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
|
||||
DeviceUserID: deviceUserID,
|
||||
}
|
||||
|
||||
// Capture device user's auth methods BEFORE the transaction migrates them
|
||||
deviceAuthMethods, _ := h.svcCtx.UserModel.FindUserAuthMethods(h.ctx, deviceUserID)
|
||||
|
||||
err := h.svcCtx.DB.WithContext(h.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var owner modelUser.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -114,7 +117,7 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := h.clearCaches(result); err != nil {
|
||||
if err := h.clearCaches(result, deviceAuthMethods); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,16 +132,32 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *accountMergeHelper) clearCaches(result *accountMergeResult) error {
|
||||
func (h *accountMergeHelper) clearCaches(result *accountMergeResult, deviceAuthMethods []*modelUser.AuthMethods) error {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := h.svcCtx.UserModel.ClearUserCache(h.ctx,
|
||||
&modelUser.User{Id: result.OwnerUserID},
|
||||
&modelUser.User{Id: result.DeviceUserID},
|
||||
); err != nil {
|
||||
return err
|
||||
// Fetch owner user with AuthMethods for proper cache key generation
|
||||
var users []*modelUser.User
|
||||
if u, err := h.svcCtx.UserModel.FindOne(h.ctx, result.OwnerUserID); err == nil {
|
||||
users = append(users, u)
|
||||
}
|
||||
// For device user, FindOne won't have AuthMethods anymore (migrated in tx),
|
||||
// so we build a minimal User with the pre-captured auth methods
|
||||
deviceUser := &modelUser.User{Id: result.DeviceUserID}
|
||||
if len(deviceAuthMethods) > 0 {
|
||||
authMethods := make([]modelUser.AuthMethods, len(deviceAuthMethods))
|
||||
for i, am := range deviceAuthMethods {
|
||||
authMethods[i] = *am
|
||||
}
|
||||
deviceUser.AuthMethods = authMethods
|
||||
}
|
||||
users = append(users, deviceUser)
|
||||
|
||||
if len(users) > 0 {
|
||||
if err := h.svcCtx.UserModel.ClearUserCache(h.ctx, users...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.MovedDevices) > 0 {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestClearAllSessions_RemovesUserSessionsAndDeviceMappings(t *testing.T) {
|
||||
logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t)
|
||||
defer cleanup()
|
||||
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-user-1", "1001")
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-user-2", "1001")
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-other", "2002")
|
||||
|
||||
mustRedisSet(t, redisClient, "auth:session_id:detail:sid-user-1", "detail")
|
||||
mustRedisSet(t, redisClient, "auth:session_id:detail:sid-other", "detail")
|
||||
|
||||
mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-1", "sid-user-1")
|
||||
mustRedisSet(t, redisClient, "auth:device_identifier:dev-user-2", "sid-user-2")
|
||||
mustRedisSet(t, redisClient, "auth:device_identifier:dev-other", "sid-other")
|
||||
|
||||
mustRedisZAdd(t, redisClient, "auth:user_sessions:1001", "sid-user-3", 1)
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-user-3", "1001")
|
||||
|
||||
logic.clearAllSessions(1001)
|
||||
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-1")
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-2")
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:sid-user-3")
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:detail:sid-user-1")
|
||||
mustRedisNotExist(t, redisClient, "auth:user_sessions:1001")
|
||||
mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-1")
|
||||
mustRedisNotExist(t, redisClient, "auth:device_identifier:dev-user-2")
|
||||
|
||||
mustRedisExist(t, redisClient, "auth:session_id:sid-other")
|
||||
mustRedisExist(t, redisClient, "auth:session_id:detail:sid-other")
|
||||
mustRedisExist(t, redisClient, "auth:device_identifier:dev-other")
|
||||
}
|
||||
|
||||
func TestClearAllSessions_ScanFallbackWorksWithoutUserSessionIndex(t *testing.T) {
|
||||
logic, redisClient, cleanup := newDeleteAccountRedisTestLogic(t)
|
||||
defer cleanup()
|
||||
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-a", "3003")
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-b", "3003")
|
||||
mustRedisSet(t, redisClient, "auth:session_id:sid-c", "4004")
|
||||
|
||||
logic.clearAllSessions(3003)
|
||||
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:sid-a")
|
||||
mustRedisNotExist(t, redisClient, "auth:session_id:sid-b")
|
||||
mustRedisExist(t, redisClient, "auth:session_id:sid-c")
|
||||
}
|
||||
|
||||
func newDeleteAccountRedisTestLogic(t *testing.T) (*DeleteAccountLogic, *redis.Client, func()) {
|
||||
t.Helper()
|
||||
|
||||
miniRedis := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
logic := NewDeleteAccountLogic(context.Background(), &svc.ServiceContext{Redis: redisClient})
|
||||
|
||||
cleanup := func() {
|
||||
_ = redisClient.Close()
|
||||
miniRedis.Close()
|
||||
}
|
||||
return logic, redisClient, cleanup
|
||||
}
|
||||
|
||||
func mustRedisSet(t *testing.T, redisClient *redis.Client, key, value string) {
|
||||
t.Helper()
|
||||
if err := redisClient.Set(context.Background(), key, value, time.Hour).Err(); err != nil {
|
||||
t.Fatalf("redis set %s failed: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRedisZAdd(t *testing.T, redisClient *redis.Client, key, member string, score float64) {
|
||||
t.Helper()
|
||||
if err := redisClient.ZAdd(context.Background(), key, redis.Z{Member: member, Score: score}).Err(); err != nil {
|
||||
t.Fatalf("redis zadd %s failed: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRedisExist(t *testing.T, redisClient *redis.Client, key string) {
|
||||
t.Helper()
|
||||
exists, err := redisClient.Exists(context.Background(), key).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("redis exists %s failed: %v", key, err)
|
||||
}
|
||||
if exists == 0 {
|
||||
t.Fatalf("expected redis key %s to exist", key)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRedisNotExist(t *testing.T, redisClient *redis.Client, key string) {
|
||||
t.Helper()
|
||||
exists, err := redisClient.Exists(context.Background(), key).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("redis exists %s failed: %v", key, err)
|
||||
}
|
||||
if exists != 0 {
|
||||
t.Fatalf("expected redis key %s to be deleted", key)
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func extractFamilyJoinCode(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var codeErr *xerr.CodeError
|
||||
if stderrors.As(pkgerrors.Cause(err), &codeErr) {
|
||||
return codeErr.GetErrCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestValidateMemberJoinConflict(t *testing.T) {
|
||||
ownerFamilyID := int64(11)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ownerFamily int64
|
||||
memberRecord *modelUser.UserFamilyMember
|
||||
wantCode uint32
|
||||
}{
|
||||
{
|
||||
name: "no member record",
|
||||
ownerFamily: ownerFamilyID,
|
||||
wantCode: 0,
|
||||
},
|
||||
{
|
||||
name: "same family active member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID,
|
||||
Status: modelUser.FamilyMemberActive,
|
||||
},
|
||||
wantCode: xerr.FamilyAlreadyBound,
|
||||
},
|
||||
{
|
||||
name: "same family left member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID,
|
||||
Status: modelUser.FamilyMemberLeft,
|
||||
},
|
||||
wantCode: 0,
|
||||
},
|
||||
{
|
||||
name: "same family removed member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID,
|
||||
Status: modelUser.FamilyMemberRemoved,
|
||||
},
|
||||
wantCode: 0,
|
||||
},
|
||||
{
|
||||
name: "cross family active member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID + 1,
|
||||
Status: modelUser.FamilyMemberActive,
|
||||
},
|
||||
wantCode: xerr.FamilyCrossBindForbidden,
|
||||
},
|
||||
{
|
||||
name: "cross family left member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID + 1,
|
||||
Status: modelUser.FamilyMemberLeft,
|
||||
},
|
||||
wantCode: 0,
|
||||
},
|
||||
{
|
||||
name: "cross family removed member",
|
||||
ownerFamily: ownerFamilyID,
|
||||
memberRecord: &modelUser.UserFamilyMember{
|
||||
FamilyId: ownerFamilyID + 1,
|
||||
Status: modelUser.FamilyMemberRemoved,
|
||||
},
|
||||
wantCode: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := validateMemberJoinConflict(testCase.ownerFamily, testCase.memberRecord)
|
||||
if testCase.wantCode == 0 {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, testCase.wantCode, extractFamilyJoinCode(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemovedSubscribeCacheMeta(t *testing.T) {
|
||||
removed := []modelUser.Subscribe{
|
||||
{Id: 1, SubscribeId: 10, Token: "member-token-1"},
|
||||
{Id: 2, SubscribeId: 11, Token: "member-token-2"},
|
||||
{Id: 3, SubscribeId: 0, Token: "member-token-3"},
|
||||
}
|
||||
|
||||
models, subscribeIDSet := buildRemovedSubscribeCacheMeta(removed)
|
||||
|
||||
require.Len(t, models, 3)
|
||||
require.Equal(t, int64(1), models[0].Id)
|
||||
require.Equal(t, "member-token-2", models[1].Token)
|
||||
require.Len(t, subscribeIDSet, 2)
|
||||
_, has10 := subscribeIDSet[10]
|
||||
_, has11 := subscribeIDSet[11]
|
||||
_, has0 := subscribeIDSet[0]
|
||||
require.True(t, has10)
|
||||
require.True(t, has11)
|
||||
require.False(t, has0)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAppendFamilyOwnerEmailIfNeeded(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
methods []types.UserAuthMethod
|
||||
familyJoined bool
|
||||
ownerEmailMethod *modelUser.AuthMethods
|
||||
wantMethodCount int
|
||||
wantEmailCount int
|
||||
wantFirstAuthType string
|
||||
wantFirstAuthValue string
|
||||
}{
|
||||
{
|
||||
name: "inject owner email when member has no email",
|
||||
methods: []types.UserAuthMethod{
|
||||
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
|
||||
},
|
||||
familyJoined: true,
|
||||
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
|
||||
wantMethodCount: 2,
|
||||
wantEmailCount: 1,
|
||||
wantFirstAuthType: "email",
|
||||
wantFirstAuthValue: "owner@example.com",
|
||||
},
|
||||
{
|
||||
name: "do not inject when member already has email",
|
||||
methods: []types.UserAuthMethod{
|
||||
{AuthType: "email", AuthIdentifier: "member@example.com", Verified: true},
|
||||
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
|
||||
},
|
||||
familyJoined: true,
|
||||
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
|
||||
wantMethodCount: 2,
|
||||
wantEmailCount: 1,
|
||||
wantFirstAuthType: "email",
|
||||
wantFirstAuthValue: "member@example.com",
|
||||
},
|
||||
{
|
||||
name: "do not inject when owner has no email",
|
||||
methods: []types.UserAuthMethod{
|
||||
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
|
||||
},
|
||||
familyJoined: true,
|
||||
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "", Verified: true},
|
||||
wantMethodCount: 1,
|
||||
wantEmailCount: 0,
|
||||
wantFirstAuthType: "device",
|
||||
},
|
||||
{
|
||||
name: "do not inject for non active family relationship",
|
||||
methods: []types.UserAuthMethod{
|
||||
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
|
||||
},
|
||||
familyJoined: false,
|
||||
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
|
||||
wantMethodCount: 1,
|
||||
wantEmailCount: 0,
|
||||
wantFirstAuthType: "device",
|
||||
},
|
||||
{
|
||||
name: "sort keeps injected email at first position",
|
||||
methods: []types.UserAuthMethod{
|
||||
{AuthType: "mobile", AuthIdentifier: "+1234567890", Verified: true},
|
||||
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
|
||||
},
|
||||
familyJoined: true,
|
||||
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
|
||||
wantMethodCount: 3,
|
||||
wantEmailCount: 1,
|
||||
wantFirstAuthType: "email",
|
||||
wantFirstAuthValue: "owner@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
finalMethods := appendFamilyOwnerEmailIfNeeded(testCase.methods, testCase.familyJoined, testCase.ownerEmailMethod)
|
||||
sortUserAuthMethodsByPriority(finalMethods)
|
||||
|
||||
require.Len(t, finalMethods, testCase.wantMethodCount)
|
||||
|
||||
emailCount := 0
|
||||
for _, method := range finalMethods {
|
||||
if method.AuthType == "email" {
|
||||
emailCount++
|
||||
}
|
||||
}
|
||||
require.Equal(t, testCase.wantEmailCount, emailCount)
|
||||
|
||||
require.Equal(t, testCase.wantFirstAuthType, finalMethods[0].AuthType)
|
||||
if testCase.wantFirstAuthValue != "" {
|
||||
require.Equal(t, testCase.wantFirstAuthValue, finalMethods[0].AuthIdentifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFillUserSubscribeEntitlementFields(t *testing.T) {
|
||||
sub := &types.UserSubscribe{}
|
||||
entitlement := &commonLogic.EntitlementContext{
|
||||
EffectiveUserID: 2001,
|
||||
Source: commonLogic.EntitlementSourceFamilyOwner,
|
||||
OwnerUserID: 2001,
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
fillUserSubscribeEntitlementFields(sub, entitlement)
|
||||
|
||||
require.Equal(t, commonLogic.EntitlementSourceFamilyOwner, sub.EntitlementSource)
|
||||
require.Equal(t, int64(2001), sub.EntitlementOwnerUserId)
|
||||
require.True(t, sub.ReadOnly)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func (l *UnsubscribeLogic) Unsubscribe(req *types.UnsubscribeRequest) error {
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
|
||||
// Find and update subscription status to cancelled (status = 4)
|
||||
userSub.Status = 4 // Set status to cancelled
|
||||
if err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub); err != nil {
|
||||
if err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (l *UnsubscribeLogic) Unsubscribe(req *types.UnsubscribeRequest) error {
|
||||
|
||||
// Update user's regular balance and save changes to database
|
||||
u.Balance = balance
|
||||
return l.svcCtx.UserModel.Update(l.ctx, u)
|
||||
return l.svcCtx.UserModel.Update(l.ctx, u, db)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
func TestApiVersionSwitchHandlerUsesLegacyByDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/test", ApiVersionSwitchHandler(
|
||||
func(c *gin.Context) { c.String(http.StatusOK, "legacy") },
|
||||
func(c *gin.Context) { c.String(http.StatusOK, "latest") },
|
||||
))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "legacy" {
|
||||
t.Fatalf("expected legacy handler, code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiVersionSwitchHandlerUsesLatestWhenFlagSet(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyAPIVersionUseLatest, true)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/test", ApiVersionSwitchHandler(
|
||||
func(c *gin.Context) { c.String(http.StatusOK, "legacy") },
|
||||
func(c *gin.Context) { c.String(http.StatusOK, "latest") },
|
||||
))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "latest" {
|
||||
t.Fatalf("expected latest handler, code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseLoginType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
claims map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefer CtxLoginType when both exist",
|
||||
claims: map[string]interface{}{"CtxLoginType": "device", "LoginType": "email"},
|
||||
want: "device",
|
||||
},
|
||||
{
|
||||
name: "fallback to legacy LoginType",
|
||||
claims: map[string]interface{}{"LoginType": "device"},
|
||||
want: "device",
|
||||
},
|
||||
{
|
||||
name: "ignore non-string values",
|
||||
claims: map[string]interface{}{"CtxLoginType": 123, "LoginType": true},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty values return empty",
|
||||
claims: map[string]interface{}{"CtxLoginType": "", "LoginType": ""},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "missing values return empty",
|
||||
claims: map[string]interface{}{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := parseLoginType(testCase.claims)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("parseLoginType() = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/signature"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
)
|
||||
|
||||
type testNonceStore struct {
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newTestNonceStore() *testNonceStore {
|
||||
return &testNonceStore{seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (s *testNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) {
|
||||
key := appId + ":" + nonce
|
||||
if s.seen[key] {
|
||||
return true, nil
|
||||
}
|
||||
s.seen[key] = true
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func makeTestSignature(secret, sts string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(sts))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func newTestServiceContext() *svc.ServiceContext {
|
||||
conf := config.Config{}
|
||||
conf.Signature.EnableSignature = true
|
||||
conf.AppSignature = signature.SignatureConf{
|
||||
AppSecrets: map[string]string{
|
||||
"web-client": "test-secret",
|
||||
},
|
||||
ValidWindowSeconds: 300,
|
||||
SkipPrefixes: []string{
|
||||
"/v1/public/health",
|
||||
},
|
||||
}
|
||||
return &svc.ServiceContext{
|
||||
Config: conf,
|
||||
SignatureValidator: signature.NewValidator(conf.AppSignature, newTestNonceStore()),
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServiceContextWithSwitch(enabled bool) *svc.ServiceContext {
|
||||
svcCtx := newTestServiceContext()
|
||||
svcCtx.Config.Signature.EnableSignature = enabled
|
||||
return svcCtx
|
||||
}
|
||||
|
||||
func decodeCode(t *testing.T, body []byte) uint32 {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Code uint32 `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("unmarshal response failed: %v", err)
|
||||
}
|
||||
return resp.Code
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewareMissingAppID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
|
||||
req.Header.Set("X-Signature-Enabled", "1")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if code := decodeCode(t, resp.Body.Bytes()); code != xerr.InvalidAccess {
|
||||
t.Fatalf("expected InvalidAccess(%d), got %d", xerr.InvalidAccess, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewareMissingSignatureHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
|
||||
req.Header.Set("X-Signature-Enabled", "1")
|
||||
req.Header.Set("X-App-Id", "web-client")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if code := decodeCode(t, resp.Body.Bytes()); code != xerr.SignatureMissing {
|
||||
t.Fatalf("expected SignatureMissing(%d), got %d", xerr.SignatureMissing, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewarePassesWhenSignatureHeaderMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
|
||||
t.Fatalf("expected pass-through without X-Signature-Enabled, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewarePassesWhenSignatureHeaderIsZero(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
|
||||
req.Header.Set("X-Signature-Enabled", "0")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
|
||||
t.Fatalf("expected pass-through when X-Signature-Enabled=0, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewarePassesWhenSystemSwitchDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContextWithSwitch(false)
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
|
||||
req.Header.Set("X-Signature-Enabled", "1")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
|
||||
t.Fatalf("expected pass-through when system switch is disabled, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewareSkipsNonPublicPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/admin/ping", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/admin/ping", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
|
||||
t.Fatalf("expected pass-through for non-public path, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewareHonorsSkipPrefix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.GET("/v1/public/healthz", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/healthz", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
|
||||
t.Fatalf("expected skip-prefix pass-through, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureMiddlewareRestoresBodyAfterVerify(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svcCtx := newTestServiceContext()
|
||||
r := gin.New()
|
||||
r.Use(SignatureMiddleware(svcCtx))
|
||||
r.POST("/v1/public/body", func(c *gin.Context) {
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
c.String(http.StatusOK, string(body))
|
||||
})
|
||||
|
||||
body := `{"hello":"world"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/public/body?a=1&b=2", strings.NewReader(body))
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "nonce-body-1"
|
||||
sts := signature.BuildStringToSign(http.MethodPost, "/v1/public/body", "a=1&b=2", []byte(body), "web-client", ts, nonce)
|
||||
req.Header.Set("X-Signature-Enabled", "1")
|
||||
req.Header.Set("X-App-Id", "web-client")
|
||||
req.Header.Set("X-Timestamp", ts)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Signature", makeTestSignature("test-secret", sts))
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK || resp.Body.String() != body {
|
||||
t.Fatalf("expected restored body, got code=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlibabaCloudConfig_Marshal(t *testing.T) {
|
||||
v := new(AlibabaCloudConfig)
|
||||
t.Log(v.Marshal())
|
||||
}
|
||||
|
||||
func TestAlibabaCloudConfig_Unmarshal(t *testing.T) {
|
||||
|
||||
cfg := AlibabaCloudConfig{
|
||||
Access: "AccessKeyId",
|
||||
Secret: "AccessKeySecret",
|
||||
SignName: "SignName",
|
||||
Endpoint: "Endpoint",
|
||||
TemplateCode: "VerifyTemplateCode",
|
||||
}
|
||||
data := cfg.Marshal()
|
||||
v := new(AlibabaCloudConfig)
|
||||
err := v.Unmarshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
assert.Equal(t, "AccessKeyId", v.Access)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeNodeTags(t *testing.T) {
|
||||
tags := normalizeNodeTags([]string{"美国", " 日本 ", "", "美国", " ", "日本"})
|
||||
require.Equal(t, []string{"美国", "日本"}, tags)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Subscribe struct {
|
||||
SpeedLimit int64 `gorm:"type:int;not null;default:0;comment:Speed Limit"`
|
||||
DeviceLimit int64 `gorm:"type:int;not null;default:0;comment:Device Limit"`
|
||||
Quota int64 `gorm:"type:int;not null;default:0;comment:Quota"`
|
||||
NewUserOnly *bool `gorm:"type:tinyint(1);default:0;comment:New user only: allow purchase within 24h of registration, once per user"`
|
||||
Nodes string `gorm:"type:varchar(255);comment:Node Ids"`
|
||||
NodeTags string `gorm:"type:varchar(255);comment:Node Tags"`
|
||||
Show *bool `gorm:"type:tinyint(1);not null;default:0;comment:Show portal page"`
|
||||
|
||||
@@ -246,7 +246,7 @@ func (m *customUserModel) BatchDeleteUser(ctx context.Context, ids []int64, tx .
|
||||
if len(tx) > 0 {
|
||||
conn = tx[0]
|
||||
}
|
||||
return conn.Where("id in ?", ids).Find(&users).Error
|
||||
return conn.Where("id in ?", ids).Preload("AuthMethods").Find(&users).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -88,15 +88,18 @@ func (m *defaultUserModel) FindOneSubscribe(ctx context.Context, id int64) (*Sub
|
||||
func (m *defaultUserModel) FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error) {
|
||||
var data []*Subscribe
|
||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
|
||||
err := conn.Model(&Subscribe{}).Where("subscribe_id = ? AND `status` IN ?", subscribeId, []int64{1, 0}).Find(v).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// update user subscribe status
|
||||
return conn.Model(&Subscribe{}).Where("subscribe_id = ? AND `status` = ?", subscribeId, 0).Update("status", 1).Error
|
||||
return conn.Model(&Subscribe{}).Where("subscribe_id = ? AND `status` IN ?", subscribeId, []int64{1, 0}).Find(v).Error
|
||||
})
|
||||
return data, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Activate pending subscribes (status 0 -> 1) in a separate write operation
|
||||
if err := m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
|
||||
return conn.Model(&Subscribe{}).Where("subscribe_id = ? AND `status` = ?", subscribeId, 0).Update("status", 1).Error
|
||||
}); err != nil {
|
||||
return data, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// QueryUserSubscribe returns a list of records that meet the conditions.
|
||||
@@ -136,6 +139,7 @@ func (m *defaultUserModel) QueryUserSubscribe(ctx context.Context, userId int64,
|
||||
func (m *defaultUserModel) FindOneUserSubscribe(ctx context.Context, id int64) (subscribeDetails *SubscribeDetails, err error) {
|
||||
//TODO cache
|
||||
//key := fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId)
|
||||
subscribeDetails = &SubscribeDetails{}
|
||||
err = m.QueryNoCacheCtx(ctx, subscribeDetails, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Model(&Subscribe{}).Preload("Subscribe").Where("id = ?", id).First(&subscribeDetails).Error
|
||||
})
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFreePort(t *testing.T) {
|
||||
port, err := FreePort()
|
||||
if err != nil {
|
||||
t.Fatalf("FreePort() error: %v", err)
|
||||
}
|
||||
t.Logf("FreePort: %v", port)
|
||||
}
|
||||
|
||||
func TestModulePort(t *testing.T) {
|
||||
port, err := ModulePort()
|
||||
if err != nil {
|
||||
t.Fatalf("ModulePort() error: %v", err)
|
||||
}
|
||||
t.Logf("ModulePort: %v", port)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func TestSpanIDFromContext(t *testing.T) {
|
||||
tracer := sdktrace.NewTracerProvider().Tracer("test")
|
||||
ctx, span := tracer.Start(
|
||||
context.Background(),
|
||||
"foo",
|
||||
oteltrace.WithSpanKind(oteltrace.SpanKindClient),
|
||||
oteltrace.WithAttributes(semconv.HTTPClientAttributesFromHTTPRequest(httptest.NewRequest(http.MethodGet, "/", nil))...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
assert.NotEmpty(t, TraceIDFromContext(ctx))
|
||||
assert.NotEmpty(t, SpanIDFromContext(ctx))
|
||||
}
|
||||
|
||||
func TestSpanIDFromContextEmpty(t *testing.T) {
|
||||
assert.Empty(t, TraceIDFromContext(context.Background()))
|
||||
assert.Empty(t, SpanIDFromContext(context.Background()))
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeleteAccountResponseAlwaysContainsIntFields(t *testing.T) {
|
||||
data, err := json.Marshal(DeleteAccountResponse{
|
||||
Success: true,
|
||||
Message: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal response: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err = json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
userID, hasUserID := decoded["user_id"]
|
||||
if !hasUserID {
|
||||
t.Fatalf("expected user_id in JSON, got %s", string(data))
|
||||
}
|
||||
if userID != float64(0) {
|
||||
t.Fatalf("expected user_id=0, got %v", userID)
|
||||
}
|
||||
|
||||
code, hasCode := decoded["code"]
|
||||
if !hasCode {
|
||||
t.Fatalf("expected code in JSON, got %s", string(data))
|
||||
}
|
||||
if code != float64(0) {
|
||||
t.Fatalf("expected code=0, got %v", code)
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,7 @@ type CreateSubscribeRequest struct {
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly *bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
@@ -2402,6 +2403,7 @@ type Subscribe struct {
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show bool `json:"show"`
|
||||
@@ -2805,6 +2807,7 @@ type UpdateSubscribeRequest struct {
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly *bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
|
||||
Reference in New Issue
Block a user