warp_change_ip_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. package integration
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/http/httptest"
  7. "path/filepath"
  8. "strings"
  9. "sync/atomic"
  10. "testing"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database"
  12. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  14. )
  15. // seedWarp stores warp credentials (with a Warp Plus license key) in the DB.
  16. func seedWarp(t *testing.T, license string) {
  17. t.Helper()
  18. oldData := fmt.Sprintf(
  19. `{"access_token":"old-token","device_id":"old-device","license_key":%q,"private_key":"old-priv"}`,
  20. license,
  21. )
  22. if err := database.GetDB().Create(&model.Setting{Key: "warp", Value: oldData}).Error; err != nil {
  23. t.Fatalf("seed warp: %v", err)
  24. }
  25. }
  26. // mockWarpAPI emulates the Cloudflare WARP registration API. When reapplyFails
  27. // is true, the PUT /reg/{id}/account endpoint returns 500 (license rejected).
  28. func mockWarpAPI(t *testing.T, reapplyFails bool) (*httptest.Server, *atomic.Int32, *atomic.Int32) {
  29. t.Helper()
  30. regCalls := &atomic.Int32{}
  31. licCalls := &atomic.Int32{}
  32. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  33. switch {
  34. case r.Method == http.MethodPost && r.URL.Path == "/reg":
  35. regCalls.Add(1)
  36. w.Header().Set("Content-Type", "application/json")
  37. _, _ = w.Write([]byte(`{"id":"new-device","token":"new-token","account":{"license":""},"config":{"client_id":"YWJj"}}`))
  38. case r.Method == http.MethodPut && r.URL.Path == "/reg/new-device/account":
  39. licCalls.Add(1)
  40. if reapplyFails {
  41. w.WriteHeader(http.StatusInternalServerError)
  42. _, _ = w.Write([]byte(`{"error":"license already in use"}`))
  43. return
  44. }
  45. var body map[string]string
  46. _ = json.NewDecoder(r.Body).Decode(&body)
  47. if body["license"] != "WARPPLLUS-KEY-0123456789abcdefgh" {
  48. t.Errorf("re-apply license: got %q, want the saved Warp Plus key", body["license"])
  49. }
  50. w.Header().Set("Content-Type", "application/json")
  51. _, _ = w.Write([]byte(`{"id":"new-device"}`))
  52. default:
  53. t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
  54. w.WriteHeader(http.StatusNotFound)
  55. }
  56. }))
  57. t.Cleanup(srv.Close)
  58. return srv, regCalls, licCalls
  59. }
  60. func withWarpAPIBase(t *testing.T, base string) {
  61. t.Helper()
  62. orig := warpAPIBase
  63. warpAPIBase = base
  64. t.Cleanup(func() { warpAPIBase = orig })
  65. }
  66. func TestChangeWarpIPPreservesLicenseKey(t *testing.T) {
  67. dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
  68. const license = "WARPPLLUS-KEY-0123456789abcdefgh" // 32 chars, >= 26 gate
  69. seedWarp(t, license)
  70. srv, regCalls, licCalls := mockWarpAPI(t, false)
  71. withWarpAPIBase(t, srv.URL)
  72. s := &WarpService{}
  73. resp, err := s.ChangeWarpIP()
  74. if err != nil {
  75. t.Fatalf("ChangeWarpIP: %v", err)
  76. }
  77. // Storage must keep the license key and the new device id.
  78. stored, err := s.GetWarp()
  79. if err != nil {
  80. t.Fatalf("GetWarp: %v", err)
  81. }
  82. var storedData map[string]string
  83. if err := json.Unmarshal([]byte(stored), &storedData); err != nil {
  84. t.Fatalf("unmarshal stored warp: %v", err)
  85. }
  86. if storedData["license_key"] != license {
  87. t.Errorf("stored license_key = %q, want %q (key must survive changeIp)", storedData["license_key"], license)
  88. }
  89. if storedData["device_id"] != "new-device" {
  90. t.Errorf("stored device_id = %q, want %q (IP must still rotate)", storedData["device_id"], "new-device")
  91. }
  92. // The response must carry the license key so the UI shows it.
  93. var parsed struct {
  94. Data map[string]string `json:"data"`
  95. }
  96. if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
  97. t.Fatalf("unmarshal response: %v", err)
  98. }
  99. if parsed.Data["license_key"] != license {
  100. t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license)
  101. }
  102. if strings.Contains(resp, "warning") {
  103. t.Errorf("response unexpectedly contains a warning: %s", resp)
  104. }
  105. if regCalls.Load() != 1 {
  106. t.Errorf("reg calls = %d, want 1", regCalls.Load())
  107. }
  108. if licCalls.Load() != 1 {
  109. t.Errorf("license re-apply calls = %d, want 1", licCalls.Load())
  110. }
  111. }
  112. func TestChangeWarpIPKeepsLicenseWhenReapplyFails(t *testing.T) {
  113. dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
  114. const license = "WARPPLLUS-KEY-0123456789abcdefgh"
  115. seedWarp(t, license)
  116. srv, _, licCalls := mockWarpAPI(t, true)
  117. withWarpAPIBase(t, srv.URL)
  118. s := &WarpService{}
  119. resp, err := s.ChangeWarpIP()
  120. if err != nil {
  121. t.Fatalf("ChangeWarpIP: %v", err)
  122. }
  123. // Even when Cloudflare rejects the re-apply, the saved key must stay.
  124. stored, err := s.GetWarp()
  125. if err != nil {
  126. t.Fatalf("GetWarp: %v", err)
  127. }
  128. var storedData map[string]string
  129. if err := json.Unmarshal([]byte(stored), &storedData); err != nil {
  130. t.Fatalf("unmarshal stored warp: %v", err)
  131. }
  132. if storedData["license_key"] != license {
  133. t.Errorf("stored license_key = %q, want %q (re-apply failure must not delete the key)", storedData["license_key"], license)
  134. }
  135. // The response must warn the user instead of silently succeeding.
  136. var parsed struct {
  137. Data map[string]string `json:"data"`
  138. Warning string `json:"warning"`
  139. }
  140. if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
  141. t.Fatalf("unmarshal response: %v", err)
  142. }
  143. if parsed.Warning == "" {
  144. t.Error("response missing warning about failed license re-apply")
  145. }
  146. if parsed.Data["license_key"] != license {
  147. t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license)
  148. }
  149. if licCalls.Load() != 1 {
  150. t.Errorf("license re-apply calls = %d, want 1", licCalls.Load())
  151. }
  152. }