routes_contract_test.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. package web
  2. import (
  3. "fmt"
  4. "os"
  5. "path/filepath"
  6. "regexp"
  7. "sort"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/robfig/cron/v3"
  12. "github.com/mhsanaei/3x-ui/v3/internal/database"
  13. "github.com/mhsanaei/3x-ui/v3/internal/web/global"
  14. )
  15. /*
  16. frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
  17. API route omitted there silently vanishes from the generated OpenAPI docs,
  18. and an entry for a removed route documents an endpoint that 404s. This test
  19. constructs the real router and diffs it against the registry both ways.
  20. Scope: everything under /panel/api/ plus the session-auth surface the
  21. registry also documents (/login, /logout, /csrf-token, /sponsors,
  22. /getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out;
  23. registry paths that start with "/{" describe the standalone subscription
  24. server, which this engine does not serve.
  25. */
  26. var contractExtraRoutes = map[string]bool{
  27. "POST /login": true,
  28. "POST /logout": true,
  29. "GET /csrf-token": true,
  30. "GET /sponsors": true,
  31. "POST /getTwoFactorEnable": true,
  32. "GET /ws": true,
  33. }
  34. func inContractScope(method, path string) bool {
  35. return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
  36. }
  37. func registeredContractRoutes(t *testing.T) map[string]bool {
  38. t.Helper()
  39. if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
  40. t.Fatalf("init db: %v", err)
  41. }
  42. t.Cleanup(func() { _ = database.CloseDB() })
  43. previous := global.GetWebServer()
  44. s := NewServer()
  45. s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
  46. global.SetWebServer(s)
  47. t.Cleanup(func() {
  48. s.cancel()
  49. global.SetWebServer(previous)
  50. })
  51. engine, err := s.initRouter()
  52. if err != nil {
  53. t.Fatalf("init router: %v", err)
  54. }
  55. routes := make(map[string]bool)
  56. for _, r := range engine.Routes() {
  57. routes[r.Method+" "+r.Path] = true
  58. }
  59. if len(routes) == 0 {
  60. t.Fatal("no routes registered; router construction is broken")
  61. }
  62. return routes
  63. }
  64. func documentedContractRoutes(t *testing.T) map[string]bool {
  65. t.Helper()
  66. source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
  67. if err != nil {
  68. t.Fatalf("read endpoints.ts: %v", err)
  69. }
  70. text := string(source)
  71. methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|HEAD|WS)'`)
  72. pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
  73. methods := methodRe.FindAllStringSubmatchIndex(text, -1)
  74. if declared := strings.Count(text, "method: '"); len(methods) != declared {
  75. t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
  76. }
  77. docs := make(map[string]bool)
  78. for i, m := range methods {
  79. segmentEnd := len(text)
  80. if i+1 < len(methods) {
  81. segmentEnd = methods[i+1][0]
  82. }
  83. pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
  84. if pathMatch == nil {
  85. t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
  86. }
  87. method := text[m[2]:m[3]]
  88. if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
  89. continue
  90. }
  91. docs[method+" "+pathMatch[1]] = true
  92. }
  93. if len(docs) == 0 {
  94. t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
  95. }
  96. return docs
  97. }
  98. func TestRouteRegistryContract(t *testing.T) {
  99. registered := registeredContractRoutes(t)
  100. documented := documentedContractRoutes(t)
  101. t.Run("every API route is documented", func(t *testing.T) {
  102. var missing []string
  103. for route := range registered {
  104. fields := strings.Fields(route)
  105. if inContractScope(fields[0], fields[1]) && !documented[route] {
  106. missing = append(missing, route)
  107. }
  108. }
  109. sort.Strings(missing)
  110. for _, route := range missing {
  111. t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
  112. }
  113. })
  114. t.Run("every documented route is registered", func(t *testing.T) {
  115. var stale []string
  116. for route := range documented {
  117. if !registered[route] {
  118. stale = append(stale, route)
  119. }
  120. }
  121. sort.Strings(stale)
  122. for _, route := range stale {
  123. t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
  124. }
  125. })
  126. }