Files
3x-ui/internal/web/service/client_identity_normalized_test.go
T
MHSanaei b5a04ea95f fix(node-sync): keep shared-email lookups on the open transaction
emailUsedByOtherInbounds and emailsUsedByOtherInbounds always queried
through database.GetDB(), yet five callers run them inside a transaction:
both node traffic sync sweeps (setRemoteTrafficLocked), the inbound edit
stats sync (updateClientTraffics) and both email-drop branches of
UpdateInboundClient. Each call needed a second pool connection while the
transaction held row locks on client_traffics/node_client_traffics. On
PostgreSQL, once subscription fetches (UPDATE client_traffics SET
last_sub_fetch) blocked on those locks filled the rest of the 25-connection
pool, the lookup waited forever, the transaction never ended, and the panel
hung until restart.

Both helpers now take the *gorm.DB to query on; transactional callers pass
tx and the rest pass the pool handle they already hold. The new tests cap
the pool at one connection, so any query that escapes the transaction
blocks; each went red before the fix on SQLite and (node sync) on a live
PostgreSQL 16, and passes after.

Closes #6789
2026-10-11 22:05:24 +02:00

246 lines
9.2 KiB
Go

package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// Identity now comes from the clients table, so settings JSON that drifted from
// it no longer decides who may claim an email (#6252).
func TestAddInboundClientIgnoresStaleSettingsSubIds(t *testing.T) {
t.Run("stale entry no longer blocks the email", func(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
target := mkInbound(t, 21101, model.VLESS, `{"clients": []}`)
// Never synced, so no clients row backs it: pure settings-JSON drift.
mkInbound(t, 21102, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s-old", "enable": true}]}`)
add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s-new"}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient rejected by a stale settings entry: %v", err)
}
if got := recordSubID(t, "bob@x"); got != "s-new" {
t.Errorf("stored subId = %q, want %q", got, "s-new")
}
})
t.Run("two drifted subIds no longer lock out the matching one", func(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
owner := mkInbound(t, 21103, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
// A second inbound whose JSON disagrees about the subId. The old scan
// locked the email to "" and then rejected even the correct subId.
mkInbound(t, 21104, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s2", "enable": true}]}`)
target := mkInbound(t, 21105, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient rejected the matching subId: %v", err)
}
})
}
// A mismatched subId must still be rejected: the check moved tables, it did not
// get weaker.
func TestAddInboundClientStillRejectsMismatchedSubId(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
owner := mkInbound(t, 21111, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
target := mkInbound(t, 21112, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-different"}}
_, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
if err == nil {
t.Fatal("a different subId for a taken email was accepted")
}
if !strings.Contains(err.Error(), "Duplicate email") {
t.Errorf("error = %q, want it to mention Duplicate email", err)
}
}
// emailsUsedByOtherInbounds keys on lower(email); the clients table stores the
// email as typed under a case-sensitive unique index, so a plain IN would miss.
func TestEmailsUsedByOtherInboundsMatchesCaseInsensitively(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-a", Email: "Alice@x", Enable: true, SubID: "s-a"}}
ibA := mkInbound(t, 21121, model.VLESS, clientsSettings(t, seed))
ibB := mkInbound(t, 21122, model.VLESS, clientsSettings(t, seed))
for _, ib := range []*model.Inbound{ibA, ibB} {
if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
}
shared, err := is.emailsUsedByOtherInbounds(database.GetDB(), []string{"alice@x"}, ibA.Id)
if err != nil {
t.Fatalf("emailsUsedByOtherInbounds: %v", err)
}
if !shared["alice@x"] {
t.Error("lower-cased lookup missed a stored mixed-case email")
}
used, err := is.emailUsedByOtherInbounds(database.GetDB(), "alice@x", ibA.Id)
if err != nil {
t.Fatalf("emailUsedByOtherInbounds: %v", err)
}
if !used {
t.Error("emailUsedByOtherInbounds missed a stored mixed-case email")
}
// The traffic row is shared, so removing the client from one inbound keeps it.
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: ibA.Id, Email: "Alice@x", Enable: true,
}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if _, err := cs.DelInboundClientByEmail(is, ibA.Id, "Alice@x", false, false); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
var count int64
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "Alice@x").Count(&count).Error; err != nil {
t.Fatalf("count traffic: %v", err)
}
if count == 0 {
t.Error("traffic row purged even though the email is still on another inbound")
}
}
// Guard, not a reproducer: this passes before the delta too. It pins the one
// delta case that is not obviously safe — the rename the taken-email guard
// refuses, where the old record must still lose this inbound's link.
func TestUpdateInboundClientRenameToTakenEmailDetachesOldLink(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
const subID = "s-shared"
oldSeed := []model.Client{{ID: "id-old", Email: "old@x", Enable: true, SubID: subID}}
newSeed := []model.Client{{ID: "id-new", Email: "new@x", Enable: true, SubID: subID}}
ibX := mkInbound(t, 21131, model.VLESS, clientsSettings(t, oldSeed))
ibY := mkInbound(t, 21132, model.VLESS, clientsSettings(t, newSeed))
if err := cs.SyncInbound(nil, ibX.Id, oldSeed); err != nil {
t.Fatalf("seed X: %v", err)
}
if err := cs.SyncInbound(nil, ibY.Id, newSeed); err != nil {
t.Fatalf("seed Y: %v", err)
}
renamed := []model.Client{{ID: "id-old", Email: "new@x", Enable: true, SubID: subID}}
if _, err := cs.UpdateInboundClient(is,
&model.Inbound{Id: ibX.Id, Settings: clientsSettings(t, renamed)}, "old@x"); err != nil {
t.Fatalf("UpdateInboundClient: %v", err)
}
links := linksOf(t, ibX.Id)
if len(links) != 1 {
t.Fatalf("inbound X link count = %d, want 1: %v", len(links), links)
}
if _, ok := links[recordID(t, "new@x")]; !ok {
t.Error("inbound X is not linked to the new@x record")
}
// The refused rename leaves old@x behind; it must not still claim inbound X.
if _, ok := links[recordID(t, "old@x")]; ok {
t.Error("old@x kept its link to inbound X after the rename")
}
}
func recordSubID(t *testing.T, email string) string {
t.Helper()
var rec model.ClientRecord
if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("record %q: %v", email, err)
}
return rec.SubID
}
// The stored record must carry the subId the panel generated into the settings
// JSON. Building the membership delta from the pre-stamp request values instead
// of the stamped wire entries silently desyncs the two.
func TestAddInboundClientPersistsTheGeneratedSubId(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
ib := mkInbound(t, 21141, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-nosub", Email: "nosub@x", Enable: true}}
if _, err := cs.AddInboundClient(is, &model.Inbound{Id: ib.Id, Settings: clientsSettings(t, add)}); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
stored := recordSubID(t, "nosub@x")
if stored == "" {
t.Fatal("client record has no subId; the generated one was not persisted")
}
inSettings := settingsSubID(t, ib.Id, "nosub@x")
if stored != inSettings {
t.Errorf("record subId = %q but settings JSON says %q: the two representations desynced",
stored, inSettings)
}
}
// clients.email is unique but case-sensitive, so an identity check that does not
// fold case lets a second record for the same address be created.
func TestAddInboundClientRejectsCaseVariantOfTakenEmail(t *testing.T) {
setupBulkDB(t)
cs := &ClientService{}
is := &InboundService{}
seed := []model.Client{{ID: "id-mix", Email: "Bob@x", Enable: true, SubID: "s-mix"}}
owner := mkInbound(t, 21151, model.VLESS, clientsSettings(t, seed))
if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
target := mkInbound(t, 21152, model.VLESS, `{"clients": []}`)
add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-other"}}
_, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
if err == nil {
var count int64
database.GetDB().Model(&model.ClientRecord{}).
Where("LOWER(email) = ?", "bob@x").Count(&count)
t.Fatalf("a case variant of a taken email was accepted; clients now holds %d rows for bob@x", count)
}
if !strings.Contains(err.Error(), "Duplicate email") {
t.Errorf("error = %q, want it to mention Duplicate email", err)
}
}
func settingsSubID(t *testing.T, inboundId int, email string) string {
t.Helper()
var ib model.Inbound
if err := database.GetDB().First(&ib, inboundId).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
clients, err := ParseInboundSettingsClients(ib.Settings)
if err != nil {
t.Fatalf("parse settings: %v", err)
}
for _, c := range clients {
if c.Email == email {
return c.SubID
}
}
t.Fatalf("%q not found in settings", email)
return ""
}