mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
266 lines
9.9 KiB
Go
266 lines
9.9 KiB
Go
|
|
package controller
|
||
|
|
|
||
|
|
import (
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
type routeDef struct {
|
||
|
|
Method string
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
|
||
|
|
// expectedRoutes lists every route documented in frontend/src/pages/api-docs/endpoints.js.
|
||
|
|
// Keep this in sync when adding new endpoints to the docs.
|
||
|
|
var expectedRoutes = []routeDef{
|
||
|
|
// Authentication — no prefix
|
||
|
|
{Method: "POST", Path: "/login"},
|
||
|
|
{Method: "GET", Path: "/logout"},
|
||
|
|
{Method: "GET", Path: "/csrf-token"},
|
||
|
|
{Method: "POST", Path: "/getTwoFactorEnable"},
|
||
|
|
|
||
|
|
// Inbounds API — prefix /panel/api/inbounds
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/list"},
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/get/:id"},
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/getClientTraffics/:email"},
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/getClientTrafficsById/:id"},
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/getSubLinks/:subId"},
|
||
|
|
{Method: "GET", Path: "/panel/api/inbounds/getClientLinks/:id/:email"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/add"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/del/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/update/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/setEnable/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/clientIps/:email"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/clearClientIps/:email"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/addClient"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/:id/copyClients"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/:id/delClient/:clientId"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/updateClient/:clientId"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/:id/resetClientTraffic/:email"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/resetAllTraffics"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/resetAllClientTraffics/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/delDepletedClients/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/import"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/onlines"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/lastOnline"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/updateClientTraffic/:email"},
|
||
|
|
{Method: "POST", Path: "/panel/api/inbounds/:id/delClientByEmail/:email"},
|
||
|
|
|
||
|
|
// Server API — prefix /panel/api/server
|
||
|
|
{Method: "GET", Path: "/panel/api/server/status"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/cpuHistory/:bucket"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/history/:metric/:bucket"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/xrayMetricsState"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/xrayMetricsHistory/:metric/:bucket"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/xrayObservatory"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/xrayObservatoryHistory/:tag/:bucket"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getXrayVersion"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getPanelUpdateInfo"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getConfigJson"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getDb"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getNewUUID"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getNewX25519Cert"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getNewmldsa65"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getNewmlkem768"},
|
||
|
|
{Method: "GET", Path: "/panel/api/server/getNewVlessEnc"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/stopXrayService"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/restartXrayService"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/installXray/:version"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/updatePanel"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/updateGeofile"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/updateGeofile/:fileName"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/logs/:count"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/xraylogs/:count"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/importDB"},
|
||
|
|
{Method: "POST", Path: "/panel/api/server/getNewEchCert"},
|
||
|
|
|
||
|
|
// Nodes API — prefix /panel/api/nodes
|
||
|
|
{Method: "GET", Path: "/panel/api/nodes/list"},
|
||
|
|
{Method: "GET", Path: "/panel/api/nodes/get/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/add"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/update/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/del/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/setEnable/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/test"},
|
||
|
|
{Method: "POST", Path: "/panel/api/nodes/probe/:id"},
|
||
|
|
{Method: "GET", Path: "/panel/api/nodes/history/:id/:metric/:bucket"},
|
||
|
|
|
||
|
|
// Custom Geo API — prefix /panel/api/custom-geo
|
||
|
|
{Method: "GET", Path: "/panel/api/custom-geo/list"},
|
||
|
|
{Method: "GET", Path: "/panel/api/custom-geo/aliases"},
|
||
|
|
{Method: "POST", Path: "/panel/api/custom-geo/add"},
|
||
|
|
{Method: "POST", Path: "/panel/api/custom-geo/update/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/custom-geo/delete/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/custom-geo/download/:id"},
|
||
|
|
{Method: "POST", Path: "/panel/api/custom-geo/update-all"},
|
||
|
|
|
||
|
|
// Backup — prefix /panel/api
|
||
|
|
{Method: "GET", Path: "/panel/api/backuptotgbot"},
|
||
|
|
|
||
|
|
// Settings API — prefix /panel/setting
|
||
|
|
{Method: "POST", Path: "/panel/setting/all"},
|
||
|
|
{Method: "POST", Path: "/panel/setting/defaultSettings"},
|
||
|
|
{Method: "POST", Path: "/panel/setting/update"},
|
||
|
|
{Method: "POST", Path: "/panel/setting/updateUser"},
|
||
|
|
{Method: "POST", Path: "/panel/setting/restartPanel"},
|
||
|
|
{Method: "GET", Path: "/panel/setting/getDefaultJsonConfig"},
|
||
|
|
{Method: "GET", Path: "/panel/setting/getApiToken"},
|
||
|
|
{Method: "POST", Path: "/panel/setting/regenerateApiToken"},
|
||
|
|
|
||
|
|
// Xray Settings API — prefix /panel/xray
|
||
|
|
{Method: "POST", Path: "/panel/xray/"},
|
||
|
|
{Method: "GET", Path: "/panel/xray/getDefaultJsonConfig"},
|
||
|
|
{Method: "GET", Path: "/panel/xray/getOutboundsTraffic"},
|
||
|
|
{Method: "GET", Path: "/panel/xray/getXrayResult"},
|
||
|
|
{Method: "POST", Path: "/panel/xray/update"},
|
||
|
|
{Method: "POST", Path: "/panel/xray/warp/:action"},
|
||
|
|
{Method: "POST", Path: "/panel/xray/nord/:action"},
|
||
|
|
{Method: "POST", Path: "/panel/xray/resetOutboundsTraffic"},
|
||
|
|
{Method: "POST", Path: "/panel/xray/testOutbound"},
|
||
|
|
|
||
|
|
// WebSocket
|
||
|
|
{Method: "GET", Path: "/ws"},
|
||
|
|
|
||
|
|
// Subscription server — separate server (not on main Gin engine)
|
||
|
|
// Documented in Subscription Server section but not tested here
|
||
|
|
// because the sub server is a separate Gin engine.
|
||
|
|
// {Method: "GET", Path: "/sub/:subid"},
|
||
|
|
// {Method: "GET", Path: "/json/:subid"},
|
||
|
|
// {Method: "GET", Path: "/clash/:subid"},
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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)\("([^"]+)"`)
|
||
|
|
|
||
|
|
func TestAPIRoutesDocumented(t *testing.T) {
|
||
|
|
// Build a set of documented routes for fast lookup
|
||
|
|
docSet := make(map[string]bool)
|
||
|
|
for _, r := range expectedRoutes {
|
||
|
|
key := r.Method + " " + r.Path
|
||
|
|
if docSet[key] {
|
||
|
|
t.Errorf("Duplicate documented route: %s", key)
|
||
|
|
}
|
||
|
|
docSet[key] = true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Walk the web directory to find all Go files with route definitions
|
||
|
|
controllerDir, err := filepath.Abs(".")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("failed to get current dir: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect all routes from the controller files
|
||
|
|
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 "xui.go":
|
||
|
|
basePath = "/panel"
|
||
|
|
case "api.go":
|
||
|
|
basePath = "/panel/api"
|
||
|
|
case "inbound.go":
|
||
|
|
basePath = "/panel/api/inbounds"
|
||
|
|
case "server.go":
|
||
|
|
basePath = "/panel/api/server"
|
||
|
|
case "node.go":
|
||
|
|
basePath = "/panel/api/nodes"
|
||
|
|
case "setting.go":
|
||
|
|
basePath = "/panel/setting"
|
||
|
|
case "xray_setting.go":
|
||
|
|
basePath = "/panel/xray"
|
||
|
|
case "custom_geo.go":
|
||
|
|
basePath = "/panel/api/custom-geo"
|
||
|
|
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"})
|
||
|
|
|
||
|
|
// Check each source route against the documented set
|
||
|
|
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/nodes": true, "/panel/settings": true,
|
||
|
|
"/panel/xray": true, "/panel/api-docs": true,
|
||
|
|
}
|
||
|
|
if spaPages[r.Path] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
// Skip /panel/csrf-token (documented under auth)
|
||
|
|
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.js: %s %s", r.Method, r.Path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Report undocumented documented routes
|
||
|
|
extraInDocs := 0
|
||
|
|
for _, r := range expectedRoutes {
|
||
|
|
key := r.Method + " " + r.Path
|
||
|
|
if !sourceSet[key] {
|
||
|
|
extraInDocs++
|
||
|
|
t.Logf("Documented route not found in source (perhaps deleted or moved): %s %s", r.Method, r.Path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d, extra in docs: %d",
|
||
|
|
len(sourceSet), len(docSet), foundInDoc, missingFromDocs, extraInDocs)
|
||
|
|
|
||
|
|
if missingFromDocs > 0 {
|
||
|
|
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
|
||
|
|
}
|
||
|
|
}
|