node_bulk_dispatch_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "sync/atomic"
  6. "testing"
  7. "github.com/google/uuid"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  11. )
  12. // fakeNodeRuntime is a runtime.Runtime stub that counts the per-client dispatch
  13. // calls so a test can assert a bulk op does NOT stream one RPC per client.
  14. type fakeNodeRuntime struct {
  15. addInbound atomic.Int32
  16. delInbound atomic.Int32
  17. addClient atomic.Int32
  18. deleteClient atomic.Int32
  19. deleteUser atomic.Int32
  20. updateInbound atomic.Int32
  21. updateUser atomic.Int32
  22. }
  23. func (f *fakeNodeRuntime) Name() string { return "fake-node" }
  24. func (f *fakeNodeRuntime) AddInbound(context.Context, *model.Inbound) error {
  25. f.addInbound.Add(1)
  26. return nil
  27. }
  28. func (f *fakeNodeRuntime) DelInbound(context.Context, *model.Inbound) error {
  29. f.delInbound.Add(1)
  30. return nil
  31. }
  32. func (f *fakeNodeRuntime) UpdateInbound(context.Context, *model.Inbound, *model.Inbound) error {
  33. f.updateInbound.Add(1)
  34. return nil
  35. }
  36. func (f *fakeNodeRuntime) AddUser(context.Context, *model.Inbound, map[string]any) error { return nil }
  37. func (f *fakeNodeRuntime) RemoveUser(context.Context, *model.Inbound, string) error { return nil }
  38. func (f *fakeNodeRuntime) UpdateUser(context.Context, *model.Inbound, string, model.Client) error {
  39. f.updateUser.Add(1)
  40. return nil
  41. }
  42. func (f *fakeNodeRuntime) DeleteUser(context.Context, *model.Inbound, string) error {
  43. f.deleteUser.Add(1)
  44. return nil
  45. }
  46. func (f *fakeNodeRuntime) DeleteClient(context.Context, string) error {
  47. f.deleteClient.Add(1)
  48. return nil
  49. }
  50. func (f *fakeNodeRuntime) AddClient(context.Context, *model.Inbound, model.Client) error {
  51. f.addClient.Add(1)
  52. return nil
  53. }
  54. func (f *fakeNodeRuntime) RestartXray(context.Context) error { return nil }
  55. func (f *fakeNodeRuntime) ResetClientTraffic(context.Context, *model.Inbound, string) error {
  56. return nil
  57. }
  58. func (f *fakeNodeRuntime) ResetInboundTraffic(context.Context, *model.Inbound) error { return nil }
  59. func (f *fakeNodeRuntime) ResetAllTraffics(context.Context) error { return nil }
  60. // setupNodeRuntime wires an online node + a fake runtime override and returns the
  61. // node id and the fake so a test can drive the service node-dispatch path without
  62. // a network node.
  63. func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
  64. t.Helper()
  65. prev := runtime.GetManager()
  66. mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
  67. runtime.SetManager(mgr)
  68. t.Cleanup(func() { runtime.SetManager(prev) })
  69. node := &model.Node{Name: "n1-" + t.Name(), Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
  70. if err := database.GetDB().Create(node).Error; err != nil {
  71. t.Fatalf("create node: %v", err)
  72. }
  73. t.Cleanup(func() {
  74. _ = database.GetDB().Where("id = ?", node.Id).Delete(&model.Node{}).Error
  75. })
  76. fake := &fakeNodeRuntime{}
  77. mgr.SetRuntimeOverride(node.Id, fake)
  78. return node.Id, fake
  79. }
  80. func nodeInbound(t *testing.T, nodeID, port int, clients []model.Client) *model.Inbound {
  81. t.Helper()
  82. if clients == nil {
  83. clients = []model.Client{}
  84. }
  85. ib := &model.Inbound{
  86. UserId: 1, NodeID: &nodeID, Tag: fmt.Sprintf("in-%d", port), Enable: true,
  87. Port: port, Protocol: model.VLESS, Settings: clientsSettings(t, clients),
  88. }
  89. if err := database.GetDB().Create(ib).Error; err != nil {
  90. t.Fatalf("create node inbound: %v", err)
  91. }
  92. if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
  93. t.Fatalf("seed SyncInbound: %v", err)
  94. }
  95. return ib
  96. }
  97. func makeNodeClients(n int) []model.Client {
  98. out := make([]model.Client, n)
  99. for i := range n {
  100. out[i] = model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("nu-%05d@x", i), Enable: true}
  101. }
  102. return out
  103. }
  104. // TestNodeBulk_LargeAddFoldsToDirty: adding more than the threshold of clients to
  105. // an online node inbound must NOT stream one AddClient RPC per client; it marks
  106. // the node dirty so a single reconcile push converges it instead.
  107. func TestNodeBulk_LargeAddFoldsToDirty(t *testing.T) {
  108. setupBulkDB(t)
  109. nodeID, fake := setupNodeRuntime(t)
  110. ib := nodeInbound(t, nodeID, 30001, nil)
  111. svc := &ClientService{}
  112. inboundSvc := &InboundService{}
  113. add := makeNodeClients(nodeBulkPushThreshold + 10)
  114. if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, add)}); err != nil {
  115. t.Fatalf("AddInboundClient: %v", err)
  116. }
  117. if got := fake.addClient.Load(); got != 0 {
  118. t.Fatalf("large add streamed %d AddClient RPCs, want 0 (should fold to dirty)", got)
  119. }
  120. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
  121. t.Fatalf("NodeSyncState: %v", err)
  122. } else if !dirty {
  123. t.Fatal("large add must mark the node dirty")
  124. }
  125. }
  126. // TestNodeBulk_SmallAddPushesLive: a small add stays on the live per-client path.
  127. func TestNodeBulk_SmallAddPushesLive(t *testing.T) {
  128. setupBulkDB(t)
  129. nodeID, fake := setupNodeRuntime(t)
  130. ib := nodeInbound(t, nodeID, 30002, nil)
  131. svc := &ClientService{}
  132. inboundSvc := &InboundService{}
  133. const small = 3
  134. add := makeNodeClients(small)
  135. if _, err := svc.AddInboundClient(inboundSvc, &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, add)}); err != nil {
  136. t.Fatalf("AddInboundClient: %v", err)
  137. }
  138. if got := fake.addClient.Load(); got != int32(small) {
  139. t.Fatalf("small add streamed %d AddClient RPCs, want %d", got, small)
  140. }
  141. }
  142. func TestNodeUpdateInboundClientNoopSkipsRuntimeAndDirty(t *testing.T) {
  143. setupBulkDB(t)
  144. nodeID, fake := setupNodeRuntime(t)
  145. client := model.Client{
  146. ID: uuid.NewString(),
  147. Email: "noop@x",
  148. SubID: "sub-noop",
  149. Enable: true,
  150. CreatedAt: 111,
  151. UpdatedAt: 222,
  152. }
  153. ib := nodeInbound(t, nodeID, 30020, []model.Client{client})
  154. svc := &ClientService{}
  155. inboundSvc := &InboundService{}
  156. if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
  157. Id: ib.Id,
  158. Protocol: model.VLESS,
  159. Settings: clientsSettings(t, []model.Client{client}),
  160. }, client.Email); err != nil {
  161. t.Fatalf("UpdateInboundClient: %v", err)
  162. }
  163. if got := fake.updateUser.Load(); got != 0 {
  164. t.Fatalf("no-op update streamed %d UpdateUser RPCs, want 0", got)
  165. }
  166. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
  167. t.Fatalf("NodeSyncState: %v", err)
  168. } else if dirty {
  169. t.Fatal("no-op update must not mark the node dirty")
  170. }
  171. reloaded, err := inboundSvc.GetInbound(ib.Id)
  172. if err != nil {
  173. t.Fatalf("GetInbound: %v", err)
  174. }
  175. if reloaded.Settings != ib.Settings {
  176. t.Fatal("no-op update rewrote inbound settings")
  177. }
  178. }
  179. func TestNodeUpdateInboundClientLivePushKeepsDirtyBackup(t *testing.T) {
  180. setupBulkDB(t)
  181. nodeID, fake := setupNodeRuntime(t)
  182. client := model.Client{
  183. ID: uuid.NewString(),
  184. Email: "edit@x",
  185. SubID: "sub-edit",
  186. Enable: true,
  187. CreatedAt: 111,
  188. UpdatedAt: 222,
  189. }
  190. ib := nodeInbound(t, nodeID, 30021, []model.Client{client})
  191. edited := client
  192. edited.Comment = "changed"
  193. svc := &ClientService{}
  194. inboundSvc := &InboundService{}
  195. if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
  196. Id: ib.Id,
  197. Protocol: model.VLESS,
  198. Settings: clientsSettings(t, []model.Client{edited}),
  199. }, client.Email); err != nil {
  200. t.Fatalf("UpdateInboundClient: %v", err)
  201. }
  202. if got := fake.updateUser.Load(); got != 1 {
  203. t.Fatalf("edit streamed %d UpdateUser RPCs, want 1", got)
  204. }
  205. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
  206. t.Fatalf("NodeSyncState: %v", err)
  207. } else if !dirty {
  208. t.Fatal("successful live update should keep node dirty as reconcile backup")
  209. }
  210. }
  211. // TestNodeBulk_LargeDeleteFoldsToDirty: deleting more than the threshold from an
  212. // online node inbound must fold into a reconcile rather than per-client deletes.
  213. func TestNodeBulk_LargeDeleteFoldsToDirty(t *testing.T) {
  214. setupBulkDB(t)
  215. nodeID, fake := setupNodeRuntime(t)
  216. seed := makeNodeClients(nodeBulkPushThreshold + 10)
  217. nodeInbound(t, nodeID, 30003, seed)
  218. svc := &ClientService{}
  219. inboundSvc := &InboundService{}
  220. emails := make([]string, len(seed))
  221. for i := range seed {
  222. emails[i] = seed[i].Email
  223. }
  224. if _, _, err := svc.BulkDelete(inboundSvc, emails, false); err != nil {
  225. t.Fatalf("BulkDelete: %v", err)
  226. }
  227. if got := fake.deleteUser.Load(); got != 0 {
  228. t.Fatalf("large delete streamed %d DeleteUser RPCs, want 0 (should fold to dirty)", got)
  229. }
  230. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
  231. t.Fatalf("NodeSyncState: %v", err)
  232. } else if !dirty {
  233. t.Fatal("large delete must mark the node dirty")
  234. }
  235. }
  236. func TestDelInbound_NodeSelectedModeDeletesRemoteImmediately(t *testing.T) {
  237. setupBulkDB(t)
  238. nodeID, fake := setupNodeRuntime(t)
  239. if err := database.GetDB().Model(&model.Node{}).Where("id = ?", nodeID).
  240. Updates(map[string]any{
  241. "inbound_sync_mode": "selected",
  242. "inbound_tags": []string{"other-tag"},
  243. }).Error; err != nil {
  244. t.Fatalf("set selected mode: %v", err)
  245. }
  246. ib := nodeInbound(t, nodeID, 30004, makeNodeClients(1))
  247. needRestart, err := (&InboundService{}).DelInbound(ib.Id)
  248. if err != nil {
  249. t.Fatalf("DelInbound: %v", err)
  250. }
  251. if needRestart {
  252. t.Fatal("node-owned delete should not request local restart")
  253. }
  254. if got := fake.delInbound.Load(); got != 1 {
  255. t.Fatalf("node-owned delete streamed %d DelInbound RPCs, want 1", got)
  256. }
  257. var count int64
  258. if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib.Id).Count(&count).Error; err != nil {
  259. t.Fatalf("count inbound: %v", err)
  260. }
  261. if count != 0 {
  262. t.Fatalf("deleted inbound row count = %d, want 0", count)
  263. }
  264. if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(nodeID); err != nil {
  265. t.Fatalf("NodeSyncState: %v", err)
  266. } else if !dirty {
  267. t.Fatal("node-owned delete should still mark the node dirty as reconcile backup")
  268. }
  269. }