client_identity_normalized_test.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. package service
  2. import (
  3. "strings"
  4. "testing"
  5. "github.com/mhsanaei/3x-ui/v3/internal/database"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  7. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  8. )
  9. // Identity now comes from the clients table, so settings JSON that drifted from
  10. // it no longer decides who may claim an email (#6252).
  11. func TestAddInboundClientIgnoresStaleSettingsSubIds(t *testing.T) {
  12. t.Run("stale entry no longer blocks the email", func(t *testing.T) {
  13. setupBulkDB(t)
  14. cs := &ClientService{}
  15. is := &InboundService{}
  16. target := mkInbound(t, 21101, model.VLESS, `{"clients": []}`)
  17. // Never synced, so no clients row backs it: pure settings-JSON drift.
  18. mkInbound(t, 21102, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s-old", "enable": true}]}`)
  19. add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s-new"}}
  20. if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
  21. t.Fatalf("AddInboundClient rejected by a stale settings entry: %v", err)
  22. }
  23. if got := recordSubID(t, "bob@x"); got != "s-new" {
  24. t.Errorf("stored subId = %q, want %q", got, "s-new")
  25. }
  26. })
  27. t.Run("two drifted subIds no longer lock out the matching one", func(t *testing.T) {
  28. setupBulkDB(t)
  29. cs := &ClientService{}
  30. is := &InboundService{}
  31. seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
  32. owner := mkInbound(t, 21103, model.VLESS, clientsSettings(t, seed))
  33. if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
  34. t.Fatalf("seed SyncInbound: %v", err)
  35. }
  36. // A second inbound whose JSON disagrees about the subId. The old scan
  37. // locked the email to "" and then rejected even the correct subId.
  38. mkInbound(t, 21104, model.VLESS, `{"clients": [{"email": "bob@x", "subId": "s2", "enable": true}]}`)
  39. target := mkInbound(t, 21105, model.VLESS, `{"clients": []}`)
  40. add := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
  41. if _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}); err != nil {
  42. t.Fatalf("AddInboundClient rejected the matching subId: %v", err)
  43. }
  44. })
  45. }
  46. // A mismatched subId must still be rejected: the check moved tables, it did not
  47. // get weaker.
  48. func TestAddInboundClientStillRejectsMismatchedSubId(t *testing.T) {
  49. setupBulkDB(t)
  50. cs := &ClientService{}
  51. is := &InboundService{}
  52. seed := []model.Client{{ID: "id-bob", Email: "bob@x", Enable: true, SubID: "s1"}}
  53. owner := mkInbound(t, 21111, model.VLESS, clientsSettings(t, seed))
  54. if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
  55. t.Fatalf("seed SyncInbound: %v", err)
  56. }
  57. target := mkInbound(t, 21112, model.VLESS, `{"clients": []}`)
  58. add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-different"}}
  59. _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
  60. if err == nil {
  61. t.Fatal("a different subId for a taken email was accepted")
  62. }
  63. if !strings.Contains(err.Error(), "Duplicate email") {
  64. t.Errorf("error = %q, want it to mention Duplicate email", err)
  65. }
  66. }
  67. // emailsUsedByOtherInbounds keys on lower(email); the clients table stores the
  68. // email as typed under a case-sensitive unique index, so a plain IN would miss.
  69. func TestEmailsUsedByOtherInboundsMatchesCaseInsensitively(t *testing.T) {
  70. setupBulkDB(t)
  71. cs := &ClientService{}
  72. is := &InboundService{}
  73. seed := []model.Client{{ID: "id-a", Email: "Alice@x", Enable: true, SubID: "s-a"}}
  74. ibA := mkInbound(t, 21121, model.VLESS, clientsSettings(t, seed))
  75. ibB := mkInbound(t, 21122, model.VLESS, clientsSettings(t, seed))
  76. for _, ib := range []*model.Inbound{ibA, ibB} {
  77. if err := cs.SyncInbound(nil, ib.Id, seed); err != nil {
  78. t.Fatalf("seed SyncInbound: %v", err)
  79. }
  80. }
  81. shared, err := is.emailsUsedByOtherInbounds([]string{"alice@x"}, ibA.Id)
  82. if err != nil {
  83. t.Fatalf("emailsUsedByOtherInbounds: %v", err)
  84. }
  85. if !shared["alice@x"] {
  86. t.Error("lower-cased lookup missed a stored mixed-case email")
  87. }
  88. used, err := is.emailUsedByOtherInbounds("alice@x", ibA.Id)
  89. if err != nil {
  90. t.Fatalf("emailUsedByOtherInbounds: %v", err)
  91. }
  92. if !used {
  93. t.Error("emailUsedByOtherInbounds missed a stored mixed-case email")
  94. }
  95. // The traffic row is shared, so removing the client from one inbound keeps it.
  96. if err := database.GetDB().Create(&xray.ClientTraffic{
  97. InboundId: ibA.Id, Email: "Alice@x", Enable: true,
  98. }).Error; err != nil {
  99. t.Fatalf("seed traffic: %v", err)
  100. }
  101. if _, err := cs.DelInboundClientByEmail(is, ibA.Id, "Alice@x", false, false); err != nil {
  102. t.Fatalf("DelInboundClientByEmail: %v", err)
  103. }
  104. var count int64
  105. if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "Alice@x").Count(&count).Error; err != nil {
  106. t.Fatalf("count traffic: %v", err)
  107. }
  108. if count == 0 {
  109. t.Error("traffic row purged even though the email is still on another inbound")
  110. }
  111. }
  112. // Guard, not a reproducer: this passes before the delta too. It pins the one
  113. // delta case that is not obviously safe — the rename the taken-email guard
  114. // refuses, where the old record must still lose this inbound's link.
  115. func TestUpdateInboundClientRenameToTakenEmailDetachesOldLink(t *testing.T) {
  116. setupBulkDB(t)
  117. cs := &ClientService{}
  118. is := &InboundService{}
  119. const subID = "s-shared"
  120. oldSeed := []model.Client{{ID: "id-old", Email: "old@x", Enable: true, SubID: subID}}
  121. newSeed := []model.Client{{ID: "id-new", Email: "new@x", Enable: true, SubID: subID}}
  122. ibX := mkInbound(t, 21131, model.VLESS, clientsSettings(t, oldSeed))
  123. ibY := mkInbound(t, 21132, model.VLESS, clientsSettings(t, newSeed))
  124. if err := cs.SyncInbound(nil, ibX.Id, oldSeed); err != nil {
  125. t.Fatalf("seed X: %v", err)
  126. }
  127. if err := cs.SyncInbound(nil, ibY.Id, newSeed); err != nil {
  128. t.Fatalf("seed Y: %v", err)
  129. }
  130. renamed := []model.Client{{ID: "id-old", Email: "new@x", Enable: true, SubID: subID}}
  131. if _, err := cs.UpdateInboundClient(is,
  132. &model.Inbound{Id: ibX.Id, Settings: clientsSettings(t, renamed)}, "old@x"); err != nil {
  133. t.Fatalf("UpdateInboundClient: %v", err)
  134. }
  135. links := linksOf(t, ibX.Id)
  136. if len(links) != 1 {
  137. t.Fatalf("inbound X link count = %d, want 1: %v", len(links), links)
  138. }
  139. if _, ok := links[recordID(t, "new@x")]; !ok {
  140. t.Error("inbound X is not linked to the new@x record")
  141. }
  142. // The refused rename leaves old@x behind; it must not still claim inbound X.
  143. if _, ok := links[recordID(t, "old@x")]; ok {
  144. t.Error("old@x kept its link to inbound X after the rename")
  145. }
  146. }
  147. func recordSubID(t *testing.T, email string) string {
  148. t.Helper()
  149. var rec model.ClientRecord
  150. if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
  151. t.Fatalf("record %q: %v", email, err)
  152. }
  153. return rec.SubID
  154. }
  155. // The stored record must carry the subId the panel generated into the settings
  156. // JSON. Building the membership delta from the pre-stamp request values instead
  157. // of the stamped wire entries silently desyncs the two.
  158. func TestAddInboundClientPersistsTheGeneratedSubId(t *testing.T) {
  159. setupBulkDB(t)
  160. cs := &ClientService{}
  161. is := &InboundService{}
  162. ib := mkInbound(t, 21141, model.VLESS, `{"clients": []}`)
  163. add := []model.Client{{ID: "id-nosub", Email: "nosub@x", Enable: true}}
  164. if _, err := cs.AddInboundClient(is, &model.Inbound{Id: ib.Id, Settings: clientsSettings(t, add)}); err != nil {
  165. t.Fatalf("AddInboundClient: %v", err)
  166. }
  167. stored := recordSubID(t, "nosub@x")
  168. if stored == "" {
  169. t.Fatal("client record has no subId; the generated one was not persisted")
  170. }
  171. inSettings := settingsSubID(t, ib.Id, "nosub@x")
  172. if stored != inSettings {
  173. t.Errorf("record subId = %q but settings JSON says %q: the two representations desynced",
  174. stored, inSettings)
  175. }
  176. }
  177. // clients.email is unique but case-sensitive, so an identity check that does not
  178. // fold case lets a second record for the same address be created.
  179. func TestAddInboundClientRejectsCaseVariantOfTakenEmail(t *testing.T) {
  180. setupBulkDB(t)
  181. cs := &ClientService{}
  182. is := &InboundService{}
  183. seed := []model.Client{{ID: "id-mix", Email: "Bob@x", Enable: true, SubID: "s-mix"}}
  184. owner := mkInbound(t, 21151, model.VLESS, clientsSettings(t, seed))
  185. if err := cs.SyncInbound(nil, owner.Id, seed); err != nil {
  186. t.Fatalf("seed SyncInbound: %v", err)
  187. }
  188. target := mkInbound(t, 21152, model.VLESS, `{"clients": []}`)
  189. add := []model.Client{{ID: "id-other", Email: "bob@x", Enable: true, SubID: "s-other"}}
  190. _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)})
  191. if err == nil {
  192. var count int64
  193. database.GetDB().Model(&model.ClientRecord{}).
  194. Where("LOWER(email) = ?", "bob@x").Count(&count)
  195. t.Fatalf("a case variant of a taken email was accepted; clients now holds %d rows for bob@x", count)
  196. }
  197. if !strings.Contains(err.Error(), "Duplicate email") {
  198. t.Errorf("error = %q, want it to mention Duplicate email", err)
  199. }
  200. }
  201. func settingsSubID(t *testing.T, inboundId int, email string) string {
  202. t.Helper()
  203. var ib model.Inbound
  204. if err := database.GetDB().First(&ib, inboundId).Error; err != nil {
  205. t.Fatalf("load inbound: %v", err)
  206. }
  207. clients, err := ParseInboundSettingsClients(ib.Settings)
  208. if err != nil {
  209. t.Fatalf("parse settings: %v", err)
  210. }
  211. for _, c := range clients {
  212. if c.Email == email {
  213. return c.SubID
  214. }
  215. }
  216. t.Fatalf("%q not found in settings", email)
  217. return ""
  218. }