Files
3x-ui/internal/web/service/client_apply_field_test.go
T
MHSanaei 2b9adaa6ff fix(clients): set a client's Telegram user on every inbound it is on
SetClientTelegramUserID patched tgId in the settings of only the
inbound the client's traffic row points at. Sibling inbounds kept the
old value, and the next sync of any of them wrote it back into the
client record, so removing a Telegram user from a multi-inbound client
silently re-bound that user to it.

The write now goes through applyClientFieldByEmail, as the other
by-email setters do, so every attached inbound, on the master or a
node, receives the new tgId.
2026-11-01 00:51:31 +01:00

220 lines
7.2 KiB
Go

package service
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
// attached to several inbounds had its expiry patched only on the first
// inbound's JSON, so the stale siblings reverted the change on the next sync.
func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
const email = "multi@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
const oldExpiry = int64(1700000000000)
const newExpiry = int64(1800000000000)
clientJSON := func(expiry int64) string {
b, _ := json.Marshal(map[string]any{"clients": []map[string]any{{
"email": email, "id": uid, "enable": true, "expiryTime": expiry, "subId": "sub-multi-1",
}}})
return string(b)
}
first := &model.Inbound{
Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry),
}
second := &model.Inbound{
Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry),
}
for _, ib := range []*model.Inbound{first, second} {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", ib.Tag, err)
}
}
clientSvc := ClientService{}
inboundSvc := InboundService{}
for _, ib := range []*model.Inbound{first, second} {
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
if _, err := clientSvc.ResetClientExpiryTimeByEmail(&inboundSvc, email, newExpiry); err != nil {
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
}
for _, ib := range []*model.Inbound{first, second} {
fresh, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
}
clients, err := inboundSvc.GetClients(fresh)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if len(clients) != 1 || clients[0].ExpiryTime != newExpiry {
t.Errorf("inbound %s settings expiry = %d, want %d (#5039)", ib.Tag, clients[0].ExpiryTime, newExpiry)
}
}
rec, err := clientSvc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.ExpiryTime != newExpiry {
t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
}
}
func TestSetClientEnableByEmail_MultiInbound(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
const email = "multienable@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c222"
clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-en-1"}]}`
first := &model.Inbound{
Tag: "vless-en-a", Enable: true, Port: 50011, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON,
}
second := &model.Inbound{
Tag: "vless-en-b", Enable: true, Port: 50012, Protocol: model.VLESS,
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON,
}
for _, ib := range []*model.Inbound{first, second} {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", ib.Tag, err)
}
}
clientSvc := ClientService{}
inboundSvc := InboundService{}
for _, ib := range []*model.Inbound{first, second} {
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
if err := db.Create(&xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if _, _, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false); err != nil {
t.Fatalf("SetClientEnableByEmail: %v", err)
}
for _, ib := range []*model.Inbound{first, second} {
fresh, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
}
clients, err := inboundSvc.GetClients(fresh)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if len(clients) != 1 || clients[0].Enable {
t.Errorf("inbound %s: client still enabled after disable-by-email; a sibling inbound kept access", ib.Tag)
}
}
}
// Regression test: removing a client's Telegram user cleared tgId on one inbound
// only, so the next sync of the sibling bound the removed account again.
func TestSetClientTelegramUserID_MultiInbound(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
const email = "multitg@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333"
clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-tg-1","tgId":4242}]}`
first := &model.Inbound{
Tag: "vless-tg-a", Enable: true, Port: 50021, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON,
}
second := &model.Inbound{
Tag: "vless-tg-b", Enable: true, Port: 50022, Protocol: model.VLESS,
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON,
}
for _, ib := range []*model.Inbound{first, second} {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", ib.Tag, err)
}
}
clientSvc := ClientService{}
inboundSvc := InboundService{}
for _, ib := range []*model.Inbound{first, second} {
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
traffic := &xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true}
if err := db.Create(traffic).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if _, err := clientSvc.SetClientTelegramUserID(&inboundSvc, traffic.Id, 0); err != nil {
t.Fatalf("SetClientTelegramUserID: %v", err)
}
for _, ib := range []*model.Inbound{first, second} {
fresh, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
}
clients, err := inboundSvc.GetClients(fresh)
if err != nil {
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
}
if len(clients) != 1 || clients[0].TgID != 0 {
t.Errorf("inbound %s still binds the removed Telegram user: %+v", ib.Tag, clients)
}
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
rec, err := clientSvc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.TgID != 0 {
t.Errorf("client record tgId = %d after the siblings were synced, want 0", rec.TgID)
}
}