From 4a5f6771b362cec5696a1e027d4c788f29cdc00f Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Thu, 13 Aug 2026 15:34:37 +0500 Subject: [PATCH] fix(nodes): report probe heartbeat persistence failures (#6207) Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> --- internal/web/controller/node.go | 5 +- .../node_credentials_writeonly_test.go | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index 4b7f4af46..f9ac74c2c 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -314,7 +314,10 @@ func (a *NodeController) probe(c *gin.Context) { } else { patch.Status = "online" } - _ = a.nodeService.UpdateHeartbeat(id, patch) + if err := a.nodeService.UpdateHeartbeat(id, patch); err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + return + } jsonObj(c, patch.ToUI(probeErr == nil), nil) } diff --git a/internal/web/controller/node_credentials_writeonly_test.go b/internal/web/controller/node_credentials_writeonly_test.go index 278a8e8ca..195447022 100644 --- a/internal/web/controller/node_credentials_writeonly_test.go +++ b/internal/web/controller/node_credentials_writeonly_test.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "net" "net/http" "net/http/httptest" @@ -12,6 +13,8 @@ import ( "github.com/gin-gonic/gin" + "gorm.io/gorm" + "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/locale" @@ -66,6 +69,52 @@ func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) { } } +func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) + })) + defer remote.Close() + host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) + if err != nil { + t.Fatalf("split remote addr: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse remote port: %v", err) + } + node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + db := database.GetDB() + const callback = "test:fail_node_heartbeat_update" + errInjected := errors.New("injected heartbeat persistence failure") + if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Table == "nodes" { + tx.AddError(errInjected) + } + }); err != nil { + t.Fatalf("register update callback: %v", err) + } + t.Cleanup(func() { + if err := db.Callback().Update().Remove(callback); err != nil { + t.Errorf("remove update callback: %v", err) + } + }) + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil)) + if !strings.Contains(w.Body.String(), `"success":false`) { + t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), errInjected.Error()) { + t.Fatalf("probe response omitted persistence error: %s", w.Body.String()) + } +} + func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) { engine := newNodeCredentialTestEngine(t)