client_partial_apply_test.go 6.4 KB

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