Files
3x-ui-agent/internal/web/service/client_identity_normalized_test.go
Sanaei f727d04f65
Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
v3.7.0
2026-08-24 15:07:15 +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([]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("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 ""
}