inbound_client_ips_merge_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. package service
  2. import (
  3. "encoding/json"
  4. "path/filepath"
  5. "testing"
  6. "time"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. "gorm.io/gorm"
  11. )
  12. // setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
  13. // for a single test, mirroring the harness used by the other service tests.
  14. func setupClientIpTestDB(t *testing.T) {
  15. t.Helper()
  16. dbDir := t.TempDir()
  17. t.Setenv("XUI_DB_FOLDER", dbDir)
  18. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  19. }
  20. func marshalIps(t *testing.T, entries ...clientIpEntry) string {
  21. t.Helper()
  22. b, err := json.Marshal(entries)
  23. if err != nil {
  24. t.Fatalf("marshal ips: %v", err)
  25. }
  26. return string(b)
  27. }
  28. // readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
  29. // plus whether the row exists at all.
  30. func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
  31. t.Helper()
  32. var row model.InboundClientIps
  33. err := database.GetDB().Where("client_email = ?", email).First(&row).Error
  34. if database.IsNotFound(err) {
  35. return nil, false
  36. }
  37. if err != nil {
  38. t.Fatalf("read client ips for %s: %v", email, err)
  39. }
  40. var entries []clientIpEntry
  41. if row.Ips != "" {
  42. if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
  43. t.Fatalf("unmarshal stored ips for %s: %v", email, err)
  44. }
  45. }
  46. out := make(map[string]int64, len(entries))
  47. for _, e := range entries {
  48. out[e.IP] = e.Timestamp
  49. }
  50. return out, true
  51. }
  52. func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
  53. setupClientIpTestDB(t)
  54. db := database.GetDB()
  55. now := time.Now().Unix()
  56. // Local client occupies id 1.
  57. local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
  58. if err := db.Create(local).Error; err != nil {
  59. t.Fatalf("seed local row: %v", err)
  60. }
  61. // Incoming node-only client carries the remote node's id 1, which must not
  62. // collide with the local row.
  63. incoming := []model.InboundClientIps{{
  64. Id: 1,
  65. ClientEmail: "node@x",
  66. Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
  67. }}
  68. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  69. t.Fatalf("merge: %v", err)
  70. }
  71. // Local row is untouched.
  72. if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
  73. t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
  74. }
  75. // Node row exists with its own ip and a freshly assigned id (not the remote 1).
  76. var nodeRow model.InboundClientIps
  77. if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
  78. t.Fatalf("node@x not created: %v", err)
  79. }
  80. if nodeRow.Id == local.Id {
  81. t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
  82. }
  83. if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
  84. t.Fatalf("node@x missing expected ip: %v", ips)
  85. }
  86. }
  87. func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
  88. setupClientIpTestDB(t)
  89. db := database.GetDB()
  90. now := time.Now().Unix()
  91. if err := db.Create(&model.InboundClientIps{
  92. ClientEmail: "a@x",
  93. Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
  94. }).Error; err != nil {
  95. t.Fatalf("seed: %v", err)
  96. }
  97. incoming := []model.InboundClientIps{{
  98. ClientEmail: "a@x",
  99. Ips: marshalIps(t,
  100. clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
  101. clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
  102. ),
  103. }}
  104. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  105. t.Fatalf("merge: %v", err)
  106. }
  107. ips, _ := readClientIps(t, "a@x")
  108. if len(ips) != 2 {
  109. t.Fatalf("want 2 ips, got %v", ips)
  110. }
  111. if ips["1.1.1.1"] != now-50 {
  112. t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
  113. }
  114. if ips["2.2.2.2"] != now-10 {
  115. t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
  116. }
  117. }
  118. func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
  119. setupClientIpTestDB(t)
  120. db := database.GetDB()
  121. now := time.Now().Unix()
  122. if err := db.Create(&model.InboundClientIps{
  123. ClientEmail: "a@x",
  124. Ips: marshalIps(t,
  125. clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
  126. clientIpEntry{IP: "fresh", Timestamp: now - 60},
  127. ),
  128. }).Error; err != nil {
  129. t.Fatalf("seed: %v", err)
  130. }
  131. incoming := []model.InboundClientIps{{
  132. ClientEmail: "a@x",
  133. Ips: marshalIps(t,
  134. clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
  135. clientIpEntry{IP: "incFresh", Timestamp: now - 10},
  136. ),
  137. }}
  138. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  139. t.Fatalf("merge: %v", err)
  140. }
  141. ips, _ := readClientIps(t, "a@x")
  142. if len(ips) != 2 {
  143. t.Fatalf("want only fresh ips, got %v", ips)
  144. }
  145. if _, ok := ips["old"]; ok {
  146. t.Fatalf("stale local ip not dropped: %v", ips)
  147. }
  148. if _, ok := ips["incStale"]; ok {
  149. t.Fatalf("stale incoming ip not dropped: %v", ips)
  150. }
  151. if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
  152. t.Fatalf("fresh ips wrong: %v", ips)
  153. }
  154. }
  155. func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
  156. setupClientIpTestDB(t)
  157. now := time.Now().Unix()
  158. incoming := []model.InboundClientIps{{
  159. ClientEmail: "b@x",
  160. Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
  161. }}
  162. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  163. t.Fatalf("merge: %v", err)
  164. }
  165. if _, ok := readClientIps(t, "b@x"); ok {
  166. t.Fatalf("all-stale node-only client should not create a row")
  167. }
  168. }
  169. func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
  170. setupClientIpTestDB(t)
  171. now := time.Now().Unix()
  172. incoming := []model.InboundClientIps{
  173. {ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
  174. {ClientEmail: "c@x", Ips: ""},
  175. }
  176. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  177. t.Fatalf("merge: %v", err)
  178. }
  179. var count int64
  180. if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
  181. t.Fatalf("count: %v", err)
  182. }
  183. if count != 0 {
  184. t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
  185. }
  186. }
  187. func TestCasUpdateInboundClientIps_MatchAndMismatch(t *testing.T) {
  188. setupClientIpTestDB(t)
  189. db := database.GetDB()
  190. now := time.Now().Unix()
  191. seed := &model.InboundClientIps{
  192. ClientEmail: "cas@x",
  193. Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now}),
  194. }
  195. if err := db.Create(seed).Error; err != nil {
  196. t.Fatalf("seed: %v", err)
  197. }
  198. next := marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now})
  199. ok, err := CasUpdateInboundClientIps(db, seed.Id, "not-the-blob", next)
  200. if err != nil {
  201. t.Fatalf("stale CAS: %v", err)
  202. }
  203. if ok {
  204. t.Fatalf("CAS with wrong expected must not update")
  205. }
  206. ips, _ := readClientIps(t, "cas@x")
  207. if ips["1.1.1.1"] != now || len(ips) != 1 {
  208. t.Fatalf("row changed on stale CAS: %v", ips)
  209. }
  210. ok, err = CasUpdateInboundClientIps(db, seed.Id, seed.Ips, next)
  211. if err != nil {
  212. t.Fatalf("fresh CAS: %v", err)
  213. }
  214. if !ok {
  215. t.Fatalf("CAS with matching expected must update")
  216. }
  217. ips, _ = readClientIps(t, "cas@x")
  218. if ips["2.2.2.2"] != now || len(ips) != 1 {
  219. t.Fatalf("fresh CAS did not land: %v", ips)
  220. }
  221. }
  222. // A job write landing between the merge's read and its Update must not drop the
  223. // node's report (#6587); a Before(update) hook injects it, since SQLite serializes writers.
  224. func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
  225. setupClientIpTestDB(t)
  226. db := database.GetDB()
  227. now := time.Now().Unix()
  228. seed := &model.InboundClientIps{
  229. ClientEmail: "race@x",
  230. Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.1", Timestamp: now - 30}),
  231. }
  232. if err := db.Create(seed).Error; err != nil {
  233. t.Fatalf("seed: %v", err)
  234. }
  235. jobBlob := marshalIps(t, clientIpEntry{IP: "10.0.0.2", Timestamp: now - 10})
  236. const callback = "test:inbound_client_ips_cas_inject"
  237. injected := false
  238. if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
  239. if injected {
  240. return
  241. }
  242. table := tx.Statement.Table
  243. if table == "" && tx.Statement.Schema != nil {
  244. table = tx.Statement.Schema.Table
  245. }
  246. if table != "inbound_client_ips" {
  247. return
  248. }
  249. injected = true
  250. // Same connection, SkipHooks: simulate the job committing a different
  251. // blob before this merge's CAS Update runs.
  252. if err := tx.Session(&gorm.Session{SkipHooks: true}).
  253. Model(&model.InboundClientIps{}).
  254. Where("id = ?", seed.Id).
  255. Update("ips", jobBlob).Error; err != nil {
  256. tx.AddError(err)
  257. }
  258. }); err != nil {
  259. t.Fatalf("register callback: %v", err)
  260. }
  261. t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
  262. incoming := []model.InboundClientIps{{
  263. ClientEmail: "race@x",
  264. Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.3", Timestamp: now}),
  265. }}
  266. if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
  267. t.Fatalf("merge: %v", err)
  268. }
  269. if !injected {
  270. t.Fatalf("inject callback never fired; CAS path untested")
  271. }
  272. ips, _ := readClientIps(t, "race@x")
  273. // After the injected job write (only .2) and the node's .3 report, both
  274. // must survive. .1 was only in the pre-job snapshot and is correctly gone.
  275. if _, ok := ips["10.0.0.2"]; !ok {
  276. t.Fatalf("job IP lost after merge retry: %v", ips)
  277. }
  278. if _, ok := ips["10.0.0.3"]; !ok {
  279. t.Fatalf("node IP lost (the #6587 failure mode): %v", ips)
  280. }
  281. if _, ok := ips["10.0.0.1"]; ok {
  282. t.Fatalf("pre-job IP should not resurrect after job replaced the blob: %v", ips)
  283. }
  284. }