Files
hi-server/internal/logic/server/getServerUserListLogic_test.go
shanshanzhong147 634b5a7bd0 feat(simnet): add SimNet protocol end-to-end support
- model: SimNet protocol fields + NormalizeSimnet; type+port protocol uniqueness
- server config: OmnXT runtime config delivery via compatible() simnet case
- credentials: derive per-user psk/key_id from subscription (pkg/simnet), no new table
- subscription: adapter buildOmnxtSimnetConfigs + base64 buildOmnxtProtocolLinks + OmnXT SimNet application (migration 02161)
- UA gating: hide experimental protocols from non first-party clients (download + JSON node-list)
- admin: normalize simnet on create/update and on GET responses
- tests: 21 simnet unit tests; full suite green
2026-07-27 00:17:02 -07:00

371 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"fmt"
"net/http"
"regexp"
"slices"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/speedlimit"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// TestNormalizeServerUserListProtocol 验证客户端 hysteria2 兼容字段被映射回
// DB 规范名 "hysteria";其他协议必须原样返回,不可被误改。
// 缺这一步会导致 hysteria2 永远查不到节点、永远走兜底,HIF-1 表象。
func TestNormalizeServerUserListProtocol(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"hysteria2 maps to hysteria", "hysteria2", "hysteria"},
{"hysteria unchanged", "hysteria", "hysteria"},
{"trojan unchanged", "trojan", "trojan"},
{"vless unchanged", "vless", "vless"},
{"vmess unchanged", "vmess", "vmess"},
{"tuic unchanged", "tuic", "tuic"},
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
{"anytls unchanged", "anytls", "anytls"},
{"simnet unchanged", "simnet", "simnet"},
{"empty unchanged", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := normalizeServerUserListProtocol(c.in)
if got != c.want {
t.Errorf("normalizeServerUserListProtocol(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestServerUserListCacheKey_ProtocolIsolation 验证缓存 key 在 server_id 相同、
// protocol 不同(如 trojan vs tuic)时一定不同——这是 HIF-1 的根因防回归。
// 若 key 重叠,tuic 的兜底假用户会污染 trojan 的真实用户列表。
func TestServerUserListCacheKey_ProtocolIsolation(t *testing.T) {
const serverId int64 = 42
build := func(protocol string) string {
return fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol(protocol))
}
protocols := []string{"trojan", "vless", "vmess", "tuic", "shadowsocks", "anytls"}
seen := make(map[string]string, len(protocols))
for _, p := range protocols {
key := build(p)
if other, exists := seen[key]; exists {
t.Fatalf("cache key collision: %q (%q) == %q (%q)", p, key, other, key)
}
seen[key] = p
}
// hysteria 与 hysteria2 必须落到同一 key,否则 hysteria2 客户端拿不到 hysteria 节点列表
hysteriaKey := build("hysteria")
hysteria2Key := build("hysteria2")
if hysteriaKey != hysteria2Key {
t.Errorf("hysteria/hysteria2 expected to share cache key, got %q vs %q", hysteriaKey, hysteria2Key)
}
}
// TestServerUserListCacheKey_ShapeMatchesDelPath 验证 GetServerUserList 写入的
// cache key 形态与 ServerUserListCacheKeysForServerDel 路径)枚举出的 key
// 形态完全一致。形态一旦漂移,Del 会清不到刚写入的 key,HIF-1 重现。
func TestServerUserListCacheKey_ShapeMatchesDelPath(t *testing.T) {
const serverId int64 = 7
writeShape := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol("trojan"))
delKeys := node.ServerUserListCacheKeysForServer(serverId)
if !slices.Contains(delKeys, writeShape) {
t.Fatalf("write-path key %q not present in Del-path enumeration %v", writeShape, delKeys)
}
}
func TestServerUserSpeedLimitInputs(t *testing.T) {
planTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
userTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":1,"speed_limit":3}]`
cases := []struct {
name string
sub *subscribe.Subscribe
userSub *user.Subscribe
wantSpeedLimit int64
wantTrafficLimit string
}{
{
name: "未设置用户覆盖时返回套餐基础限速",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{},
wantSpeedLimit: 100,
wantTrafficLimit: planTrafficLimit,
},
{
name: "用户 speed_limit 覆盖套餐基础限速",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
SpeedLimit: 30,
},
wantSpeedLimit: 30,
wantTrafficLimit: planTrafficLimit,
},
{
name: "用户 traffic_limit 覆盖套餐规则",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
TrafficLimit: &userTrafficLimit,
},
wantSpeedLimit: 100,
wantTrafficLimit: userTrafficLimit,
},
{
name: "用户 traffic_limit 为空时保留套餐规则",
sub: &subscribe.Subscribe{
SpeedLimit: 100,
TrafficLimit: planTrafficLimit,
},
userSub: &user.Subscribe{
TrafficLimit: ptrString(""),
},
wantSpeedLimit: 100,
wantTrafficLimit: planTrafficLimit,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotSpeedLimit, gotTrafficLimit := serverUserSpeedLimitInputs(tc.sub, tc.userSub)
if gotSpeedLimit != tc.wantSpeedLimit {
t.Fatalf("speedLimit = %d, want %d", gotSpeedLimit, tc.wantSpeedLimit)
}
if gotTrafficLimit != tc.wantTrafficLimit {
t.Fatalf("trafficLimit = %q, want %q", gotTrafficLimit, tc.wantTrafficLimit)
}
})
}
}
func TestCalculateServerUserSpeedLimits_BatchTrafficRules(t *testing.T) {
db, mock, cleanup := newServerUserListTestDB(t)
defer cleanup()
ctx, _ := gin.CreateTestContext(nil)
ctx.Request = httptestNewRequest()
logic := &GetServerUserListLogic{
Logger: logger.WithContext(ctx.Request.Context()),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
trafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
candidates := map[int64]serverUserSpeedLimitCandidate{
101: {userID: 1001, userSubscribeID: 101, baseSpeed: 0, trafficLimit: trafficLimit},
102: {userID: 1002, userSubscribeID: 102, baseSpeed: 0, trafficLimit: trafficLimit},
103: {userID: 1003, userSubscribeID: 103, baseSpeed: 20, trafficLimit: trafficLimit},
}
mock.ExpectQuery("FROM `traffic_log`").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
AddRow(1001, 101, gb(4), gb(5)).
AddRow(1002, 102, gb(4), gb(6)).
AddRow(1003, 103, gb(12), int64(0)))
got := logic.calculateServerUserSpeedLimits(candidates)
assertSpeedLimit(t, got, 101, 0)
assertSpeedLimit(t, got, 102, 5)
assertSpeedLimit(t, got, 103, 5)
assertServerUserListExpectations(t, mock)
}
func TestCalculateServerUserSpeedLimits_UserTrafficLimitOverride(t *testing.T) {
db, mock, cleanup := newServerUserListTestDB(t)
defer cleanup()
ctx, _ := gin.CreateTestContext(nil)
ctx.Request = httptestNewRequest()
logic := &GetServerUserListLogic{
Logger: logger.WithContext(ctx.Request.Context()),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
candidates := map[int64]serverUserSpeedLimitCandidate{
201: {
userID: 2001,
userSubscribeID: 201,
baseSpeed: 50,
trafficLimit: `[{"stat_type":"day","stat_value":1,"traffic_usage":2,"speed_limit":3}]`,
},
202: {
userID: 2002,
userSubscribeID: 202,
baseSpeed: 50,
trafficLimit: "",
},
}
mock.ExpectQuery("FROM `traffic_log`").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
AddRow(2001, 201, gb(2), int64(0)))
got := logic.calculateServerUserSpeedLimits(candidates)
assertSpeedLimit(t, got, 201, 3)
assertSpeedLimit(t, got, 202, 50)
assertServerUserListExpectations(t, mock)
}
func TestServerUserTrafficRuleWindow(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
rule speedlimit.TrafficLimitRule
want time.Time
ok bool
}{
{
name: "hour window",
rule: speedlimit.TrafficLimitRule{StatType: "hour", StatValue: 2},
want: now.Add(-2 * time.Hour),
ok: true,
},
{
name: "day window",
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 1},
want: now.AddDate(0, 0, -1),
ok: true,
},
{
name: "invalid stat type",
rule: speedlimit.TrafficLimitRule{StatType: "week", StatValue: 1},
ok: false,
},
{
name: "invalid stat value",
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 0},
ok: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := serverUserTrafficRuleWindow(tc.rule, now)
if ok != tc.ok {
t.Fatalf("ok = %v, want %v", ok, tc.ok)
}
if !ok {
return
}
if !got.start.Equal(tc.want) || !got.end.Equal(now) {
t.Fatalf("window = [%s, %s), want [%s, %s)", got.start, got.end, tc.want, now)
}
})
}
}
func newServerUserListTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if matched, _ := regexp.MatchString(expectedSQL, actualSQL); matched {
return nil
}
t.Fatalf("unexpected SQL\nexpected pattern: %s\nactual: %s", expectedSQL, actualSQL)
return nil
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func assertServerUserListExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func assertSpeedLimit(t *testing.T, got map[int64]int64, userSubscribeID int64, want int64) {
t.Helper()
if got[userSubscribeID] != want {
t.Fatalf("speed limit for user_subscribe_id=%d = %d, want %d", userSubscribeID, got[userSubscribeID], want)
}
}
func gb(value int64) int64 {
return value * 1024 * 1024 * 1024
}
func ptrString(value string) *string {
return &value
}
func httptestNewRequest() *http.Request {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
return req
}
// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则:
// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。
// 这是 user-dimension 限速在过期节点组分支下能生效的关键。
func TestMergeSpeedLimit(t *testing.T) {
cases := []struct {
name string
a int64
b int64
want int64
}{
{"both zero stays zero", 0, 0, 0},
{"a positive b zero takes a", 30, 0, 30},
{"a zero b positive takes b", 0, 50, 50},
{"a negative treated as zero takes b", -1, 50, 50},
{"b negative treated as zero takes a", 50, -1, 50},
{"both positive takes smaller (a<b)", 20, 50, 20},
{"both positive takes smaller (b<a)", 80, 30, 30},
{"equal positive returns same value", 25, 25, 25},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mergeSpeedLimit(tc.a, tc.b); got != tc.want {
t.Fatalf("mergeSpeedLimit(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}