client_partial_apply_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. package controller
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "path/filepath"
  8. "testing"
  9. "github.com/gin-gonic/gin"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/web/entity"
  13. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  14. )
  15. // seedPartlyApplyingClient puts one client on two inbounds and corrupts the second
  16. // one's settings, so a later op succeeds on one inbound and fails on the other.
  17. func seedPartlyApplyingClient(t *testing.T, email string, basePort int) (healthyID, brokenID int) {
  18. t.Helper()
  19. dbDir := t.TempDir()
  20. t.Setenv("XUI_DB_FOLDER", dbDir)
  21. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  22. t.Fatalf("InitDB: %v", err)
  23. }
  24. t.Cleanup(func() { _ = database.CloseDB() })
  25. db := database.GetDB()
  26. ids := make([]int, 0, 2)
  27. for i := range 2 {
  28. ib := &model.Inbound{
  29. UserId: 1, Enable: true, Port: basePort + i,
  30. Tag: "in-" + string(rune('a'+i)) + "-partial",
  31. Protocol: model.VLESS, Settings: `{"clients": []}`,
  32. StreamSettings: `{"network":"tcp","security":"none"}`,
  33. }
  34. if err := db.Create(ib).Error; err != nil {
  35. t.Fatalf("create inbound %d: %v", i, err)
  36. }
  37. ids = append(ids, ib.Id)
  38. }
  39. if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
  40. Client: model.Client{Email: email, ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-" + email, Enable: true},
  41. InboundIds: ids,
  42. }); err != nil {
  43. t.Fatalf("seed Create across both inbounds: %v", err)
  44. }
  45. if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
  46. Update("settings", `{"clients":`).Error; err != nil {
  47. t.Fatalf("corrupt inbound %d settings: %v", ids[1], err)
  48. }
  49. return ids[0], ids[1]
  50. }
  51. func postCtx(t *testing.T, email string, body any) (*gin.Context, *httptest.ResponseRecorder) {
  52. t.Helper()
  53. gin.SetMode(gin.TestMode)
  54. w := httptest.NewRecorder()
  55. c, _ := gin.CreateTestContext(w)
  56. c.Params = gin.Params{{Key: "email", Value: email}}
  57. payload := []byte("{}")
  58. if body != nil {
  59. var err error
  60. if payload, err = json.Marshal(body); err != nil {
  61. t.Fatalf("marshal body: %v", err)
  62. }
  63. }
  64. c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))
  65. c.Request.Header.Set("Content-Type", "application/json")
  66. return c, w
  67. }
  68. // assertPartialApply pins that the op really failed on one inbound, so a green
  69. // test cannot be a plain full success that never exercised the error path.
  70. func assertPartialApply(t *testing.T, w *httptest.ResponseRecorder) {
  71. t.Helper()
  72. var msg entity.Msg
  73. if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
  74. t.Fatalf("decode response %q: %v", w.Body.String(), err)
  75. }
  76. if msg.Success {
  77. t.Fatalf("response reports success=true, want the partial apply to report failure: %q", w.Body.String())
  78. }
  79. }
  80. // TestUpdateHandlerFlagsRestartOnPartialApply pins that an edit committed on some
  81. // inbounds and failed on others still flags Xray, as create/attach already did.
  82. func TestUpdateHandlerFlagsRestartOnPartialApply(t *testing.T) {
  83. const email = "[email protected]"
  84. seedPartlyApplyingClient(t, email, 43310)
  85. a := &ClientController{}
  86. a.xrayService.IsNeedRestartAndSetFalse()
  87. c, w := postCtx(t, email, map[string]any{
  88. "email": email, "id": "11111111-2222-3333-4444-555555555555",
  89. "subId": "sub-" + email, "enable": true, "comment": "edited",
  90. })
  91. a.update(c)
  92. assertPartialApply(t, w)
  93. if !a.xrayService.IsNeedRestartAndSetFalse() {
  94. t.Fatal("a partly-applied client edit left Xray unflagged for restart")
  95. }
  96. }
  97. // TestDeleteHandlerFlagsRestartOnPartialApply is the delete-side twin: the
  98. // removals that landed still need the restart the error path used to discard.
  99. func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
  100. const email = "[email protected]"
  101. seedPartlyApplyingClient(t, email, 43320)
  102. a := &ClientController{}
  103. a.xrayService.IsNeedRestartAndSetFalse()
  104. c, w := postCtx(t, email, nil)
  105. a.delete(c)
  106. assertPartialApply(t, w)
  107. if !a.xrayService.IsNeedRestartAndSetFalse() {
  108. t.Fatal("a partly-applied client delete left Xray unflagged for restart")
  109. }
  110. }
  111. // TestImportHandlerFlagsRestartWhenTrafficRestoreFails: the traffic restore runs
  112. // after the clients are committed, so its failure must not discard their restart.
  113. func TestImportHandlerFlagsRestartWhenTrafficRestoreFails(t *testing.T) {
  114. dbDir := t.TempDir()
  115. t.Setenv("XUI_DB_FOLDER", dbDir)
  116. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  117. t.Fatalf("InitDB: %v", err)
  118. }
  119. t.Cleanup(func() { _ = database.CloseDB() })
  120. db := database.GetDB()
  121. ib := &model.Inbound{
  122. UserId: 1, Enable: true, Port: 43340, Tag: "in-import-partial",
  123. Protocol: model.VLESS, Settings: `{"clients": []}`,
  124. StreamSettings: `{"network":"tcp","security":"none"}`,
  125. }
  126. if err := db.Create(ib).Error; err != nil {
  127. t.Fatalf("create inbound: %v", err)
  128. }
  129. trigger := `CREATE TRIGGER fail_traffic_restore BEFORE UPDATE OF up ON client_traffics
  130. BEGIN SELECT RAISE(ABORT, 'injected traffic restore failure'); END`
  131. if err := db.Exec(trigger).Error; err != nil {
  132. t.Fatalf("create failure trigger: %v", err)
  133. }
  134. const email = "[email protected]"
  135. data, err := json.Marshal([]service.ClientCreatePayload{{
  136. Client: model.Client{Email: email, SubID: "sub-import-partial", Enable: true},
  137. InboundIds: []int{ib.Id},
  138. Traffic: &service.ClientPortableTraffic{Up: 5, Down: 6},
  139. }})
  140. if err != nil {
  141. t.Fatalf("marshal import data: %v", err)
  142. }
  143. a := &ClientController{}
  144. a.xrayService.IsNeedRestartAndSetFalse()
  145. c, w := postCtx(t, "", importClientsRequest{Data: string(data)})
  146. a.importClients(c)
  147. assertPartialApply(t, w)
  148. var created int64
  149. if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&created).Error; err != nil {
  150. t.Fatalf("count imported client: %v", err)
  151. }
  152. if created != 1 {
  153. t.Fatalf("imported client count=%d, want 1 committed before the restore failed", created)
  154. }
  155. if !a.xrayService.IsNeedRestartAndSetFalse() {
  156. t.Fatal("a failed traffic restore left the imported clients' Xray restart unflagged")
  157. }
  158. }
  159. // TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
  160. func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
  161. const email = "[email protected]"
  162. healthyID, brokenID := seedPartlyApplyingClient(t, email, 43330)
  163. a := &ClientController{}
  164. a.xrayService.IsNeedRestartAndSetFalse()
  165. c, w := postCtx(t, email, attachDetachBody{InboundIds: []int{healthyID, brokenID}})
  166. a.detach(c)
  167. assertPartialApply(t, w)
  168. if !a.xrayService.IsNeedRestartAndSetFalse() {
  169. t.Fatal("a partly-applied client detach left Xray unflagged for restart")
  170. }
  171. }