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) NewProbeTasksController(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) // V2.2.1 ⑦: the public view's name is the public remark when one // is set (the seed sets "shown publicly"), not the node name. if node["id"] != float64(pubID) || node["name"] != "shown publicly" || 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) } // The theme store (item 7 B5) lists an installed theme only when a // valid theme.json manifest sits beside dist/ — the install pipeline // always writes one, so the fixture must too, or the config PUT gate // (ProbeThemeSelectable) rightly reports the theme as not installed. manifest := `{"name":"Test Theme","short":"testtheme","description":"contract test fixture","version":"0.0.1","author":"tests","url":""}` if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } indexHTML := "