spa_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. package controller
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "testing/fstest"
  8. "github.com/gin-contrib/sessions"
  9. "github.com/gin-contrib/sessions/cookie"
  10. "github.com/gin-gonic/gin"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. "github.com/mhsanaei/3x-ui/v3/internal/web/locale"
  13. "github.com/mhsanaei/3x-ui/v3/internal/web/session"
  14. )
  15. func newSPAFallbackTestEngine(t *testing.T) *gin.Engine {
  16. return newSPAFallbackTestEngineWithBasePath(t, "/admin-random/")
  17. }
  18. func newSPAFallbackTestEngineWithBasePath(t *testing.T, basePath string) *gin.Engine {
  19. t.Helper()
  20. gin.SetMode(gin.TestMode)
  21. oldDistFS := distFS
  22. SetDistFS(fstest.MapFS{
  23. "dist/index.html": {Data: []byte(`<!doctype html><html><head></head><body>spa shell</body></html>`)},
  24. })
  25. t.Cleanup(func() { SetDistFS(oldDistFS) })
  26. engine := gin.New()
  27. engine.Use(sessions.Sessions("3x-ui", cookie.NewStore([]byte("spa-fallback-test-secret"))))
  28. engine.Use(func(c *gin.Context) {
  29. c.Set("base_path", basePath)
  30. c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
  31. if c.GetHeader("X-Test-Login") == "1" {
  32. session.SetAPIAuthUser(c, &model.User{Id: 1, Username: "test"})
  33. }
  34. c.Next()
  35. })
  36. ctrl := NewXUIController(engine.Group(basePath))
  37. engine.NoRoute(func(c *gin.Context) {
  38. if ctrl.HandleNoRoutePanelSPA(c) {
  39. return
  40. }
  41. c.AbortWithStatus(http.StatusNotFound)
  42. })
  43. return engine
  44. }
  45. func TestPanelSPAFallbackServesRootBasePath(t *testing.T) {
  46. engine := newSPAFallbackTestEngineWithBasePath(t, "/")
  47. req := httptest.NewRequest(http.MethodGet, "/panel/hosts", nil)
  48. req.Header.Set("Accept", "text/html")
  49. req.Header.Set("X-Test-Login", "1")
  50. w := httptest.NewRecorder()
  51. engine.ServeHTTP(w, req)
  52. if w.Code != http.StatusOK {
  53. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  54. }
  55. if !strings.Contains(w.Body.String(), "spa shell") {
  56. t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
  57. }
  58. }
  59. func TestPanelSPAFallbackServesAuthenticatedClientRoutes(t *testing.T) {
  60. engine := newSPAFallbackTestEngine(t)
  61. for _, target := range []string{
  62. "/admin-random/panel/hosts",
  63. "/admin-random/panel/some/future/route",
  64. } {
  65. t.Run(target, func(t *testing.T) {
  66. req := httptest.NewRequest(http.MethodGet, target, nil)
  67. req.Header.Set("Accept", "text/html")
  68. req.Header.Set("X-Test-Login", "1")
  69. w := httptest.NewRecorder()
  70. engine.ServeHTTP(w, req)
  71. if w.Code != http.StatusOK {
  72. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  73. }
  74. if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
  75. t.Fatalf("Content-Type = %q, want text/html", ct)
  76. }
  77. if !strings.Contains(w.Body.String(), "spa shell") {
  78. t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
  79. }
  80. })
  81. }
  82. }
  83. func TestPanelSPAFallbackPreservesAuthSemantics(t *testing.T) {
  84. engine := newSPAFallbackTestEngine(t)
  85. t.Run("browser redirects to login", func(t *testing.T) {
  86. req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
  87. req.Header.Set("Accept", "text/html")
  88. w := httptest.NewRecorder()
  89. engine.ServeHTTP(w, req)
  90. if w.Code != http.StatusTemporaryRedirect {
  91. t.Fatalf("status = %d, want 307", w.Code)
  92. }
  93. if loc := w.Header().Get("Location"); loc != "/admin-random/" {
  94. t.Fatalf("Location = %q, want /admin-random/", loc)
  95. }
  96. })
  97. t.Run("ajax gets json unauthorized", func(t *testing.T) {
  98. req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
  99. req.Header.Set("Accept", "text/html")
  100. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  101. w := httptest.NewRecorder()
  102. engine.ServeHTTP(w, req)
  103. if w.Code != http.StatusUnauthorized {
  104. t.Fatalf("status = %d, want 401", w.Code)
  105. }
  106. if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
  107. t.Fatalf("Content-Type = %q, want application/json", ct)
  108. }
  109. })
  110. }
  111. func TestPanelSPAFallbackExclusions(t *testing.T) {
  112. engine := newSPAFallbackTestEngine(t)
  113. for _, tc := range []struct {
  114. target string
  115. want int
  116. }{
  117. {target: "/admin-random/panel/api", want: http.StatusNotFound},
  118. {target: "/admin-random/panel/api/unknown", want: http.StatusNotFound},
  119. {target: "/admin-random/panel/csrf-token/", want: http.StatusMovedPermanently},
  120. {target: "/admin-random/panel/missing.js", want: http.StatusNotFound},
  121. } {
  122. t.Run(tc.target, func(t *testing.T) {
  123. req := httptest.NewRequest(http.MethodGet, tc.target, nil)
  124. req.Header.Set("Accept", "text/html")
  125. req.Header.Set("X-Test-Login", "1")
  126. w := httptest.NewRecorder()
  127. engine.ServeHTTP(w, req)
  128. if w.Code != tc.want {
  129. t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
  130. }
  131. if strings.Contains(w.Body.String(), "spa shell") {
  132. t.Fatalf("excluded route was served by SPA fallback: %s", w.Body.String())
  133. }
  134. })
  135. }
  136. }
  137. func TestPanelCSRFTokenRemainsExplicit(t *testing.T) {
  138. engine := newSPAFallbackTestEngine(t)
  139. req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/csrf-token", nil)
  140. req.Header.Set("Accept", "text/html")
  141. req.Header.Set("X-Test-Login", "1")
  142. w := httptest.NewRecorder()
  143. engine.ServeHTTP(w, req)
  144. if w.Code != http.StatusOK {
  145. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  146. }
  147. if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
  148. t.Fatalf("Content-Type = %q, want application/json", ct)
  149. }
  150. if strings.Contains(w.Body.String(), "spa shell") {
  151. t.Fatalf("csrf-token was served by SPA fallback: %s", w.Body.String())
  152. }
  153. }
  154. func TestPanelSPAFallbackPredicate(t *testing.T) {
  155. oldDistFS := distFS
  156. SetDistFS(fstest.MapFS{})
  157. t.Cleanup(func() { SetDistFS(oldDistFS) })
  158. cases := []struct {
  159. name string
  160. method string
  161. path string
  162. accept string
  163. want bool
  164. }{
  165. {name: "panel root", method: http.MethodGet, path: "/admin-random/panel", accept: "text/html", want: true},
  166. {name: "panel descendant", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "*/*", want: true},
  167. {name: "empty accept", method: http.MethodGet, path: "/admin-random/panel/future", want: true},
  168. {name: "post excluded", method: http.MethodPost, path: "/admin-random/panel/hosts", accept: "text/html"},
  169. {name: "json accept excluded", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "application/json"},
  170. {name: "api root excluded", method: http.MethodGet, path: "/admin-random/panel/api", accept: "text/html"},
  171. {name: "api descendant excluded", method: http.MethodGet, path: "/admin-random/panel/api/unknown", accept: "text/html"},
  172. {name: "csrf excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token", accept: "text/html"},
  173. {name: "csrf descendant excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token/", accept: "text/html"},
  174. {name: "file excluded", method: http.MethodGet, path: "/admin-random/panel/missing.css", accept: "text/html"},
  175. {name: "outside panel excluded", method: http.MethodGet, path: "/admin-random/hosts", accept: "text/html"},
  176. {name: "dotted email route param served", method: http.MethodGet, path: "/admin-random/panel/clients/[email protected]", accept: "text/html", want: true},
  177. {name: "dotted version route param served", method: http.MethodGet, path: "/admin-random/panel/sub/1.2.3", accept: "text/html", want: true},
  178. {name: "uppercase asset extension excluded", method: http.MethodGet, path: "/admin-random/panel/app.JS", accept: "text/html"},
  179. }
  180. for _, tc := range cases {
  181. t.Run(tc.name, func(t *testing.T) {
  182. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  183. c.Set("base_path", "/admin-random/")
  184. req := httptest.NewRequest(tc.method, tc.path, nil)
  185. if tc.accept != "" {
  186. req.Header.Set("Accept", tc.accept)
  187. }
  188. c.Request = req
  189. if got := isPanelSPAFallbackRequest(c); got != tc.want {
  190. t.Fatalf("isPanelSPAFallbackRequest() = %v, want %v", got, tc.want)
  191. }
  192. })
  193. }
  194. }