package controller import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/gin-gonic/gin" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) // V2.1.0: a probe-only agent's config poll carries the flag and NO // core payload (singbox_config null, hash empty), while the probe // keys and a pending update intent still ride along — a probe-only // node must stay reachable for probes and one-click agent updates. // The control agent on a normal node keeps receiving its payload. func TestConfigProbeOnlyWithholdsCorePayload(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() seed := func(name, token string, probeOnly bool) { node := &model.Node{Name: name, Address: "10.0.9.4"} if err := db.Create(node).Error; err != nil { t.Fatalf("seed node: %v", err) } agent := &model.AgentNode{ TokenHash: hashAgentToken(token), TokenPrefix: token[:12], Name: name, NodeID: &node.Id, AgentVersion: "A2.0.3", ProbeOnly: probeOnly, } if probeOnly { agent.UpdateRequestedAt = time.Now().Unix() agent.UpdateState = "pending" agent.UpdateTarget = "A2.1.0" } if err := db.Create(agent).Error; err != nil { t.Fatalf("seed agent: %v", err) } inbound := &model.Inbound{ Enable: true, Port: 443, Protocol: model.VLESS, Tag: "inbound-" + name, NodeID: &node.Id, Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`, StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` + `{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` + `"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`, SingboxConfig: `{"type":"vless","tag":"inbound-` + name + `","listen_port":443,` + `"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` + `"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` + `"short_id":["ab12cd34"]}},"users":[]}`, } if err := db.Create(inbound).Error; err != nil { t.Fatalf("seed inbound: %v", err) } } const probeToken = "probe-only-token-0000000000000000000001" const normalToken = "probe-normal-token-00000000000000000001" seed("probe-node", probeToken, true) seed("normal-node", normalToken, false) engine := gin.New() NewAgentController(engine.Group("/panel/api/agent")) get := func(token string) map[string]any { req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("config response not JSON: %v", err) } return resp } resp := get(probeToken) if resp["probe_only"] != true { t.Fatalf("probe_only = %v, want true", resp["probe_only"]) } if cfg, present := resp["singbox_config"]; !present || cfg != nil { t.Fatalf("singbox_config = %v (present=%v), want JSON null", cfg, present) } if h := resp["singbox_config_hash"]; h != "" { t.Fatalf("singbox_config_hash = %v, want empty", h) } if _, present := resp["probe_tasks"]; !present { t.Fatal("probe_tasks missing: probe-only nodes still run latency probes") } if _, present := resp["probe_fast_interval"]; !present { t.Fatal("probe_fast_interval missing on probe-only config") } upd, _ := resp["agent_update"].(map[string]any) if upd == nil || upd["target"] != "A2.1.0" { t.Fatalf("agent_update = %v, want the pending intent (probe-only nodes still update)", resp["agent_update"]) } resp = get(normalToken) if resp["probe_only"] != false { t.Fatalf("probe_only = %v, want false for a normal node", resp["probe_only"]) } if cfg, _ := resp["singbox_config"].(map[string]any); cfg == nil { t.Fatalf("normal node lost its singbox payload: %v", resp["singbox_config"]) } if h, _ := resp["singbox_config_hash"].(string); h == "" { t.Fatal("normal node singbox_config_hash empty") } }