mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +00:00
ed6bc1d898
Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts.
140 lines
4.2 KiB
Go
140 lines
4.2 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
|
)
|
|
|
|
/*
|
|
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
|
|
API route omitted there silently vanishes from the generated OpenAPI docs,
|
|
and an entry for a removed route documents an endpoint that 404s. This test
|
|
constructs the real router and diffs it against the registry both ways.
|
|
|
|
Scope: everything under /panel/api/ plus the session-auth surface the
|
|
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
|
|
/ws). SPA page routes are UI, not API, and stay out; registry paths that
|
|
start with "/{" describe the standalone subscription server, which this
|
|
engine does not serve.
|
|
*/
|
|
|
|
var contractExtraRoutes = map[string]bool{
|
|
"POST /login": true,
|
|
"POST /logout": true,
|
|
"GET /csrf-token": true,
|
|
"POST /getTwoFactorEnable": true,
|
|
"GET /ws": true,
|
|
}
|
|
|
|
func inContractScope(method, path string) bool {
|
|
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
|
|
}
|
|
|
|
func registeredContractRoutes(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.CloseDB() })
|
|
|
|
previous := global.GetWebServer()
|
|
s := NewServer()
|
|
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
|
|
global.SetWebServer(s)
|
|
t.Cleanup(func() {
|
|
s.cancel()
|
|
global.SetWebServer(previous)
|
|
})
|
|
|
|
engine, err := s.initRouter()
|
|
if err != nil {
|
|
t.Fatalf("init router: %v", err)
|
|
}
|
|
routes := make(map[string]bool)
|
|
for _, r := range engine.Routes() {
|
|
routes[r.Method+" "+r.Path] = true
|
|
}
|
|
if len(routes) == 0 {
|
|
t.Fatal("no routes registered; router construction is broken")
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func documentedContractRoutes(t *testing.T) map[string]bool {
|
|
t.Helper()
|
|
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
|
|
if err != nil {
|
|
t.Fatalf("read endpoints.ts: %v", err)
|
|
}
|
|
text := string(source)
|
|
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|HEAD|WS)'`)
|
|
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
|
|
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
|
|
if declared := strings.Count(text, "method: '"); len(methods) != declared {
|
|
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
|
|
}
|
|
docs := make(map[string]bool)
|
|
for i, m := range methods {
|
|
segmentEnd := len(text)
|
|
if i+1 < len(methods) {
|
|
segmentEnd = methods[i+1][0]
|
|
}
|
|
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
|
|
if pathMatch == nil {
|
|
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
|
|
}
|
|
method := text[m[2]:m[3]]
|
|
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
|
|
continue
|
|
}
|
|
docs[method+" "+pathMatch[1]] = true
|
|
}
|
|
if len(docs) == 0 {
|
|
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
|
|
}
|
|
return docs
|
|
}
|
|
|
|
func TestRouteRegistryContract(t *testing.T) {
|
|
registered := registeredContractRoutes(t)
|
|
documented := documentedContractRoutes(t)
|
|
|
|
t.Run("every API route is documented", func(t *testing.T) {
|
|
var missing []string
|
|
for route := range registered {
|
|
fields := strings.Fields(route)
|
|
if inContractScope(fields[0], fields[1]) && !documented[route] {
|
|
missing = append(missing, route)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
for _, route := range missing {
|
|
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
|
|
}
|
|
})
|
|
|
|
t.Run("every documented route is registered", func(t *testing.T) {
|
|
var stale []string
|
|
for route := range documented {
|
|
if !registered[route] {
|
|
stale = append(stale, route)
|
|
}
|
|
}
|
|
sort.Strings(stale)
|
|
for _, route := range stale {
|
|
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
|
|
}
|
|
})
|
|
}
|