client_bulk_flow_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. package service
  2. import (
  3. "encoding/json"
  4. "testing"
  5. "time"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  9. )
  10. // mkInboundStream is mkInbound with explicit stream settings, needed to make an
  11. // inbound flow-eligible (VLESS + tcp + reality/tls).
  12. func mkInboundStream(t *testing.T, port int, proto model.Protocol, settings, stream string) *model.Inbound {
  13. t.Helper()
  14. ib := &model.Inbound{
  15. Tag: string(proto) + "-stream-" + emailSafe(port),
  16. Enable: true,
  17. Port: port,
  18. Protocol: proto,
  19. Settings: settings,
  20. StreamSettings: stream,
  21. }
  22. if err := database.GetDB().Create(ib).Error; err != nil {
  23. t.Fatalf("create inbound %d: %v", port, err)
  24. }
  25. return ib
  26. }
  27. func emailSafe(port int) string {
  28. return string(rune('a'+port%26)) + string(rune('a'+(port/26)%26))
  29. }
  30. func flowOf(t *testing.T, svc *ClientService, email string) string {
  31. t.Helper()
  32. rec, err := svc.GetRecordByEmail(nil, email)
  33. if err != nil {
  34. t.Fatalf("GetRecordByEmail(%q): %v", email, err)
  35. }
  36. return rec.Flow
  37. }
  38. const (
  39. realityStream = `{"network":"tcp","security":"reality"}`
  40. wsStream = `{"network":"ws","security":"none"}`
  41. )
  42. // TestBulkAdjust_FlowSetAndClear covers the happy path: a vision flow is applied
  43. // on an eligible VLESS inbound and later cleared with the "none" directive. Both
  44. // transitions are real config changes, so they must request a restart.
  45. func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
  46. setupBulkDB(t)
  47. svc := &ClientService{}
  48. inboundSvc := &InboundService{}
  49. clients := []model.Client{
  50. {Email: "f1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "f1", Enable: true},
  51. {Email: "f2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "f2", Enable: true},
  52. }
  53. ib := mkInboundStream(t, 30001, model.VLESS, clientsSettings(t, clients), realityStream)
  54. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  55. t.Fatalf("seed: %v", err)
  56. }
  57. emails := emailsOf(clients)
  58. // Set vision flow.
  59. res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, "")
  60. if err != nil {
  61. t.Fatalf("BulkAdjust set: %v", err)
  62. }
  63. if res.Adjusted != 2 {
  64. t.Fatalf("expected 2 adjusted, got %d (skipped=%v)", res.Adjusted, res.Skipped)
  65. }
  66. if !restart {
  67. t.Fatalf("setting flow should request a restart")
  68. }
  69. for _, e := range emails {
  70. if got := flowOf(t, svc, e); got != "xtls-rprx-vision-udp443" {
  71. t.Fatalf("%s flow = %q, want xtls-rprx-vision-udp443", e, got)
  72. }
  73. }
  74. // Setting the same flow again is a no-op: honored (counted) but no restart.
  75. if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, ""); err != nil {
  76. t.Fatalf("BulkAdjust idempotent: %v", err)
  77. } else if restart2 {
  78. t.Fatalf("re-setting identical flow should not request a restart")
  79. }
  80. // Clear flow.
  81. cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none", nil, "")
  82. if err != nil {
  83. t.Fatalf("BulkAdjust clear: %v", err)
  84. }
  85. if cres.Adjusted != 2 {
  86. t.Fatalf("expected 2 cleared, got %d (skipped=%v)", cres.Adjusted, cres.Skipped)
  87. }
  88. if !crestart {
  89. t.Fatalf("clearing flow should request a restart")
  90. }
  91. for _, e := range emails {
  92. if got := flowOf(t, svc, e); got != "" {
  93. t.Fatalf("%s flow = %q, want empty after clear", e, got)
  94. }
  95. }
  96. }
  97. // TestBulkAdjust_FlowIneligibleSkipped verifies a vision flow is refused on an
  98. // inbound that cannot carry it (ws transport), reported as skipped, and the
  99. // client's flow is left untouched.
  100. func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
  101. setupBulkDB(t)
  102. svc := &ClientService{}
  103. inboundSvc := &InboundService{}
  104. clients := []model.Client{
  105. {Email: "ws1@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "ws1", Enable: true},
  106. }
  107. ib := mkInboundStream(t, 30101, model.VLESS, clientsSettings(t, clients), wsStream)
  108. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  109. t.Fatalf("seed: %v", err)
  110. }
  111. res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision", nil, "")
  112. if err != nil {
  113. t.Fatalf("BulkAdjust: %v", err)
  114. }
  115. if res.Adjusted != 0 {
  116. t.Fatalf("ineligible inbound should adjust nothing, got %d", res.Adjusted)
  117. }
  118. if restart {
  119. t.Fatalf("no change should not request a restart")
  120. }
  121. if len(res.Skipped) != 1 || res.Skipped[0].Email != "ws1@x" {
  122. t.Fatalf("expected ws1@x in skipped, got %v", res.Skipped)
  123. }
  124. if got := flowOf(t, svc, "ws1@x"); got != "" {
  125. t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
  126. }
  127. }
  128. // TestBulkAdjust_NoDirectiveErrors guards the relaxed precondition: with no
  129. // days, traffic, or flow set there is nothing to do.
  130. func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
  131. setupBulkDB(t)
  132. svc := &ClientService{}
  133. inboundSvc := &InboundService{}
  134. if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "", nil, ""); err == nil {
  135. t.Fatalf("expected error when no adjustment is specified")
  136. }
  137. // An unknown flow directive is ignored (treated as ""), so it also errors.
  138. if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow", nil, ""); err == nil {
  139. t.Fatalf("unknown flow should be ignored and error like an empty directive")
  140. }
  141. }
  142. // TestBulkAdjust_DaysApplyDespiteIneligibleFlow is the regression for the review
  143. // blocker: when a client on a flow-ineligible inbound is adjusted with BOTH a
  144. // days/traffic delta AND a flow directive, the days/traffic change must still be
  145. // persisted to ClientTraffic (not just the inbound JSON / ClientRecord) and the
  146. // client must count as adjusted, while the unhonored flow is reported separately.
  147. func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
  148. setupBulkDB(t)
  149. svc := &ClientService{}
  150. inboundSvc := &InboundService{}
  151. const day = int64(24 * 60 * 60 * 1000)
  152. const gb = int64(1) << 30
  153. baseExpiry := time.Now().UnixMilli() + 30*day
  154. baseTotal := 10 * gb
  155. clients := []model.Client{
  156. {Email: "mix@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "mix", Enable: true, ExpiryTime: baseExpiry, TotalGB: baseTotal},
  157. }
  158. ib := mkInboundStream(t, 30201, model.VLESS, clientsSettings(t, clients), wsStream)
  159. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  160. t.Fatalf("seed: %v", err)
  161. }
  162. // ClientTraffic is the store the enforcement job reads; seed it to match.
  163. if err := database.GetDB().Create(&xray.ClientTraffic{Email: "mix@x", Enable: true, ExpiryTime: baseExpiry, Total: baseTotal}).Error; err != nil {
  164. t.Fatalf("seed traffic: %v", err)
  165. }
  166. res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision", nil, "")
  167. if err != nil {
  168. t.Fatalf("BulkAdjust: %v", err)
  169. }
  170. if res.Adjusted != 1 {
  171. t.Fatalf("days/traffic should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
  172. }
  173. if len(res.Skipped) != 1 || res.Skipped[0].Email != "mix@x" {
  174. t.Fatalf("expected mix@x reported for the unhonored flow, got %v", res.Skipped)
  175. }
  176. wantExpiry := baseExpiry + 7*day
  177. wantTotal := baseTotal + gb
  178. // ClientRecord (inbound-derived) advanced.
  179. if rec, err := svc.GetRecordByEmail(nil, "mix@x"); err != nil {
  180. t.Fatalf("record: %v", err)
  181. } else if rec.ExpiryTime != wantExpiry || rec.TotalGB != wantTotal {
  182. t.Fatalf("ClientRecord not advanced: expiry=%d total=%d", rec.ExpiryTime, rec.TotalGB)
  183. }
  184. // ClientTraffic advanced in lockstep — no divergence.
  185. var ct xray.ClientTraffic
  186. if err := database.GetDB().Where("email = ?", "mix@x").First(&ct).Error; err != nil {
  187. t.Fatalf("traffic row: %v", err)
  188. }
  189. if ct.ExpiryTime != wantExpiry || ct.Total != wantTotal {
  190. t.Fatalf("ClientTraffic diverged: expiry=%d total=%d, want expiry=%d total=%d", ct.ExpiryTime, ct.Total, wantExpiry, wantTotal)
  191. }
  192. // Flow left untouched on the ineligible inbound.
  193. if got := flowOf(t, svc, "mix@x"); got != "" {
  194. t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
  195. }
  196. }
  197. // TestBulkAdjust_HwidLimit verifies setting and clearing HWID limit in bulk.
  198. func TestBulkAdjust_HwidLimit(t *testing.T) {
  199. setupBulkDB(t)
  200. svc := &ClientService{}
  201. inboundSvc := &InboundService{}
  202. clients := []model.Client{
  203. {Email: "h1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sub-h1", Enable: true},
  204. {Email: "h2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sub-h2", Enable: true},
  205. }
  206. ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
  207. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  208. t.Fatalf("seed: %v", err)
  209. }
  210. emails := emailsOf(clients)
  211. limit2 := 2
  212. res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit2, "")
  213. if err != nil {
  214. t.Fatalf("BulkAdjust hwid: %v", err)
  215. }
  216. if res.Adjusted != 2 {
  217. t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
  218. }
  219. if restart {
  220. t.Fatalf("hwid adjustment should not request xray restart")
  221. }
  222. for _, e := range emails {
  223. rec, rErr := svc.GetRecordByEmail(nil, e)
  224. if rErr != nil || rec.LimitHwid != 2 {
  225. t.Fatalf("%s limitHwid = %d (err=%v), want 2", e, rec.LimitHwid, rErr)
  226. }
  227. }
  228. // Reset to 0 (unlimited)
  229. limit0 := 0
  230. res0, _, err0 := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit0, "")
  231. if err0 != nil || res0.Adjusted != 2 {
  232. t.Fatalf("BulkAdjust hwid 0: err=%v, res=%+v", err0, res0)
  233. }
  234. for _, e := range emails {
  235. rec, _ := svc.GetRecordByEmail(nil, e)
  236. if rec.LimitHwid != 0 {
  237. t.Fatalf("%s limitHwid = %d, want 0", e, rec.LimitHwid)
  238. }
  239. }
  240. }
  241. // TestBulkAdjust_MtprotoAdTagSetAndClear verifies ad-tag bulk update and clearing.
  242. func TestBulkAdjust_MtprotoAdTagSetAndClear(t *testing.T) {
  243. setupBulkDB(t)
  244. svc := &ClientService{}
  245. inboundSvc := &InboundService{}
  246. const tag1 = "0123456789abcdef0123456789abcdef"
  247. clients := []model.Client{
  248. {Email: "tg1@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
  249. {Email: "tg2@x", Secret: "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d", Enable: true},
  250. }
  251. ib := &model.Inbound{
  252. Tag: "mtproto-bulk-test",
  253. Enable: true,
  254. Port: 30401,
  255. Protocol: model.MTProto,
  256. Settings: clientsSettings(t, clients),
  257. }
  258. if err := database.GetDB().Create(ib).Error; err != nil {
  259. t.Fatalf("create mtproto inbound: %v", err)
  260. }
  261. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  262. t.Fatalf("seed mtproto: %v", err)
  263. }
  264. emails := emailsOf(clients)
  265. // Set ad-tag
  266. res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
  267. if err != nil {
  268. t.Fatalf("BulkAdjust adTag: %v", err)
  269. }
  270. if res.Adjusted != 2 {
  271. t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
  272. }
  273. if restart {
  274. t.Fatalf("mtproto adTag update should not request xray restart")
  275. }
  276. for _, e := range emails {
  277. rec, _ := svc.GetRecordByEmail(nil, e)
  278. if rec.AdTag != tag1 {
  279. t.Fatalf("%s adTag = %q, want %q", e, rec.AdTag, tag1)
  280. }
  281. }
  282. // Clear ad-tag with "none"
  283. cres, _, cerr := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "none")
  284. if cerr != nil || cres.Adjusted != 2 {
  285. t.Fatalf("BulkAdjust clear adTag: err=%v, res=%+v", cerr, cres)
  286. }
  287. for _, e := range emails {
  288. rec, _ := svc.GetRecordByEmail(nil, e)
  289. if rec.AdTag != "" {
  290. t.Fatalf("%s adTag = %q, want empty after clear", e, rec.AdTag)
  291. }
  292. }
  293. // Invalid ad-tag errors
  294. if _, _, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "invalid-hex"); err == nil {
  295. t.Fatalf("expected error for invalid hex ad tag")
  296. }
  297. }
  298. // TestBulkAdjust_AdTagIneligibleSkipped verifies that non-MTProto clients are
  299. // refused adTag adjustment, reported as skipped, and their ClientRecord is untouched.
  300. func TestBulkAdjust_AdTagIneligibleSkipped(t *testing.T) {
  301. setupBulkDB(t)
  302. svc := &ClientService{}
  303. inboundSvc := &InboundService{}
  304. clients := []model.Client{
  305. {Email: "vless-notg@x", ID: "55555555-5555-5555-5555-555555555555", SubID: "vless-notg", Enable: true},
  306. }
  307. ib := mkInbound(t, 30501, model.VLESS, clientsSettings(t, clients))
  308. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  309. t.Fatalf("seed: %v", err)
  310. }
  311. const tag1 = "0123456789abcdef0123456789abcdef"
  312. res, restart, err := svc.BulkAdjust(inboundSvc, []string{"vless-notg@x"}, 0, 0, "", nil, tag1)
  313. if err != nil {
  314. t.Fatalf("BulkAdjust: %v", err)
  315. }
  316. if res.Adjusted != 0 {
  317. t.Fatalf("ineligible protocol should adjust nothing, got %d", res.Adjusted)
  318. }
  319. if restart {
  320. t.Fatalf("no change should not request restart")
  321. }
  322. if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-notg@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
  323. t.Fatalf("expected vless-notg@x in skipped with 'adTag not supported on inbound', got %+v", res.Skipped)
  324. }
  325. rec, err := svc.GetRecordByEmail(nil, "vless-notg@x")
  326. if err != nil {
  327. t.Fatalf("GetRecordByEmail: %v", err)
  328. }
  329. if rec.AdTag != "" {
  330. t.Fatalf("adTag on non-MTProto record should stay empty, got %q", rec.AdTag)
  331. }
  332. }
  333. // TestBulkAdjust_DaysApplyDespiteIneligibleAdTag verifies that when a non-MTProto
  334. // client is adjusted with both days and adTag, days are applied but adTag is not
  335. // written to ClientRecord and is reported as skipped.
  336. func TestBulkAdjust_DaysApplyDespiteIneligibleAdTag(t *testing.T) {
  337. setupBulkDB(t)
  338. svc := &ClientService{}
  339. inboundSvc := &InboundService{}
  340. const day = int64(24 * 60 * 60 * 1000)
  341. baseExpiry := time.Now().UnixMilli() + 30*day
  342. clients := []model.Client{
  343. {Email: "vless-days@x", ID: "66666666-6666-6666-6666-666666666666", SubID: "vless-days", Enable: true, ExpiryTime: baseExpiry},
  344. }
  345. ib := mkInbound(t, 30601, model.VLESS, clientsSettings(t, clients))
  346. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  347. t.Fatalf("seed: %v", err)
  348. }
  349. if err := database.GetDB().Create(&xray.ClientTraffic{Email: "vless-days@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
  350. t.Fatalf("seed traffic: %v", err)
  351. }
  352. const tag1 = "0123456789abcdef0123456789abcdef"
  353. res, _, err := svc.BulkAdjust(inboundSvc, []string{"vless-days@x"}, 7, 0, "", nil, tag1)
  354. if err != nil {
  355. t.Fatalf("BulkAdjust: %v", err)
  356. }
  357. if res.Adjusted != 1 {
  358. t.Fatalf("days should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
  359. }
  360. if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-days@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
  361. t.Fatalf("expected vless-days@x reported for unhonored adTag, got %v", res.Skipped)
  362. }
  363. rec, err := svc.GetRecordByEmail(nil, "vless-days@x")
  364. if err != nil {
  365. t.Fatalf("record: %v", err)
  366. }
  367. if rec.ExpiryTime != baseExpiry+7*day {
  368. t.Fatalf("expiry time not advanced: got %d, want %d", rec.ExpiryTime, baseExpiry+7*day)
  369. }
  370. if rec.AdTag != "" {
  371. t.Fatalf("adTag should remain empty on ClientRecord for non-MTProto, got %q", rec.AdTag)
  372. }
  373. }
  374. // TestBulkAdjust_MixedMtprotoAndVless_AdTag verifies bulk adjust over a mixed
  375. // MTProto and VLESS selection.
  376. func TestBulkAdjust_MixedMtprotoAndVless_AdTag(t *testing.T) {
  377. setupBulkDB(t)
  378. svc := &ClientService{}
  379. inboundSvc := &InboundService{}
  380. const tag1 = "0123456789abcdef0123456789abcdef"
  381. tgClients := []model.Client{
  382. {Email: "tg-mix@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
  383. }
  384. tgIb := &model.Inbound{
  385. Tag: "mtproto-mix",
  386. Enable: true,
  387. Port: 30701,
  388. Protocol: model.MTProto,
  389. Settings: clientsSettings(t, tgClients),
  390. }
  391. if err := database.GetDB().Create(tgIb).Error; err != nil {
  392. t.Fatalf("create mtproto: %v", err)
  393. }
  394. if err := svc.SyncInbound(nil, tgIb.Id, tgClients); err != nil {
  395. t.Fatalf("sync mtproto: %v", err)
  396. }
  397. vlessClients := []model.Client{
  398. {Email: "vless-mix@x", ID: "77777777-7777-7777-7777-777777777777", SubID: "vless-mix", Enable: true},
  399. }
  400. vlessIb := mkInbound(t, 30702, model.VLESS, clientsSettings(t, vlessClients))
  401. if err := svc.SyncInbound(nil, vlessIb.Id, vlessClients); err != nil {
  402. t.Fatalf("sync vless: %v", err)
  403. }
  404. emails := []string{"tg-mix@x", "vless-mix@x"}
  405. res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
  406. if err != nil {
  407. t.Fatalf("BulkAdjust: %v", err)
  408. }
  409. if res.Adjusted != 1 {
  410. t.Fatalf("expected 1 adjusted (MTProto only), got %d", res.Adjusted)
  411. }
  412. if restart {
  413. t.Fatalf("adTag should not restart xray")
  414. }
  415. if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-mix@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
  416. t.Fatalf("expected vless-mix@x in skipped, got %+v", res.Skipped)
  417. }
  418. tgRec, _ := svc.GetRecordByEmail(nil, "tg-mix@x")
  419. if tgRec.AdTag != tag1 {
  420. t.Fatalf("tg-mix@x adTag = %q, want %q", tgRec.AdTag, tag1)
  421. }
  422. vlessRec, _ := svc.GetRecordByEmail(nil, "vless-mix@x")
  423. if vlessRec.AdTag != "" {
  424. t.Fatalf("vless-mix@x adTag = %q, want empty", vlessRec.AdTag)
  425. }
  426. }
  427. // TestBulkAdjust_UnchangedClientKeepsUpdatedAt pins the updated_at stamp to the
  428. // client that actually changed: an untouched client must not be re-stamped only
  429. // because a client earlier in the same inbound's array was adjusted.
  430. func TestBulkAdjust_UnchangedClientKeepsUpdatedAt(t *testing.T) {
  431. setupBulkDB(t)
  432. svc := &ClientService{}
  433. inboundSvc := &InboundService{}
  434. const day = int64(24 * 60 * 60 * 1000)
  435. const seeded = int64(1600000000000)
  436. baseExpiry := time.Now().UnixMilli() + 30*day
  437. // chg@x is listed first and takes the expiry bump; keep@x has unlimited
  438. // expiry on a ws inbound, so the same call changes nothing for it.
  439. clients := []model.Client{
  440. {Email: "chg@x", ID: "88888888-8888-8888-8888-888888888888", SubID: "chg", Enable: true, ExpiryTime: baseExpiry, UpdatedAt: seeded},
  441. {Email: "keep@x", ID: "99999999-9999-9999-9999-999999999999", SubID: "keep", Enable: true, UpdatedAt: seeded},
  442. }
  443. ib := mkInboundStream(t, 30801, model.VLESS, clientsSettings(t, clients), wsStream)
  444. if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
  445. t.Fatalf("seed: %v", err)
  446. }
  447. if err := database.GetDB().Create(&xray.ClientTraffic{Email: "chg@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
  448. t.Fatalf("seed traffic: %v", err)
  449. }
  450. // The flow directive is what keeps keep@x in the plan; the ws inbound cannot
  451. // carry it, so the directive is not itself a change for either client.
  452. if _, _, err := svc.BulkAdjust(inboundSvc, emailsOf(clients), 7, 0, "xtls-rprx-vision", nil, ""); err != nil {
  453. t.Fatalf("BulkAdjust: %v", err)
  454. }
  455. stamps := settingsUpdatedAt(t, inboundSvc, ib.Id)
  456. if stamps["chg@x"] <= seeded {
  457. t.Fatalf("adjusted client should be re-stamped, updated_at = %d", stamps["chg@x"])
  458. }
  459. if stamps["keep@x"] != seeded {
  460. t.Fatalf("untouched client updated_at = %d, want %d — a sibling's change must not re-stamp it", stamps["keep@x"], seeded)
  461. }
  462. }
  463. func settingsUpdatedAt(t *testing.T, inboundSvc *InboundService, inboundId int) map[string]int64 {
  464. t.Helper()
  465. ib, err := inboundSvc.GetInbound(inboundId)
  466. if err != nil {
  467. t.Fatalf("GetInbound: %v", err)
  468. }
  469. var parsed struct {
  470. Clients []struct {
  471. Email string `json:"email"`
  472. UpdatedAt int64 `json:"updated_at"`
  473. } `json:"clients"`
  474. }
  475. if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
  476. t.Fatalf("unmarshal settings: %v", err)
  477. }
  478. out := make(map[string]int64, len(parsed.Clients))
  479. for _, c := range parsed.Clients {
  480. out[c.Email] = c.UpdatedAt
  481. }
  482. return out
  483. }