Просмотр исходного кода

fix(api): authenticate GET /panel/api/openapi.json + pin the route registry to the router (#6133)

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
PathGao 8 часов назад
Родитель
Сommit
33f72f8f4a

+ 30 - 0
frontend/public/openapi.json

@@ -3980,6 +3980,36 @@
         }
       }
     },
+    "/panel/api/openapi.json": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
+        "operationId": "get_panel_api_openapi_json",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/status": {
       "get": {
         "tags": [

+ 5 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
     description:
       'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
     endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/openapi.json',
+        summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/status',

+ 2 - 0
internal/web/controller/api.go

@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	api.Use(middleware.ConfigEnvelopeMiddleware())
 	api.Use(middleware.CSRFMiddleware())
 
+	api.GET("/openapi.json", ServeOpenAPISpec)
+
 	// Inbounds API
 	inbounds := api.Group("/inbounds")
 	a.inboundController = NewInboundController(inbounds)

+ 0 - 166
internal/web/controller/api_docs_test.go

@@ -1,166 +0,0 @@
-package controller
-
-import (
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"testing"
-)
-
-type routeDef struct {
-	Method string
-	Path   string
-}
-
-// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
-var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
-
-// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
-var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
-
-// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
-// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
-// placeholders (paths starting with /{...}) are skipped because they aren't
-// registered on the main Gin engine.
-func buildDocSet(t *testing.T) map[string]bool {
-	t.Helper()
-	controllerDir, err := filepath.Abs(".")
-	if err != nil {
-		t.Fatalf("failed to get current dir: %v", err)
-	}
-	endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
-	data, err := os.ReadFile(endpointsPath)
-	if err != nil {
-		t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
-	}
-	docSet := make(map[string]bool)
-	for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
-		method, path := m[1], m[2]
-		if method == "WS" {
-			continue
-		}
-		if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
-			continue
-		}
-		docSet[method+" "+path] = true
-	}
-	if len(docSet) == 0 {
-		t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
-	}
-	return docSet
-}
-
-func TestAPIRoutesDocumented(t *testing.T) {
-	docSet := buildDocSet(t)
-
-	controllerDir, err := filepath.Abs(".")
-	if err != nil {
-		t.Fatalf("failed to get current dir: %v", err)
-	}
-
-	var allRoutes []routeDef
-
-	entries, err := os.ReadDir(controllerDir)
-	if err != nil {
-		t.Fatalf("failed to read controller dir: %v", err)
-	}
-
-	for _, entry := range entries {
-		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
-			continue
-		}
-		data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
-		if err != nil {
-			t.Fatalf("failed to read %s: %v", entry.Name(), err)
-		}
-		src := string(data)
-
-		// Determine the base path for this file based on its initRouter patterns
-		basePath := ""
-		switch entry.Name() {
-		case "index.go":
-			basePath = ""
-		case "spa.go":
-			basePath = "/panel"
-		case "api.go":
-			basePath = "/panel/api"
-		case "inbound.go":
-			basePath = "/panel/api/inbounds"
-		case "client.go":
-			basePath = "/panel/api/clients"
-		case "group.go":
-			basePath = "/panel/api/clients"
-		case "server.go":
-			basePath = "/panel/api/server"
-		case "node.go":
-			basePath = "/panel/api/nodes"
-		case "host.go":
-			basePath = "/panel/api/hosts"
-		case "setting.go":
-			basePath = "/panel/api/setting"
-		case "xray_setting.go":
-			basePath = "/panel/api/xray"
-		case "websocket.go":
-			basePath = ""
-		}
-
-		// Find all route registrations
-		matches := routePattern.FindAllStringSubmatch(src, -1)
-		for _, m := range matches {
-			method := m[2]
-			path := strings.TrimSpace(m[3])
-			if basePath == "" {
-				allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
-			} else {
-				fullPath := basePath + path
-				allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
-			}
-		}
-	}
-
-	// The WebSocket route /ws is registered in web/web.go (not a controller file)
-	allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
-
-	missingFromDocs := 0
-	foundInDoc := 0
-	sourceSet := make(map[string]bool)
-
-	for _, r := range allRoutes {
-		key := r.Method + " " + r.Path
-		// Skip SPA page routes (these are UI pages, not API endpoints)
-		spaPages := map[string]bool{
-			"/": true, "/panel/": true, "/panel/inbounds": true,
-			"/panel/clients": true, "/panel/groups": true,
-			"/panel/nodes": true, "/panel/settings": true,
-			"/panel/xray": true, "/panel/outbound": true,
-			"/panel/routing": true, "/panel/api-docs": true,
-		}
-		if spaPages[r.Path] {
-			continue
-		}
-		// Skip /panel/csrf-token (documented under auth as /csrf-token)
-		if r.Path == "/panel/csrf-token" {
-			continue
-		}
-		// Skip Chrome DevTools route
-		if strings.Contains(r.Path, ".well-known") {
-			continue
-		}
-
-		sourceSet[key] = true
-		if docSet[key] {
-			foundInDoc++
-		} else {
-			missingFromDocs++
-			t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
-		}
-	}
-
-	t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
-		len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
-
-	if missingFromDocs > 0 {
-		t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
-	}
-}

+ 139 - 0
internal/web/routes_contract_test.go

@@ -0,0 +1,139 @@
+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))
+		}
+	})
+}

+ 0 - 1
internal/web/web.go

@@ -247,7 +247,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	s.index = controller.NewIndexController(g)
 	s.panel = controller.NewXUIController(g)
-	g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
 	s.api = controller.NewAPIController(g)
 
 	// Initialize WebSocket hub