package controller import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "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" ) // V2.0.1 probe batch, master side: the fast-report endpoint ingests // metrics, and the config poll carries the cadence + the node's // counted-interface pin. Since V2.0.3 the fast report also stamps // last-seen (the hub's themes gate batches on its freshness); it // originally did not — see the probeReport handler comment. func TestAgentFastReportAndConfigKeys(t *testing.T) { 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() }) db := database.GetDB() const token = "fast-report-token" node := &model.Node{Name: "fast-node", Address: "192.0.2.1", Port: 8443, Enable: true} if err := db.Create(node).Error; err != nil { t.Fatalf("seed node: %v", err) } agent := &model.AgentNode{TokenHash: hashAgentToken(token), TokenPrefix: "fast-", Name: "fast-node", NodeID: &node.Id} if err := db.Create(agent).Error; err != nil { t.Fatalf("seed agent: %v", err) } engine := gin.New() NewAgentController(engine.Group("/panel/api/agent")) call := func(method, path, body string) (int, []byte) { req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) return rec.Code, rec.Body.Bytes() } load := func() model.AgentNode { var row model.AgentNode if err := db.First(&row, agent.Id).Error; err != nil { t.Fatalf("reload agent: %v", err) } return row } // One heartbeat establishes last-seen. if code, _ := call(http.MethodPost, "/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":1}`); code != http.StatusOK { t.Fatalf("heartbeat: %d", code) } seenAfterHeartbeat := load().LastSeen if seenAfterHeartbeat == 0 { t.Fatal("heartbeat did not stamp last-seen") } metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` + `"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}` // Age the stamp first so the fast report's touch is observable // even within the same millisecond as the heartbeat's. stale := seenAfterHeartbeat - 120_000 if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil { t.Fatalf("age last-seen: %v", err) } if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK { t.Fatalf("probe-report: %d (%s)", code, body) } row := load() if row.ProbeMemUsed != 400 || row.ProbeAt == 0 { t.Errorf("fast report metrics not stored: %+v", row) } if row.LastSeen <= stale { t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen) } // Unauthorized callers are refused. req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/probe-report", bytes.NewBufferString(metrics)) req.Header.Set("Authorization", "Bearer wrong") rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("probe-report with bad token: %d, want 401", rec.Code) } configObj := func() map[string]any { code, body := call(http.MethodGet, "/panel/api/agent/config", "") if code != http.StatusOK { t.Fatalf("config poll: %d (%s)", code, body) } // getConfig answers with the bare payload (no envelope). var obj map[string]any if err := json.Unmarshal(body, &obj); err != nil { t.Fatalf("config payload: %v", err) } return obj } obj := configObj() if obj["probe_fast_interval"] != float64(3) { t.Errorf("default probe_fast_interval = %v, want 3", obj["probe_fast_interval"]) } if obj["probe_stat_iface"] != "" { t.Errorf("default probe_stat_iface = %v, want empty", obj["probe_stat_iface"]) } // Operator changes: cadence via the probe settings block, pin // via the node's probe meta — both must ride the next poll. settings := service.GetProbePublicSettings() settings.FastIntervalSec = 9 if err := service.SetProbePublicSettings(settings); err != nil { t.Fatalf("SetProbePublicSettings: %v", err) } st := probe.Default() meta := probe.DefaultMeta(int64(agent.Id)) meta.StatIface = "eth9" if err := st.UpsertMeta(meta); err != nil { t.Fatalf("UpsertMeta: %v", err) } obj = configObj() if obj["probe_fast_interval"] != float64(9) { t.Errorf("probe_fast_interval = %v, want 9", obj["probe_fast_interval"]) } if obj["probe_stat_iface"] != "eth9" { t.Errorf("probe_stat_iface = %v, want eth9", obj["probe_stat_iface"]) } // Settings validation clamps the ranges the page offers. bad := service.GetProbePublicSettings() bad.RetentionDays = 0 if err := service.SetProbePublicSettings(bad); err == nil { t.Error("retention_days = 0 accepted, want refusal") } bad = service.GetProbePublicSettings() bad.FastIntervalSec = 61 if err := service.SetProbePublicSettings(bad); err == nil { t.Error("fast interval = 61 accepted, want refusal") } } // V2.0.1 ⑤: batch task creation — shared fields, auto names, // duplicate collapse, all-or-nothing validation. func TestProbeTasksBatch(t *testing.T) { rig := newProbeTestRig(t) cleanPingTasks(t) const batchPath = "/panel/api/agent/probe-tasks/batch" rec := rig.do(http.MethodPost, batchPath, []byte( `{"targets":["1.1.1.1:443","8.8.8.8:53"," 1.1.1.1:443 "],"autoJoin":true}`), true) env := decodeEnvelope(t, rec.Body.Bytes()) if !env.Success { t.Fatalf("batch create refused: %s", env.Msg) } var obj struct { IDs []int64 `json:"ids"` } if err := json.Unmarshal(env.Obj, &obj); err != nil || len(obj.IDs) != 2 { t.Fatalf("batch ids = %s (err %v), want 2 ids (duplicate collapsed)", env.Obj, err) } tasks, err := probe.PingTasks() if err != nil || len(tasks) != 2 { t.Fatalf("tasks after batch = %d (err %v)", len(tasks), err) } for _, task := range tasks { if task.Name != task.Target { t.Errorf("auto name = %q, want the target %q", task.Name, task.Target) } if task.Interval != 60 { t.Errorf("default interval = %d, want 60", task.Interval) } if !task.AutoJoin { t.Errorf("autoJoin not carried into task %d", task.ID) } } cleanPingTasks(t) rec = rig.do(http.MethodPost, batchPath, []byte( `{"targets":["1.1.1.1:443","8.8.8.8:53"],"name":"线路","interval":30}`), true) env = decodeEnvelope(t, rec.Body.Bytes()) if !env.Success { t.Fatalf("named batch refused: %s", env.Msg) } tasks, _ = probe.PingTasks() if len(tasks) != 2 { t.Fatalf("named batch tasks = %d", len(tasks)) } for _, task := range tasks { if !strings.HasPrefix(task.Name, "线路 · ") || task.Interval != 30 { t.Errorf("named batch task = %+v", task) } } // One bad line refuses the whole paste; nothing is created. cleanPingTasks(t) rec = rig.do(http.MethodPost, batchPath, []byte( `{"targets":["1.1.1.1:443","not-a-target"]}`), true) env = decodeEnvelope(t, rec.Body.Bytes()) if env.Success { t.Fatal("batch with a bad target succeeded") } if tasks, _ := probe.PingTasks(); len(tasks) != 0 { t.Fatalf("refused batch leaked %d tasks", len(tasks)) } } // V2.0.1 probe batch: the meta editor's calibration + interface pin // fields round-trip and validate. func TestProbeMetaCalibrationFields(t *testing.T) { rig := newProbeTestRig(t) db := database.GetDB() nodeID := 77 node := &model.Node{Id: nodeID, Name: "meta-node", Address: "192.0.2.1", Port: 8443, Enable: true} if err := db.Create(node).Error; err != nil { t.Fatalf("create node: %v", err) } agent := &model.AgentNode{TokenHash: hashAgentToken("meta-token"), TokenPrefix: "meta-", Name: "meta-agent", NodeID: &nodeID} if err := db.Create(agent).Error; err != nil { t.Fatalf("create agent: %v", err) } path := "/panel/api/agent/probe-meta/77" put := func(body string) envelope { rec := rig.do(http.MethodPut, path, []byte(body), true) return decodeEnvelope(t, rec.Body.Bytes()) } base := `"price":0,"currency":"USD","billingCycle":"monthly",` + `"expiresAt":null,"trafficLimit":0,"trafficMode":"sum","trafficResetDay":1,"public":true,"sort":0,` + `"group":"","publicRemark":"","notify":false,"countryPin":"","ipv4Pin":"","ipv6Pin":""` env := put(`{` + base + `,"trafficCalUpGb":2.5,"trafficCalDownGb":-1,"statIface":" eth0 "}`) if !env.Success { t.Fatalf("meta save with calibration refused: %s", env.Msg) } meta, found, err := probe.MetaFor(int64(agent.Id)) if err != nil || !found { t.Fatalf("MetaFor: found=%v err=%v", found, err) } if meta.TrafficCalUpGB != 2.5 || meta.TrafficCalDownGB != -1 || meta.StatIface != "eth0" { t.Errorf("stored meta = up %v down %v iface %q (pin must be trimmed)", meta.TrafficCalUpGB, meta.TrafficCalDownGB, meta.StatIface) } env = put(`{` + base + `,"statIface":"eth 0"}`) if env.Success { t.Error("interface name with a space accepted") } env = put(`{` + base + `,"statIface":"eth/0"}`) if env.Success { t.Error("interface name with a slash accepted") } }