mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
33f72f8f4a
* test(web): pin the endpoints.ts registry to the actual Gin routes endpoints.ts is a hand-maintained registry and nothing checked it against the router: an omitted API route silently vanishes from the generated OpenAPI docs, and an entry for a removed route documents an endpoint that 404s. Two new tests construct the real router against a throwaway DB and diff the /panel/api surface both ways. The check found one gap on arrival: GET /panel/api/openapi.json — the endpoint that serves the docs — was itself undocumented. Registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one Three things from the review, in severity order. The bot found that GET /panel/api/openapi.json was registered on the base-path group one line before the /panel/api group installs checkAPIAuth, so Gin's snapshot of the parent chain meant the whole admin API surface plus build version was fetchable without a session — while this very PR was about to document it as auth-required. Move the registration inside the authed api group. Verified: unauthenticated it now 404s exactly like server/status (was 200), and a logged-in session still serves it 200, so the docs page is unaffected. The existing api_docs_test.go already checked the forward direction by regex-scanning controller source against a hand-maintained per-file path switch — which is why it missed this web.go-registered route, and whose fall-through default silently mis-paths any unlisted controller file. The new router-based test is a strict superset, so fold in the extra surface it guarded (/login, /logout, /csrf-token, /getTwoFactorEnable, /ws) and delete the old test rather than run two. Harden the endpoints.ts parser: pair each method with the next path sequentially instead of a brace-crossing regex, and fail loudly when the parsed count doesn't match the declared method fields. Construct the server once across both subtests, cancel it, and restore the previous global on cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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|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))
|
|
}
|
|
})
|
|
}
|