1
0

inbound_client_ips_merge_test.go 9.4 KB

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