|
@@ -0,0 +1,173 @@
|
|
|
|
|
+package integration
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/http/httptest"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "sync/atomic"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// seedWarp stores warp credentials (with a Warp Plus license key) in the DB.
|
|
|
|
|
+func seedWarp(t *testing.T, license string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ oldData := fmt.Sprintf(
|
|
|
|
|
+ `{"access_token":"old-token","device_id":"old-device","license_key":%q,"private_key":"old-priv"}`,
|
|
|
|
|
+ license,
|
|
|
|
|
+ )
|
|
|
|
|
+ if err := database.GetDB().Create(&model.Setting{Key: "warp", Value: oldData}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed warp: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// mockWarpAPI emulates the Cloudflare WARP registration API. When reapplyFails
|
|
|
|
|
+// is true, the PUT /reg/{id}/account endpoint returns 500 (license rejected).
|
|
|
|
|
+func mockWarpAPI(t *testing.T, reapplyFails bool) (*httptest.Server, *atomic.Int32, *atomic.Int32) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ regCalls := &atomic.Int32{}
|
|
|
|
|
+ licCalls := &atomic.Int32{}
|
|
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case r.Method == http.MethodPost && r.URL.Path == "/reg":
|
|
|
|
|
+ regCalls.Add(1)
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _, _ = w.Write([]byte(`{"id":"new-device","token":"new-token","account":{"license":""},"config":{"client_id":"YWJj"}}`))
|
|
|
|
|
+ case r.Method == http.MethodPut && r.URL.Path == "/reg/new-device/account":
|
|
|
|
|
+ licCalls.Add(1)
|
|
|
|
|
+ if reapplyFails {
|
|
|
|
|
+ w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
+ _, _ = w.Write([]byte(`{"error":"license already in use"}`))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ var body map[string]string
|
|
|
|
|
+ _ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
|
+ if body["license"] != "WARPPLLUS-KEY-0123456789abcdefgh" {
|
|
|
|
|
+ t.Errorf("re-apply license: got %q, want the saved Warp Plus key", body["license"])
|
|
|
|
|
+ }
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _, _ = w.Write([]byte(`{"id":"new-device"}`))
|
|
|
|
|
+ default:
|
|
|
|
|
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
|
|
|
+ w.WriteHeader(http.StatusNotFound)
|
|
|
|
|
+ }
|
|
|
|
|
+ }))
|
|
|
|
|
+ t.Cleanup(srv.Close)
|
|
|
|
|
+ return srv, regCalls, licCalls
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func withWarpAPIBase(t *testing.T, base string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ orig := warpAPIBase
|
|
|
|
|
+ warpAPIBase = base
|
|
|
|
|
+ t.Cleanup(func() { warpAPIBase = orig })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestChangeWarpIPPreservesLicenseKey(t *testing.T) {
|
|
|
|
|
+ if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
|
|
|
+ t.Fatalf("InitDB: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Cleanup(func() { _ = database.CloseDB() })
|
|
|
|
|
+
|
|
|
|
|
+ const license = "WARPPLLUS-KEY-0123456789abcdefgh" // 32 chars, >= 26 gate
|
|
|
|
|
+ seedWarp(t, license)
|
|
|
|
|
+ srv, regCalls, licCalls := mockWarpAPI(t, false)
|
|
|
|
|
+ withWarpAPIBase(t, srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ s := &WarpService{}
|
|
|
|
|
+ resp, err := s.ChangeWarpIP()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("ChangeWarpIP: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Storage must keep the license key and the new device id.
|
|
|
|
|
+ stored, err := s.GetWarp()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("GetWarp: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var storedData map[string]string
|
|
|
|
|
+ if err := json.Unmarshal([]byte(stored), &storedData); err != nil {
|
|
|
|
|
+ t.Fatalf("unmarshal stored warp: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if storedData["license_key"] != license {
|
|
|
|
|
+ t.Errorf("stored license_key = %q, want %q (key must survive changeIp)", storedData["license_key"], license)
|
|
|
|
|
+ }
|
|
|
|
|
+ if storedData["device_id"] != "new-device" {
|
|
|
|
|
+ t.Errorf("stored device_id = %q, want %q (IP must still rotate)", storedData["device_id"], "new-device")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The response must carry the license key so the UI shows it.
|
|
|
|
|
+ var parsed struct {
|
|
|
|
|
+ Data map[string]string `json:"data"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
|
|
|
|
|
+ t.Fatalf("unmarshal response: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if parsed.Data["license_key"] != license {
|
|
|
|
|
+ t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Contains(resp, "warning") {
|
|
|
|
|
+ t.Errorf("response unexpectedly contains a warning: %s", resp)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if regCalls.Load() != 1 {
|
|
|
|
|
+ t.Errorf("reg calls = %d, want 1", regCalls.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+ if licCalls.Load() != 1 {
|
|
|
|
|
+ t.Errorf("license re-apply calls = %d, want 1", licCalls.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestChangeWarpIPKeepsLicenseWhenReapplyFails(t *testing.T) {
|
|
|
|
|
+ if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
|
|
|
+ t.Fatalf("InitDB: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Cleanup(func() { _ = database.CloseDB() })
|
|
|
|
|
+
|
|
|
|
|
+ const license = "WARPPLLUS-KEY-0123456789abcdefgh"
|
|
|
|
|
+ seedWarp(t, license)
|
|
|
|
|
+ srv, _, licCalls := mockWarpAPI(t, true)
|
|
|
|
|
+ withWarpAPIBase(t, srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ s := &WarpService{}
|
|
|
|
|
+ resp, err := s.ChangeWarpIP()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("ChangeWarpIP: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Even when Cloudflare rejects the re-apply, the saved key must stay.
|
|
|
|
|
+ stored, err := s.GetWarp()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("GetWarp: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var storedData map[string]string
|
|
|
|
|
+ if err := json.Unmarshal([]byte(stored), &storedData); err != nil {
|
|
|
|
|
+ t.Fatalf("unmarshal stored warp: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if storedData["license_key"] != license {
|
|
|
|
|
+ t.Errorf("stored license_key = %q, want %q (re-apply failure must not delete the key)", storedData["license_key"], license)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The response must warn the user instead of silently succeeding.
|
|
|
|
|
+ var parsed struct {
|
|
|
|
|
+ Data map[string]string `json:"data"`
|
|
|
|
|
+ Warning string `json:"warning"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
|
|
|
|
|
+ t.Fatalf("unmarshal response: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if parsed.Warning == "" {
|
|
|
|
|
+ t.Error("response missing warning about failed license re-apply")
|
|
|
|
|
+ }
|
|
|
|
|
+ if parsed.Data["license_key"] != license {
|
|
|
|
|
+ t.Errorf("response license_key = %q, want %q", parsed.Data["license_key"], license)
|
|
|
|
|
+ }
|
|
|
|
|
+ if licCalls.Load() != 1 {
|
|
|
|
|
+ t.Errorf("license re-apply calls = %d, want 1", licCalls.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|