periodic_traffic_reset_client_test.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. package job
  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. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  10. )
  11. func initResetJobDB(t *testing.T) {
  12. t.Helper()
  13. dbDir := t.TempDir()
  14. t.Setenv("XUI_DB_FOLDER", dbDir)
  15. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  16. t.Fatalf("InitDB: %v", err)
  17. }
  18. t.Cleanup(func() { _ = database.CloseDB() })
  19. }
  20. type seededClient struct {
  21. email string
  22. cycle string
  23. day int
  24. recordEnable bool
  25. quotaEnable bool
  26. total int64
  27. }
  28. // seedClientOnCycle creates an inbound that never resets on its own, a client
  29. // carrying its own cycle, and the client_inbounds link the reset path resolves
  30. // through — without it every reset falls into the orphaned-client branch.
  31. func seedClientOnCycle(t *testing.T, port int, c seededClient) {
  32. t.Helper()
  33. db := database.GetDB()
  34. client := model.Client{
  35. Email: c.email, ID: uuidFor(port), Enable: c.recordEnable,
  36. TrafficReset: c.cycle, TrafficResetDay: c.day,
  37. }
  38. settings, err := json.Marshal(map[string]any{"clients": []model.Client{client}})
  39. if err != nil {
  40. t.Fatalf("marshal settings: %v", err)
  41. }
  42. ib := model.Inbound{
  43. UserId: 1, Enable: true, Port: port, Protocol: model.VLESS,
  44. Tag: "inbound-" + c.email, TrafficReset: "never", Settings: string(settings),
  45. }
  46. if err := db.Create(&ib).Error; err != nil {
  47. t.Fatalf("create inbound: %v", err)
  48. }
  49. rec := model.ClientRecord{
  50. Email: c.email, UUID: client.ID, Enable: c.recordEnable,
  51. TrafficReset: c.cycle, TrafficResetDay: c.day,
  52. }
  53. if err := db.Create(&rec).Error; err != nil {
  54. t.Fatalf("create client record: %v", err)
  55. }
  56. // gorm skips a false bool on insert, so the column default:true wins; the
  57. // disabled case has to be written back explicitly.
  58. if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
  59. Update("enable", c.recordEnable).Error; err != nil {
  60. t.Fatalf("set record enable: %v", err)
  61. }
  62. if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
  63. t.Fatalf("link client to inbound: %v", err)
  64. }
  65. if err := db.Create(&xray.ClientTraffic{
  66. InboundId: ib.Id, Email: c.email, Enable: c.quotaEnable, Up: 500, Down: 700, Total: c.total,
  67. }).Error; err != nil {
  68. t.Fatalf("create traffic: %v", err)
  69. }
  70. }
  71. func uuidFor(port int) string {
  72. return "00000000-0000-0000-0000-0000000" + string(rune('0'+port/10000%10)) +
  73. string(rune('0'+port/1000%10)) + string(rune('0'+port/100%10)) +
  74. string(rune('0'+port/10%10)) + string(rune('0'+port%10))
  75. }
  76. func trafficFor(t *testing.T, email string) xray.ClientTraffic {
  77. t.Helper()
  78. var row xray.ClientTraffic
  79. if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
  80. t.Fatalf("read traffic for %s: %v", email, err)
  81. }
  82. return row
  83. }
  84. func recordFor(t *testing.T, email string) model.ClientRecord {
  85. t.Helper()
  86. var rec model.ClientRecord
  87. if err := database.GetDB().Where("email = ?", email).First(&rec).Error; err != nil {
  88. t.Fatalf("read record for %s: %v", email, err)
  89. }
  90. return rec
  91. }
  92. func TestPeriodicTrafficResetClients(t *testing.T) {
  93. t.Run("resets a client on its own cycle inside a never-reset inbound", func(t *testing.T) {
  94. initResetJobDB(t)
  95. seedClientOnCycle(t, 41001, seededClient{email: "[email protected]", cycle: "weekly", day: 1, recordEnable: true, quotaEnable: true})
  96. seedClientOnCycle(t, 41002, seededClient{email: "[email protected]", cycle: "monthly", day: 1, recordEnable: true, quotaEnable: true})
  97. NewPeriodicTrafficResetJob("weekly", time.UTC).Run()
  98. if row := trafficFor(t, "[email protected]"); row.Up != 0 || row.Down != 0 {
  99. t.Fatalf("weekly client not reset by the weekly run: up=%d down=%d", row.Up, row.Down)
  100. }
  101. if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
  102. t.Fatalf("monthly client reset by the weekly run: up=%d down=%d", row.Up, row.Down)
  103. }
  104. })
  105. t.Run("leaves a client with no cycle of its own alone", func(t *testing.T) {
  106. initResetJobDB(t)
  107. seedClientOnCycle(t, 41003, seededClient{email: "[email protected]", cycle: "never", day: 1, recordEnable: true, quotaEnable: true})
  108. for _, period := range []Period{"hourly", "daily", "weekly", "monthly"} {
  109. NewPeriodicTrafficResetJob(period, time.UTC).Run()
  110. }
  111. if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
  112. t.Fatalf("client with trafficReset=never was reset: up=%d down=%d", row.Up, row.Down)
  113. }
  114. })
  115. t.Run("monthly client waits for its own day", func(t *testing.T) {
  116. initResetJobDB(t)
  117. today := time.Now().In(time.UTC).Day()
  118. otherDay := today%28 + 1
  119. seedClientOnCycle(t, 41004, seededClient{email: "[email protected]", cycle: "monthly", day: today, recordEnable: true, quotaEnable: true})
  120. seedClientOnCycle(t, 41005, seededClient{email: "[email protected]", cycle: "monthly", day: otherDay, recordEnable: true, quotaEnable: true})
  121. NewPeriodicTrafficResetJob("monthly", time.UTC).Run()
  122. if row := trafficFor(t, "[email protected]"); row.Up != 0 || row.Down != 0 {
  123. t.Fatalf("client due today was not reset: up=%d down=%d", row.Up, row.Down)
  124. }
  125. if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
  126. t.Fatalf("client due on another day was reset: up=%d down=%d", row.Up, row.Down)
  127. }
  128. })
  129. t.Run("restores a client the quota switched off", func(t *testing.T) {
  130. initResetJobDB(t)
  131. // Depletion disables all three of client_traffics.enable, clients.enable
  132. // and the settings JSON, so a reset that lifts only the first leaves the
  133. // client out of the running core with nothing left to revisit it.
  134. seedClientOnCycle(t, 41006, seededClient{
  135. email: "[email protected]", cycle: "daily", day: 1,
  136. recordEnable: false, quotaEnable: false, total: 1000,
  137. })
  138. NewPeriodicTrafficResetJob("daily", time.UTC).Run()
  139. if row := trafficFor(t, "[email protected]"); !row.Enable {
  140. t.Fatal("quota gate not lifted: the client cannot use its new allowance")
  141. }
  142. if rec := recordFor(t, "[email protected]"); !rec.Enable {
  143. t.Fatal("clients.enable still false: GetXrayConfig skips the client, so it stays locked out for good")
  144. }
  145. if enabled := settingsEnableOf(t, 41006); !enabled {
  146. t.Fatal("the inbound settings JSON still has the client disabled")
  147. }
  148. })
  149. t.Run("leaves a client the operator switched off", func(t *testing.T) {
  150. initResetJobDB(t)
  151. // Disabled with usage below quota: nothing but a human did that.
  152. seedClientOnCycle(t, 41007, seededClient{
  153. email: "[email protected]", cycle: "daily", day: 1,
  154. recordEnable: false, quotaEnable: true, total: 100000,
  155. })
  156. NewPeriodicTrafficResetJob("daily", time.UTC).Run()
  157. if rec := recordFor(t, "[email protected]"); rec.Enable {
  158. t.Fatal("an operator-disabled client was switched back on by a cron job")
  159. }
  160. if row := trafficFor(t, "[email protected]"); row.Up != 500 || row.Down != 700 {
  161. t.Fatalf("an operator-disabled client was reset anyway: up=%d down=%d", row.Up, row.Down)
  162. }
  163. })
  164. }
  165. func settingsEnableOf(t *testing.T, port int) bool {
  166. t.Helper()
  167. var stored model.Inbound
  168. if err := database.GetDB().Where("port = ?", port).First(&stored).Error; err != nil {
  169. t.Fatal(err)
  170. }
  171. var settings struct {
  172. Clients []model.Client `json:"clients"`
  173. }
  174. if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil {
  175. t.Fatalf("parse inbound settings: %v", err)
  176. }
  177. if len(settings.Clients) != 1 {
  178. t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients))
  179. }
  180. return settings.Clients[0].Enable
  181. }