package controller import ( "bytes" "compress/gzip" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strconv" "strings" "testing" "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" ws "github.com/gorilla/websocket" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service/probe" "github.com/mhsanaei/3x-ui/v3/internal/web/session" ) // Item 7 B3 contract tests: the five public endpoints, audience // discipline (anonymous callers never see addresses / hostnames / // private remarks), theme hosting + fallback, theme config round // trip, and the probe-meta edit endpoints. Field sets are asserted // exactly against the hub's node_view / metrics definitions. type probeTestRig struct { engine *gin.Engine dbDir string store *probe.Store cookies []*http.Cookie } func newProbeTestRig(t *testing.T) *probeTestRig { t.Helper() gin.SetMode(gin.TestMode) dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { t.Fatalf("InitDB: %v", err) } t.Cleanup(func() { _ = database.CloseDB() }) st := probe.Default() if st == nil { t.Fatal("probe store unavailable") } service.InvalidateProbeSnapshot() engine := gin.New() store := cookie.NewStore([]byte("probe-public-test-secret")) engine.Use(sessions.Sessions("3x-ui", store)) engine.Use(func(c *gin.Context) { c.Set("base_path", "/panel/") }) a := &APIController{} engine.GET("/test-login", func(c *gin.Context) { var user model.User if err := database.GetDB().First(&user).Error; err != nil { c.Status(http.StatusInternalServerError) return } if err := session.SetLoginUser(c, &user); err != nil { c.Status(http.StatusInternalServerError) return } c.Status(http.StatusOK) }) pc := NewPublicProbeController(engine) agentAdmin := engine.Group("/panel/api/agent") agentAdmin.Use(a.checkAPIAuth) NewProbeMetaController(agentAdmin) engine.NoRoute(func(c *gin.Context) { if pc.HandleFallback(c) { return } c.AbortWithStatus(http.StatusNotFound) }) rig := &probeTestRig{engine: engine, dbDir: dbDir, store: st} // Log in once; handlers that need the anonymous side just skip the // cookies. rec := rig.do(http.MethodGet, "/test-login", nil, false) if rec.Code != http.StatusOK { t.Fatalf("test login: %d", rec.Code) } rig.cookies = rec.Result().Cookies() if len(rig.cookies) == 0 { t.Fatal("test login set no session cookie") } return rig } func (r *probeTestRig) do(method, path string, body []byte, authed bool) *httptest.ResponseRecorder { var rd io.Reader if body != nil { rd = bytes.NewReader(body) } req := httptest.NewRequest(method, path, rd) if body != nil { req.Header.Set("Content-Type", "application/json") } if authed { for _, ck := range r.cookies { req.AddCookie(ck) } } rec := httptest.NewRecorder() r.engine.ServeHTTP(rec, req) return rec } func (r *probeTestRig) setSetting(t *testing.T, key, value string) { t.Helper() db := database.GetDB() var row model.Setting err := db.Where("key = ?", key).First(&row).Error if database.IsNotFound(err) { if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil { t.Fatalf("set setting %s: %v", key, err) } return } if err != nil { t.Fatalf("read setting %s: %v", key, err) } row.Value = value if err := db.Save(&row).Error; err != nil { t.Fatalf("save setting %s: %v", key, err) } } // seedAgent creates a bound node + agent pair with a live probe // snapshot, a meta row and a traffic ledger line. func (r *probeTestRig) seedAgent(t *testing.T, name string, public bool) (agentID int64, nodeID int) { t.Helper() db := database.GetDB() node := &model.Node{Name: name, Remark: "private-remark-" + name} if err := db.Create(node).Error; err != nil { t.Fatalf("seed node: %v", err) } now := time.Now() agent := &model.AgentNode{ TokenHash: "hash-" + name, TokenPrefix: "tp-", Name: "agent-" + name, NodeID: &node.Id, LastSeen: now.UnixMilli(), AgentVersion: "A1.0.0", IPv4: "203.0.113.9", SeenIPv4: "198.51.100.4", ProbeHostname: "secret-host-" + name, ProbeOS: "Debian GNU/Linux 13", ProbeKernel: "6.12.1", ProbeArch: "amd64", ProbeVirt: "kvm", ProbeCPUName: "Test CPU", ProbeCPUCores: 4, ProbeIface: "eth0", ProbeMemTotal: 4_000_000_000, ProbeMemUsed: 1_500_000_000, ProbeSwapTotal: 1_000_000_000, ProbeSwapUsed: 5_000_000, ProbeDiskTotal: 8_000_000_000, ProbeDiskUsed: 3_000_000_000, ProbeNetRxTot: 111_000, ProbeNetTxTot: 222_000, ProbeNetRx: 1000, ProbeNetTx: 2000, ProbeTCP: 42, ProbeUDP: 7, ProbeProcs: 123, ProbeCPU: 12.5, UptimeSecs: 3600, ProbeAt: now.UnixMilli(), } if err := db.Create(agent).Error; err != nil { t.Fatalf("seed agent: %v", err) } id := int64(agent.Id) r.store.DeleteAgentData(id) // the default store is process-wide; start clean exp := now.AddDate(0, 1, 0).Format("2006-01-02") meta := probe.DefaultMeta(id) meta.Public = public meta.Price = 10 meta.ExpiresAt = &exp meta.GroupName = "grp" meta.PublicRemark = "shown publicly" if err := probe.SaveMeta(meta); err != nil { t.Fatalf("seed meta: %v", err) } month := probe.PeriodStart(now, 1).Format("2006-01-02") day := now.Format("2006-01-02") if _, err := r.store.DB().Exec(`INSERT INTO probe_traffic (agent_id, total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start) VALUES (?, 111, 222, 33, 44, ?, 5, 6, ?)`, id, month, day); err != nil { t.Fatalf("seed traffic: %v", err) } return id, node.Id } func keySet(t *testing.T, body []byte, path ...string) map[string]bool { t.Helper() var v any if err := json.Unmarshal(body, &v); err != nil { t.Fatalf("decode: %v", err) } cur := v for _, p := range path { m, ok := cur.(map[string]any) if !ok { t.Fatalf("path %v: not an object at %q", path, p) } cur = m[p] } m, ok := cur.(map[string]any) if !ok { t.Fatalf("path %v: not an object", path) } out := map[string]bool{} for k := range m { out[k] = true } return out } func assertKeys(t *testing.T, got map[string]bool, want []string) { t.Helper() w := map[string]bool{} for _, k := range want { w[k] = true } var extra, missing []string for k := range got { if !w[k] { extra = append(extra, k) } } for k := range w { if !got[k] { missing = append(missing, k) } } sort.Strings(extra) sort.Strings(missing) if len(extra) > 0 || len(missing) > 0 { t.Fatalf("key set mismatch: extra=%v missing=%v", extra, missing) } } var anonNodeKeys = []string{ "id", "name", "country", "group", "public_remark", "sort", "public", "online", "last_seen", "last_seen_ago", "metrics", "os", "kernel", "arch", "virt", "cpu_name", "cpu_cores", "mem_total", "swap_total", "disk_total", "agent_version", "price", "currency", "billing_cycle", "expires_at", "expires_in", "traffic_limit", "traffic_mode", "traffic_reset_day", "total_rx", "total_tx", "month_rx", "month_tx", "month_used", "month_start", "day_rx", "day_tx", } var metricKeys = []string{ "uptime", "cpu", "load", "mem_total", "mem_used", "swap_total", "swap_used", "disk_total", "disk_used", "net_rx", "net_tx", "tcp", "udp", "procs", "total_rx", "total_tx", "month_rx", "month_tx", } var historyKeys = []string{ "ts", "cpu", "mem_used", "disk_used", "net_rx", "net_tx", "net_rx_max", "net_tx_max", "cpu_max", "minutes", "swap_used", "tcp", "udp", "procs", } func TestProbePublicContract(t *testing.T) { rig := newProbeTestRig(t) pubID, _ := rig.seedAgent(t, "PubNode", true) privID, privNodeID := rig.seedAgent(t, "PrivNode", false) service.InvalidateProbeSnapshot() // --- public page off (the shipped default) --- rec := rig.do(http.MethodGet, "/api/me", nil, false) if rec.Code != 200 { t.Fatalf("me anon: %d", rec.Code) } assertKeys(t, keySet(t, rec.Body.Bytes()), []string{"authed", "github", "site_name", "public_page", "history_days", "site"}) var me map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &me) if me["authed"] != false || me["public_page"] != false || me["site_name"] != "Monitor" || me["history_days"] != float64(7) || me["github"] != false || me["site"] != "" { t.Fatalf("me defaults wrong: %v", me) } if rec := rig.do(http.MethodGet, "/api/nodes", nil, false); rec.Code != 401 { t.Fatalf("nodes anon with page off: %d", rec.Code) } if rec := rig.do(http.MethodGet, "/api/nodes/1/metrics", nil, false); rec.Code != 401 { t.Fatalf("metrics anon with page off: %d", rec.Code) } // --- public page on: the anonymous audience --- rig.setSetting(t, "public_page", "on") service.InvalidateProbeSnapshot() rec = rig.do(http.MethodGet, "/api/nodes", nil, false) if rec.Code != 200 { t.Fatalf("nodes anon: %d (%s)", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, leak := range []string{ "secret-host", "private-remark", `"hostname"`, `"remark"`, `"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0", } { if strings.Contains(body, leak) { t.Fatalf("anonymous frame leaks %q: %s", leak, body) } } var frame struct { Nodes []map[string]json.RawMessage `json:"nodes"` Admin bool `json:"admin"` } if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil { t.Fatalf("frame decode: %v", err) } if frame.Admin || len(frame.Nodes) != 1 { t.Fatalf("anon frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes)) } nodeBody, _ := json.Marshal(frame.Nodes[0]) assertKeys(t, keySet(t, nodeBody), anonNodeKeys) var node map[string]any _ = json.Unmarshal(nodeBody, &node) if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true || node["month_rx"] != float64(33) || node["month_used"] != float64(77) || node["total_tx"] != float64(222) || node["price"] != float64(10) { t.Fatalf("anon node values wrong: %v", node) } metricsObj, _ := json.Marshal(node["metrics"]) assertKeys(t, keySet(t, metricsObj), metricKeys) // Metrics history for the public node; the private one refuses. base := time.Now().Unix() base -= base % 60 for _, ts := range []int64{base - 120, base - 60} { if _, err := rig.store.DB().Exec(`INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs, cpu_max) VALUES (?, ?, 10, 100, 0, 0, 1000, 2000, 1500, 2500, 3, 2, 10, 12)`, pubID, ts); err != nil { t.Fatalf("seed metric: %v", err) } } rec = rig.do(http.MethodGet, "/api/nodes/"+itoa(pubID)+"/metrics?hours=1", nil, false) if rec.Code != 200 { t.Fatalf("metrics anon: %d (%s)", rec.Code, rec.Body.String()) } var hist struct { Metrics []map[string]json.RawMessage `json:"metrics"` Ping []any `json:"ping"` Probes map[string]string `json:"probes"` Loss map[string]float64 `json:"loss"` Step int64 `json:"step"` } if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil { t.Fatalf("metrics decode: %v", err) } if hist.Step != 60 || len(hist.Metrics) != 2 || len(hist.Ping) != 0 || len(hist.Probes) != 0 || len(hist.Loss) != 0 { t.Fatalf("metrics shape wrong: %+v", hist) } rowBody, _ := json.Marshal(hist.Metrics[0]) assertKeys(t, keySet(t, rowBody), historyKeys) if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, false); rec.Code != 401 { t.Fatalf("metrics for private node anon: %d", rec.Code) } if rec := rig.do(http.MethodGet, "/api/nodes/abc/metrics", nil, false); rec.Code != 400 { t.Fatalf("metrics bad id: %d", rec.Code) } // --- the admin audience --- rec = rig.do(http.MethodGet, "/api/nodes", nil, true) if rec.Code != 200 { t.Fatalf("nodes admin: %d", rec.Code) } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Fatalf("admin frame cache header: %q", cc) } _ = json.Unmarshal(rec.Body.Bytes(), &frame) if !frame.Admin || len(frame.Nodes) != 2 { t.Fatalf("admin frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes)) } adminBody := rec.Body.String() for _, want := range []string{"secret-host-PubNode", "private-remark-PubNode", "203.0.113.9"} { if !strings.Contains(adminBody, want) { t.Fatalf("admin frame missing %q", want) } } if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, true); rec.Code != 200 { t.Fatalf("metrics for private node admin: %d", rec.Code) } // --- theme hosting + API 404 discipline --- themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist") if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil { t.Fatal(err) } indexHTML := "theme" if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil { t.Fatal(err) } rig.setSetting(t, "theme", "testtheme") rec = rig.do(http.MethodGet, "/", nil, false) if rec.Code != 200 || rec.Body.String() != indexHTML { t.Fatalf("theme root: %d %q", rec.Code, rec.Body.String()) } etag := rec.Header().Get("ETag") if etag == "" || !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") { t.Fatalf("shell headers: etag=%q cc=%q", etag, rec.Header().Get("Cache-Control")) } req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("If-None-Match", etag) rec304 := httptest.NewRecorder() rig.engine.ServeHTTP(rec304, req) if rec304.Code != 304 { t.Fatalf("etag revalidation: %d", rec304.Code) } if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML { t.Fatalf("spa fallback: %d", rec.Code) } rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false) if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") { t.Fatalf("asset serving: %d cc=%q", rec.Code, rec.Header().Get("Cache-Control")) } if rec := rig.do(http.MethodGet, "/assets/missing.js", nil, false); rec.Code != 404 { t.Fatalf("missing asset: %d", rec.Code) } rec = rig.do(http.MethodGet, "/api/nope", nil, false) if rec.Code != 404 || !strings.Contains(rec.Body.String(), "没有这个接口:/api/nope") { t.Fatalf("api 404: %d %q", rec.Code, rec.Body.String()) } if rec := rig.do(http.MethodGet, "/panel/unknown", nil, false); rec.Code != 404 { t.Fatalf("panel path must stay with the panel: %d", rec.Code) } // --- theme config round trip --- if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Code != 200 || rec.Body.String() != "{}" { t.Fatalf("config default: %d %q", rec.Code, rec.Body.String()) } if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), false); rec.Code != 401 { t.Fatalf("config PUT anon: %d", rec.Code) } if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), true); rec.Code != 204 { t.Fatalf("config PUT admin: %d (%s)", rec.Code, rec.Body.String()) } if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Body.String() != `{"accent":"red"}` { t.Fatalf("config round trip: %q", rec.Body.String()) } if rec := rig.do(http.MethodPut, "/api/themes/notinstalled/config", []byte(`{}`), true); rec.Code != 400 { t.Fatalf("config PUT uninstalled: %d", rec.Code) } if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`[1,2]`), true); rec.Code != 400 { t.Fatalf("config PUT non-object: %d", rec.Code) } if rec := rig.do(http.MethodGet, "/api/themes/bad!short/config", nil, true); rec.Code != 404 { t.Fatalf("config bad short: %d", rec.Code) } // --- probe-meta edit endpoints --- metaPath := "/panel/api/agent/probe-meta/" + itoa(int64(privNodeID)) if rec := rig.do(http.MethodGet, metaPath, nil, false); rec.Code == 200 { t.Fatal("probe-meta GET without login must not succeed") } rec = rig.do(http.MethodGet, metaPath, nil, true) if rec.Code != 200 { t.Fatalf("probe-meta GET: %d (%s)", rec.Code, rec.Body.String()) } var env struct { Success bool `json:"success"` Obj struct { Currency string `json:"currency"` BillingCycle string `json:"billingCycle"` Public bool `json:"public"` } `json:"obj"` } _ = json.Unmarshal(rec.Body.Bytes(), &env) if !env.Success || env.Obj.Currency != "USD" || env.Obj.Public || env.Obj.BillingCycle != "monthly" { t.Fatalf("probe-meta GET body: %s", rec.Body.String()) } bad := []byte(`{"price":1,"currency":"USD","billingCycle":"monthly","trafficLimit":0,` + `"trafficMode":"sum","trafficResetDay":32,"public":true}`) rec = rig.do(http.MethodPut, metaPath, bad, true) _ = json.Unmarshal(rec.Body.Bytes(), &env) if env.Success { t.Fatalf("reset day 32 must be refused: %s", rec.Body.String()) } good := []byte(`{"price":3.5,"currency":"usd","billingCycle":"12m","trafficLimit":2000000000,` + `"trafficMode":"max","trafficResetDay":15,"public":false,"ipv4Pin":" 203.0.113.9 "}`) rec = rig.do(http.MethodPut, metaPath, good, true) _ = json.Unmarshal(rec.Body.Bytes(), &env) if !env.Success { t.Fatalf("probe-meta PUT: %s", rec.Body.String()) } rec = rig.do(http.MethodGet, metaPath, nil, true) _ = json.Unmarshal(rec.Body.Bytes(), &env) if env.Obj.Currency != "USD" || env.Obj.BillingCycle != "yearly" || env.Obj.Public { t.Fatalf("probe-meta normalization not stored: %s", rec.Body.String()) } saved, ok, err := probe.MetaFor(privID) if err != nil || !ok || saved.IPv4Pin != "203.0.113.9" || saved.TrafficLimit != 2_000_000_000 { t.Fatalf("stored meta: ok=%v err=%v %+v", ok, err, saved) } if rec := rig.do(http.MethodGet, "/panel/api/agent/probe-meta/999999", nil, true); rec.Code != 404 { t.Fatalf("probe-meta unbound node: %d", rec.Code) } } func TestProbeWebSocket(t *testing.T) { rig := newProbeTestRig(t) _, _ = rig.seedAgent(t, "WsNode", true) rig.setSetting(t, "public_page", "on") service.InvalidateProbeSnapshot() srv := httptest.NewServer(rig.engine) defer srv.Close() wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws" readFrame := func(conn *ws.Conn) (int, map[string]any) { t.Helper() _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) mt, raw, err := conn.ReadMessage() if err != nil { t.Fatalf("ws read: %v", err) } if mt == ws.BinaryMessage { zr, err := gzip.NewReader(bytes.NewReader(raw)) if err != nil { t.Fatalf("gzip frame: %v", err) } raw, err = io.ReadAll(zr) if err != nil { t.Fatalf("gunzip frame: %v", err) } } var frame map[string]any if err := json.Unmarshal(raw, &frame); err != nil { t.Fatalf("frame is not JSON: %v (%q)", err, raw) } return mt, frame } // Anonymous text frame. conn, _, err := ws.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial anon: %v", err) } mt, frame := readFrame(conn) if mt != ws.TextMessage || frame["admin"] != false { t.Fatalf("anon frame: type=%d admin=%v", mt, frame["admin"]) } if nodes, ok := frame["nodes"].([]any); !ok || len(nodes) != 1 { t.Fatalf("anon frame nodes: %v", frame["nodes"]) } conn.Close() // Anonymous gzip frame: binary, gunzips to the same shape. conn, _, err = ws.DefaultDialer.Dial(wsURL+"?gzip=1", nil) if err != nil { t.Fatalf("dial gzip: %v", err) } mt, frame = readFrame(conn) if mt != ws.BinaryMessage || frame["admin"] != false { t.Fatalf("gzip frame: type=%d admin=%v", mt, frame["admin"]) } conn.Close() // Admin frame over the session cookie. hdr := http.Header{} for _, ck := range rig.cookies { hdr.Add("Cookie", ck.String()) } conn, _, err = ws.DefaultDialer.Dial(wsURL, hdr) if err != nil { t.Fatalf("dial admin: %v", err) } _, frame = readFrame(conn) if frame["admin"] != true { t.Fatalf("admin frame: %v", frame["admin"]) } conn.Close() } func itoa(v int64) string { return strconv.FormatInt(v, 10) }