warp_change_ip_test.go 5.5 KB

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