host_test.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. package controller
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "path/filepath"
  8. "strconv"
  9. "testing"
  10. "github.com/gin-contrib/sessions"
  11. "github.com/gin-contrib/sessions/cookie"
  12. "github.com/gin-gonic/gin"
  13. "github.com/op/go-logging"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  16. xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
  17. )
  18. func newHostTestDB(t *testing.T) {
  19. t.Helper()
  20. // I18nWeb logs a warning when the localizer is absent (as in tests); the
  21. // logger must be initialised so that warning does not nil-panic.
  22. xuilogger.InitLogger(logging.ERROR)
  23. gin.SetMode(gin.TestMode)
  24. dbDir := t.TempDir()
  25. t.Setenv("XUI_DB_FOLDER", dbDir)
  26. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  27. t.Fatalf("InitDB: %v", err)
  28. }
  29. t.Cleanup(func() { _ = database.CloseDB() })
  30. }
  31. type hostEnvelope struct {
  32. Success bool `json:"success"`
  33. Msg string `json:"msg"`
  34. Obj json.RawMessage `json:"obj"`
  35. }
  36. func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any) hostEnvelope {
  37. t.Helper()
  38. var rdr *bytes.Reader
  39. if body != nil {
  40. b, _ := json.Marshal(body)
  41. rdr = bytes.NewReader(b)
  42. } else {
  43. rdr = bytes.NewReader(nil)
  44. }
  45. req := httptest.NewRequest(method, path, rdr)
  46. req.Header.Set("Content-Type", "application/json")
  47. w := httptest.NewRecorder()
  48. engine.ServeHTTP(w, req)
  49. if w.Code != http.StatusOK {
  50. t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
  51. }
  52. var env hostEnvelope
  53. if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
  54. t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
  55. }
  56. return env
  57. }
  58. // TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
  59. // the {success,msg,obj} envelope convention through the registered routes.
  60. func TestHostController_AddListGetDelete(t *testing.T) {
  61. newHostTestDB(t)
  62. engine := gin.New()
  63. NewHostController(engine.Group("/panel/api/hosts"))
  64. ib := &model.Inbound{Tag: "ctl", Enable: true, Port: 5443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
  65. if err := database.GetDB().Create(ib).Error; err != nil {
  66. t.Fatalf("seed inbound: %v", err)
  67. }
  68. // add
  69. add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
  70. "inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443,
  71. })
  72. if !add.Success {
  73. t.Fatalf("add not successful: %s", add.Msg)
  74. }
  75. var created model.Host
  76. if err := json.Unmarshal(add.Obj, &created); err != nil {
  77. t.Fatalf("decode created host: %v", err)
  78. }
  79. if created.Id == 0 || created.Remark != "h1" {
  80. t.Fatalf("created host = %+v", created)
  81. }
  82. // list
  83. list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
  84. var hosts []model.Host
  85. if err := json.Unmarshal(list.Obj, &hosts); err != nil {
  86. t.Fatalf("decode list: %v", err)
  87. }
  88. if len(hosts) != 1 || hosts[0].Id != created.Id {
  89. t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id)
  90. }
  91. // get
  92. get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
  93. if !get.Success {
  94. t.Fatalf("get not successful: %s", get.Msg)
  95. }
  96. // del
  97. del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil)
  98. if !del.Success {
  99. t.Fatalf("del not successful: %s", del.Msg)
  100. }
  101. list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
  102. var hosts2 []model.Host
  103. _ = json.Unmarshal(list2.Obj, &hosts2)
  104. if len(hosts2) != 0 {
  105. t.Fatalf("after delete, list = %+v, want empty", hosts2)
  106. }
  107. }
  108. // TestHostController_AuthInherited mirrors production wiring: the hosts group is
  109. // nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
  110. // to a hosts route is rejected (401) — the auth is inherited, not re-declared.
  111. func TestHostController_AuthInherited(t *testing.T) {
  112. newHostTestDB(t)
  113. engine := gin.New()
  114. store := cookie.NewStore([]byte("host-auth-test-secret"))
  115. engine.Use(sessions.Sessions("3x-ui", store))
  116. a := &APIController{}
  117. api := engine.Group("/panel/api")
  118. api.Use(a.checkAPIAuth)
  119. NewHostController(api.Group("/hosts"))
  120. req := httptest.NewRequest(http.MethodGet, "/panel/api/hosts/list", nil)
  121. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  122. w := httptest.NewRecorder()
  123. engine.ServeHTTP(w, req)
  124. if w.Code != http.StatusUnauthorized {
  125. t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
  126. }
  127. }
  128. func itoa(i int) string {
  129. return strconv.Itoa(i)
  130. }