|
|
@@ -0,0 +1,245 @@
|
|
|
+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 ""
|
|
|
+}
|