From 2b3e5f963350de4e4268285cb74c81e1d6fe8a6a Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:39:57 +0200 Subject: [PATCH 1/3] fix(rpc): derive /raft/node leadership from raft leader ID --- pkg/raft/node.go | 7 +++ pkg/rpc/server/http.go | 7 ++- pkg/rpc/server/http_test.go | 101 ++++++++++++++++++++++++++++++++++++ pkg/rpc/server/server.go | 1 + 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/pkg/raft/node.go b/pkg/raft/node.go index f156ae4785..88aa90504c 100644 --- a/pkg/raft/node.go +++ b/pkg/raft/node.go @@ -208,6 +208,13 @@ func (n *Node) NodeID() string { return n.config.NodeID } +func (n *Node) LeaderID() string { + if n == nil || n.raft == nil { + return "" + } + return n.leaderID() +} + func (n *Node) leaderID() string { _, id := n.raft.LeaderWithID() return string(id) diff --git a/pkg/rpc/server/http.go b/pkg/rpc/server/http.go index 6cffdb2739..a721ce0b82 100644 --- a/pkg/rpc/server/http.go +++ b/pkg/rpc/server/http.go @@ -139,11 +139,16 @@ func RegisterCustomHTTPEndpoints(mux *http.ServeMux, s store.Store, pm p2p.P2PRP http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + leaderID := raftNode.LeaderID() + isLeader := raftNode.IsLeader() + if leaderID != "" { + isLeader = leaderID == raftNode.NodeID() + } rsp := struct { IsLeader bool `json:"is_leader"` NodeID string `json:"node_id"` }{ - IsLeader: raftNode.IsLeader(), + IsLeader: isLeader, NodeID: raftNode.NodeID(), } w.Header().Set("Content-Type", "application/json") diff --git a/pkg/rpc/server/http_test.go b/pkg/rpc/server/http_test.go index 4fa2e92e17..f7a6c5b0e9 100644 --- a/pkg/rpc/server/http_test.go +++ b/pkg/rpc/server/http_test.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -45,6 +46,106 @@ func TestRegisterCustomHTTPEndpoints(t *testing.T) { mockStore.AssertExpectations(t) } +type testRaftNodeSource struct { + isLeader bool + leaderID string + nodeID string +} + +func (t testRaftNodeSource) IsLeader() bool { + return t.isLeader +} + +func (t testRaftNodeSource) LeaderID() string { + return t.leaderID +} + +func (t testRaftNodeSource) NodeID() string { + return t.nodeID +} + +func TestRegisterCustomHTTPEndpoints_RaftNodeStatus(t *testing.T) { + mux := http.NewServeMux() + logger := zerolog.Nop() + + raftNode := testRaftNodeSource{ + isLeader: false, + leaderID: "node-a", + nodeID: "node-a", + } + + RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, raftNode) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/raft/node", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + IsLeader bool `json:"is_leader"` + NodeID string `json:"node_id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.True(t, body.IsLeader) + assert.Equal(t, "node-a", body.NodeID) +} + +func TestRegisterCustomHTTPEndpoints_RaftNodeStatusFallsBackWithoutLeaderID(t *testing.T) { + mux := http.NewServeMux() + logger := zerolog.Nop() + + raftNode := testRaftNodeSource{ + isLeader: false, + leaderID: "", + nodeID: "node-a", + } + + RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, raftNode) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/raft/node", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + IsLeader bool `json:"is_leader"` + NodeID string `json:"node_id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.False(t, body.IsLeader) + assert.Equal(t, "node-a", body.NodeID) +} + +func TestRegisterCustomHTTPEndpoints_RaftNodeStatusMethodNotAllowed(t *testing.T) { + mux := http.NewServeMux() + logger := zerolog.Nop() + + RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, testRaftNodeSource{}) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/raft/node", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) +} + func TestHealthReady_aggregatorBlockDelay(t *testing.T) { logger := zerolog.Nop() diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index ce747ae37d..419f8b6631 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -368,6 +368,7 @@ func (p *P2PServer) GetNetInfo( type RaftNodeSource interface { IsLeader() bool + LeaderID() string NodeID() string } From 7f8476d841f13ab5dd8712fc56315ac76de55407 Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:52:36 +0200 Subject: [PATCH 2/3] fix(raft): add LeaderID doc comment; consolidate raft-status tests - Add Go doc comment to Node.LeaderID() describing return value, nil-safety, and staleness semantics, consistent with IsLeader/HasQuorum style. - Consolidate three near-duplicate TestRegisterCustomHTTPEndpoints_RaftNodeStatus* tests into a single table-driven test covering: leaderID==nodeID (is_leader true), leaderID!=nodeID (is_leader false), empty leaderID fallback, and non-GET method (405). Clarifies that is_leader is derived from LeaderID(), not the IsLeader() field on testRaftNodeSource. Co-Authored-By: Claude Sonnet 4.6 --- pkg/raft/node.go | 5 ++ pkg/rpc/server/http_test.go | 140 ++++++++++++++++++------------------ 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/pkg/raft/node.go b/pkg/raft/node.go index e0e1a2d8d5..b22ce92bed 100644 --- a/pkg/raft/node.go +++ b/pkg/raft/node.go @@ -257,6 +257,11 @@ func (n *Node) NodeID() string { return n.config.NodeID } +// LeaderID returns the server ID of the current cluster leader. +// Returns an empty string if the receiver is nil, raft is uninitialized, or no +// leader has been elected yet. The value may be momentarily stale between raft +// leadership changes; callers that need a strong guarantee should cross-check +// with HasQuorum. func (n *Node) LeaderID() string { if n == nil || n.raft == nil { return "" diff --git a/pkg/rpc/server/http_test.go b/pkg/rpc/server/http_test.go index f7a6c5b0e9..45f19c4c56 100644 --- a/pkg/rpc/server/http_test.go +++ b/pkg/rpc/server/http_test.go @@ -8,14 +8,13 @@ import ( "testing" "time" + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/test/mocks" + "github.com/evstack/ev-node/types" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - - "github.com/evstack/ev-node/pkg/config" - "github.com/evstack/ev-node/test/mocks" - "github.com/evstack/ev-node/types" ) func TestRegisterCustomHTTPEndpoints(t *testing.T) { @@ -65,85 +64,82 @@ func (t testRaftNodeSource) NodeID() string { } func TestRegisterCustomHTTPEndpoints_RaftNodeStatus(t *testing.T) { - mux := http.NewServeMux() - logger := zerolog.Nop() - - raftNode := testRaftNodeSource{ - isLeader: false, - leaderID: "node-a", - nodeID: "node-a", - } - - RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, raftNode) - - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/raft/node", nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server - require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) - - require.Equal(t, http.StatusOK, resp.StatusCode) - - var body struct { + type bodyShape struct { IsLeader bool `json:"is_leader"` NodeID string `json:"node_id"` } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - assert.True(t, body.IsLeader) - assert.Equal(t, "node-a", body.NodeID) -} -func TestRegisterCustomHTTPEndpoints_RaftNodeStatusFallsBackWithoutLeaderID(t *testing.T) { - mux := http.NewServeMux() - logger := zerolog.Nop() - - raftNode := testRaftNodeSource{ - isLeader: false, - leaderID: "", - nodeID: "node-a", + cases := []struct { + name string + node testRaftNodeSource + method string + wantStatus int + wantIsLeader bool + wantNodeID string + skipBodyDecode bool + }{ + { + // leaderID == nodeID: handler derives is_leader=true from LeaderID(), + // regardless of the IsLeader() field on testRaftNodeSource. + name: "leader matches — is_leader true", + node: testRaftNodeSource{leaderID: "node-a", nodeID: "node-a"}, + method: http.MethodGet, + wantStatus: http.StatusOK, + wantIsLeader: true, + wantNodeID: "node-a", + }, + { + // leaderID != nodeID: handler derives is_leader=false. + name: "leader differs — is_leader false", + node: testRaftNodeSource{leaderID: "node-b", nodeID: "node-a"}, + method: http.MethodGet, + wantStatus: http.StatusOK, + wantIsLeader: false, + wantNodeID: "node-a", + }, + { + // empty leaderID: fallback — is_leader=false (no elected leader known). + name: "empty leaderID fallback — is_leader false", + node: testRaftNodeSource{leaderID: "", nodeID: "node-a"}, + method: http.MethodGet, + wantStatus: http.StatusOK, + wantIsLeader: false, + wantNodeID: "node-a", + }, + { + name: "non-GET method — 405", + node: testRaftNodeSource{}, + method: http.MethodPost, + wantStatus: http.StatusMethodNotAllowed, + skipBodyDecode: true, + }, } - RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, raftNode) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, zerolog.Nop(), tc.node) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) - req, err := http.NewRequest(http.MethodGet, ts.URL+"/raft/node", nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server - require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) + req, err := http.NewRequest(tc.method, ts.URL+"/raft/node", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) - require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tc.wantStatus, resp.StatusCode) + if tc.skipBodyDecode { + return + } - var body struct { - IsLeader bool `json:"is_leader"` - NodeID string `json:"node_id"` + var body bodyShape + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, tc.wantIsLeader, body.IsLeader) + assert.Equal(t, tc.wantNodeID, body.NodeID) + }) } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - assert.False(t, body.IsLeader) - assert.Equal(t, "node-a", body.NodeID) -} - -func TestRegisterCustomHTTPEndpoints_RaftNodeStatusMethodNotAllowed(t *testing.T) { - mux := http.NewServeMux() - logger := zerolog.Nop() - - RegisterCustomHTTPEndpoints(mux, nil, nil, config.DefaultConfig(), nil, logger, testRaftNodeSource{}) - - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - - req, err := http.NewRequest(http.MethodPost, ts.URL+"/raft/node", nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) //nolint:gosec // test-only request to httptest server - require.NoError(t, err) - t.Cleanup(func() { _ = resp.Body.Close() }) - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) } func TestHealthReady_aggregatorBlockDelay(t *testing.T) { From ed2f57df364ae8b19edf7fa61660a73a4f3eedec Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:04:03 +0200 Subject: [PATCH 3/3] fix(rpc): fix gci import grouping in http_test.go Separate third-party imports from evstack/ev-node-prefixed imports per project gci custom-order config. Co-Authored-By: Claude Sonnet 4.6 --- pkg/rpc/server/http_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/rpc/server/http_test.go b/pkg/rpc/server/http_test.go index 45f19c4c56..23e0e180f3 100644 --- a/pkg/rpc/server/http_test.go +++ b/pkg/rpc/server/http_test.go @@ -8,13 +8,14 @@ import ( "testing" "time" - "github.com/evstack/ev-node/pkg/config" - "github.com/evstack/ev-node/test/mocks" - "github.com/evstack/ev-node/types" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/evstack/ev-node/pkg/config" + "github.com/evstack/ev-node/test/mocks" + "github.com/evstack/ev-node/types" ) func TestRegisterCustomHTTPEndpoints(t *testing.T) {