diff --git a/services/p2p/Client_test.go b/services/p2p/Client_test.go index 5f1cc1f33..606be1c8e 100644 --- a/services/p2p/Client_test.go +++ b/services/p2p/Client_test.go @@ -42,6 +42,7 @@ type MockPeerServiceClient struct { IsPeerUnhealthyFunc func(ctx context.Context, in *p2p_api.IsPeerUnhealthyRequest, opts ...grpc.CallOption) (*p2p_api.IsPeerUnhealthyResponse, error) GetPeerRegistryFunc func(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*p2p_api.GetPeerRegistryResponse, error) GetPeerFunc func(ctx context.Context, in *p2p_api.GetPeerRequest, opts ...grpc.CallOption) (*p2p_api.GetPeerResponse, error) + RecordBytesDownloadedFunc func(ctx context.Context, in *p2p_api.RecordBytesDownloadedRequest, opts ...grpc.CallOption) (*p2p_api.RecordBytesDownloadedResponse, error) } func (m *MockPeerServiceClient) GetPeers(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*p2p_api.GetPeersResponse, error) { @@ -201,6 +202,9 @@ func (m *MockPeerServiceClient) GetPeerRegistry(ctx context.Context, in *emptypb } func (m *MockPeerServiceClient) RecordBytesDownloaded(ctx context.Context, in *p2p_api.RecordBytesDownloadedRequest, opts ...grpc.CallOption) (*p2p_api.RecordBytesDownloadedResponse, error) { + if m.RecordBytesDownloadedFunc != nil { + return m.RecordBytesDownloadedFunc(ctx, in, opts...) + } return &p2p_api.RecordBytesDownloadedResponse{Ok: true}, nil } @@ -428,3 +432,472 @@ func TestSimpleClientDisconnectPeer(t *testing.T) { assert.Contains(t, err.Error(), "peer not found") }) } + +// --- Catchup-recording wrapper coverage --- +// +// Each Client.go wrapper has the same shape: build proto, call gRPC, propagate +// the gRPC error if any, return a service error when the response Ok flag is +// false, otherwise return nil. These tests cover the three exit paths per +// method, plus the data-shaping conversions in GetPeer/GetPeerRegistry/ +// GetPeersForCatchup/ResetReputation. + +func newClientWithMock(m *MockPeerServiceClient) *Client { + return &Client{ + client: m, + logger: ulogger.New("test"), + } +} + +func TestSimpleClientRecordCatchupAttempt(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupAttemptFunc: func(ctx context.Context, in *p2p_api.RecordCatchupAttemptRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupAttemptResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + return &p2p_api.RecordCatchupAttemptResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.RecordCatchupAttempt(context.Background(), "peer1")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupAttemptFunc: func(ctx context.Context, in *p2p_api.RecordCatchupAttemptRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupAttemptResponse, error) { + return nil, assert.AnError + }, + }) + assert.ErrorIs(t, client.RecordCatchupAttempt(context.Background(), "peer1"), assert.AnError) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupAttemptFunc: func(ctx context.Context, in *p2p_api.RecordCatchupAttemptRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupAttemptResponse, error) { + return &p2p_api.RecordCatchupAttemptResponse{Ok: false}, nil + }, + }) + err := client.RecordCatchupAttempt(context.Background(), "peer1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to record catchup attempt") + }) +} + +func TestSimpleClientRecordCatchupSuccess(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupSuccessFunc: func(ctx context.Context, in *p2p_api.RecordCatchupSuccessRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupSuccessResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + assert.Equal(t, int64(1500), in.DurationMs) + return &p2p_api.RecordCatchupSuccessResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.RecordCatchupSuccess(context.Background(), "peer1", 1500)) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupSuccessFunc: func(ctx context.Context, in *p2p_api.RecordCatchupSuccessRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupSuccessResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.RecordCatchupSuccess(context.Background(), "peer1", 0)) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupSuccessFunc: func(ctx context.Context, in *p2p_api.RecordCatchupSuccessRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupSuccessResponse, error) { + return &p2p_api.RecordCatchupSuccessResponse{Ok: false}, nil + }, + }) + err := client.RecordCatchupSuccess(context.Background(), "peer1", 0) + assert.Contains(t, err.Error(), "failed to record catchup success") + }) +} + +func TestSimpleClientRecordCatchupFailure(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupFailureFunc: func(ctx context.Context, in *p2p_api.RecordCatchupFailureRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupFailureResponse, error) { + return &p2p_api.RecordCatchupFailureResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.RecordCatchupFailure(context.Background(), "peer1")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupFailureFunc: func(ctx context.Context, in *p2p_api.RecordCatchupFailureRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupFailureResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.RecordCatchupFailure(context.Background(), "peer1")) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupFailureFunc: func(ctx context.Context, in *p2p_api.RecordCatchupFailureRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupFailureResponse, error) { + return &p2p_api.RecordCatchupFailureResponse{Ok: false}, nil + }, + }) + err := client.RecordCatchupFailure(context.Background(), "peer1") + assert.Contains(t, err.Error(), "failed to record catchup failure") + }) +} + +func TestSimpleClientRecordCatchupMalicious(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupMaliciousFunc: func(ctx context.Context, in *p2p_api.RecordCatchupMaliciousRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupMaliciousResponse, error) { + return &p2p_api.RecordCatchupMaliciousResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.RecordCatchupMalicious(context.Background(), "peer1")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupMaliciousFunc: func(ctx context.Context, in *p2p_api.RecordCatchupMaliciousRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupMaliciousResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.RecordCatchupMalicious(context.Background(), "peer1")) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordCatchupMaliciousFunc: func(ctx context.Context, in *p2p_api.RecordCatchupMaliciousRequest, opts ...grpc.CallOption) (*p2p_api.RecordCatchupMaliciousResponse, error) { + return &p2p_api.RecordCatchupMaliciousResponse{Ok: false}, nil + }, + }) + err := client.RecordCatchupMalicious(context.Background(), "peer1") + assert.Contains(t, err.Error(), "failed to record catchup malicious") + }) +} + +func TestSimpleClientUpdateCatchupError(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupErrorFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupErrorRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupErrorResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + assert.Equal(t, "boom", in.ErrorMsg) + return &p2p_api.UpdateCatchupErrorResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.UpdateCatchupError(context.Background(), "peer1", "boom")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupErrorFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupErrorRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupErrorResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.UpdateCatchupError(context.Background(), "peer1", "boom")) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupErrorFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupErrorRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupErrorResponse, error) { + return &p2p_api.UpdateCatchupErrorResponse{Ok: false}, nil + }, + }) + err := client.UpdateCatchupError(context.Background(), "peer1", "boom") + assert.Contains(t, err.Error(), "failed to update catchup error") + }) +} + +func TestSimpleClientUpdateCatchupReputation(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupReputationFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupReputationRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupReputationResponse, error) { + assert.InDelta(t, 75.0, in.Score, 0.001) + return &p2p_api.UpdateCatchupReputationResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.UpdateCatchupReputation(context.Background(), "peer1", 75.0)) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupReputationFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupReputationRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupReputationResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.UpdateCatchupReputation(context.Background(), "peer1", 75.0)) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + UpdateCatchupReputationFunc: func(ctx context.Context, in *p2p_api.UpdateCatchupReputationRequest, opts ...grpc.CallOption) (*p2p_api.UpdateCatchupReputationResponse, error) { + return &p2p_api.UpdateCatchupReputationResponse{Ok: false}, nil + }, + }) + err := client.UpdateCatchupReputation(context.Background(), "peer1", 75.0) + assert.Contains(t, err.Error(), "failed to update catchup reputation") + }) +} + +func TestSimpleClientResetReputation(t *testing.T) { + t.Run("ok_specific_peer", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ResetReputationFunc: func(ctx context.Context, in *p2p_api.ResetReputationRequest, opts ...grpc.CallOption) (*p2p_api.ResetReputationResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + return &p2p_api.ResetReputationResponse{Ok: true, PeersReset: 1}, nil + }, + }) + n, err := client.ResetReputation(context.Background(), "peer1") + assert.NoError(t, err) + assert.Equal(t, 1, n) + }) + t.Run("ok_all_peers", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ResetReputationFunc: func(ctx context.Context, in *p2p_api.ResetReputationRequest, opts ...grpc.CallOption) (*p2p_api.ResetReputationResponse, error) { + return &p2p_api.ResetReputationResponse{Ok: true, PeersReset: 7}, nil + }, + }) + n, err := client.ResetReputation(context.Background(), "") + assert.NoError(t, err) + assert.Equal(t, 7, n) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ResetReputationFunc: func(ctx context.Context, in *p2p_api.ResetReputationRequest, opts ...grpc.CallOption) (*p2p_api.ResetReputationResponse, error) { + return nil, assert.AnError + }, + }) + _, err := client.ResetReputation(context.Background(), "peer1") + assert.Error(t, err) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ResetReputationFunc: func(ctx context.Context, in *p2p_api.ResetReputationRequest, opts ...grpc.CallOption) (*p2p_api.ResetReputationResponse, error) { + return &p2p_api.ResetReputationResponse{Ok: false}, nil + }, + }) + _, err := client.ResetReputation(context.Background(), "peer1") + assert.Contains(t, err.Error(), "failed to reset reputation") + }) +} + +func TestSimpleClientGetPeersForCatchup(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeersForCatchupFunc: func(ctx context.Context, in *p2p_api.GetPeersForCatchupRequest, opts ...grpc.CallOption) (*p2p_api.GetPeersForCatchupResponse, error) { + return &p2p_api.GetPeersForCatchupResponse{ + Peers: []*p2p_api.PeerInfoForCatchup{ + { + Id: "12D3KooWBhWMmHCXuyfM48dEPRsBzkemQQu71yC9rR2zHGmAjzQz", + Height: 42, + CatchupReputationScore: 88.5, + CatchupAttempts: 3, + CatchupSuccesses: 2, + CatchupFailures: 1, + }, + }, + }, nil + }, + }) + peers, err := client.GetPeersForCatchup(context.Background()) + assert.NoError(t, err) + assert.Len(t, peers, 1) + assert.Equal(t, uint32(42), peers[0].Height) + assert.InDelta(t, 88.5, peers[0].ReputationScore, 0.001) + assert.Equal(t, int64(3), peers[0].InteractionAttempts) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeersForCatchupFunc: func(ctx context.Context, in *p2p_api.GetPeersForCatchupRequest, opts ...grpc.CallOption) (*p2p_api.GetPeersForCatchupResponse, error) { + return nil, assert.AnError + }, + }) + _, err := client.GetPeersForCatchup(context.Background()) + assert.Error(t, err) + }) +} + +func TestSimpleClientReportValidSubtree(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidSubtreeFunc: func(ctx context.Context, in *p2p_api.ReportValidSubtreeRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidSubtreeResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + assert.Equal(t, "subtreehash", in.SubtreeHash) + return &p2p_api.ReportValidSubtreeResponse{Success: true}, nil + }, + }) + assert.NoError(t, client.ReportValidSubtree(context.Background(), "peer1", "subtreehash")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidSubtreeFunc: func(ctx context.Context, in *p2p_api.ReportValidSubtreeRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidSubtreeResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.ReportValidSubtree(context.Background(), "peer1", "h")) + }) + t.Run("not_success", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidSubtreeFunc: func(ctx context.Context, in *p2p_api.ReportValidSubtreeRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidSubtreeResponse, error) { + return &p2p_api.ReportValidSubtreeResponse{Success: false, Message: "rejected"}, nil + }, + }) + err := client.ReportValidSubtree(context.Background(), "peer1", "h") + assert.Contains(t, err.Error(), "rejected") + }) +} + +func TestSimpleClientReportValidBlock(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidBlockFunc: func(ctx context.Context, in *p2p_api.ReportValidBlockRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidBlockResponse, error) { + return &p2p_api.ReportValidBlockResponse{Success: true}, nil + }, + }) + assert.NoError(t, client.ReportValidBlock(context.Background(), "peer1", "blockhash")) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidBlockFunc: func(ctx context.Context, in *p2p_api.ReportValidBlockRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidBlockResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.ReportValidBlock(context.Background(), "peer1", "h")) + }) + t.Run("not_success", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + ReportValidBlockFunc: func(ctx context.Context, in *p2p_api.ReportValidBlockRequest, opts ...grpc.CallOption) (*p2p_api.ReportValidBlockResponse, error) { + return &p2p_api.ReportValidBlockResponse{Success: false, Message: "stale"}, nil + }, + }) + err := client.ReportValidBlock(context.Background(), "peer1", "h") + assert.Contains(t, err.Error(), "stale") + }) +} + +func TestSimpleClientIsPeerMalicious(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + IsPeerMaliciousFunc: func(ctx context.Context, in *p2p_api.IsPeerMaliciousRequest, opts ...grpc.CallOption) (*p2p_api.IsPeerMaliciousResponse, error) { + return &p2p_api.IsPeerMaliciousResponse{IsMalicious: true, Reason: "spam"}, nil + }, + }) + mal, reason, err := client.IsPeerMalicious(context.Background(), "peer1") + assert.NoError(t, err) + assert.True(t, mal) + assert.Equal(t, "spam", reason) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + IsPeerMaliciousFunc: func(ctx context.Context, in *p2p_api.IsPeerMaliciousRequest, opts ...grpc.CallOption) (*p2p_api.IsPeerMaliciousResponse, error) { + return nil, assert.AnError + }, + }) + _, _, err := client.IsPeerMalicious(context.Background(), "peer1") + assert.Error(t, err) + }) +} + +func TestSimpleClientIsPeerUnhealthy(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + IsPeerUnhealthyFunc: func(ctx context.Context, in *p2p_api.IsPeerUnhealthyRequest, opts ...grpc.CallOption) (*p2p_api.IsPeerUnhealthyResponse, error) { + return &p2p_api.IsPeerUnhealthyResponse{IsUnhealthy: true, Reason: "low rep", ReputationScore: 12.5}, nil + }, + }) + unhealthy, reason, score, err := client.IsPeerUnhealthy(context.Background(), "peer1") + assert.NoError(t, err) + assert.True(t, unhealthy) + assert.Equal(t, "low rep", reason) + assert.InDelta(t, 12.5, score, 0.001) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + IsPeerUnhealthyFunc: func(ctx context.Context, in *p2p_api.IsPeerUnhealthyRequest, opts ...grpc.CallOption) (*p2p_api.IsPeerUnhealthyResponse, error) { + return nil, assert.AnError + }, + }) + _, _, _, err := client.IsPeerUnhealthy(context.Background(), "peer1") + assert.Error(t, err) + }) +} + +func TestSimpleClientGetPeerRegistry(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeerRegistryFunc: func(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*p2p_api.GetPeerRegistryResponse, error) { + return &p2p_api.GetPeerRegistryResponse{ + Peers: []*p2p_api.PeerRegistryInfo{ + {Id: "12D3KooWBhWMmHCXuyfM48dEPRsBzkemQQu71yC9rR2zHGmAjzQz", Height: 99, IsConnected: true}, + }, + }, nil + }, + }) + peers, err := client.GetPeerRegistry(context.Background()) + assert.NoError(t, err) + assert.Len(t, peers, 1) + assert.Equal(t, uint32(99), peers[0].Height) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeerRegistryFunc: func(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*p2p_api.GetPeerRegistryResponse, error) { + return nil, assert.AnError + }, + }) + _, err := client.GetPeerRegistry(context.Background()) + assert.Error(t, err) + }) +} + +func TestSimpleClientRecordBytesDownloaded(t *testing.T) { + t.Run("ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordBytesDownloadedFunc: func(ctx context.Context, in *p2p_api.RecordBytesDownloadedRequest, opts ...grpc.CallOption) (*p2p_api.RecordBytesDownloadedResponse, error) { + assert.Equal(t, uint64(2048), in.BytesDownloaded) + return &p2p_api.RecordBytesDownloadedResponse{Ok: true}, nil + }, + }) + assert.NoError(t, client.RecordBytesDownloaded(context.Background(), "peer1", 2048)) + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordBytesDownloadedFunc: func(ctx context.Context, in *p2p_api.RecordBytesDownloadedRequest, opts ...grpc.CallOption) (*p2p_api.RecordBytesDownloadedResponse, error) { + return nil, assert.AnError + }, + }) + assert.Error(t, client.RecordBytesDownloaded(context.Background(), "peer1", 0)) + }) + t.Run("not_ok", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + RecordBytesDownloadedFunc: func(ctx context.Context, in *p2p_api.RecordBytesDownloadedRequest, opts ...grpc.CallOption) (*p2p_api.RecordBytesDownloadedResponse, error) { + return &p2p_api.RecordBytesDownloadedResponse{Ok: false}, nil + }, + }) + err := client.RecordBytesDownloaded(context.Background(), "peer1", 0) + assert.Contains(t, err.Error(), "failed to record bytes downloaded") + }) +} + +func TestSimpleClientGetPeer(t *testing.T) { + t.Run("found", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeerFunc: func(ctx context.Context, in *p2p_api.GetPeerRequest, opts ...grpc.CallOption) (*p2p_api.GetPeerResponse, error) { + assert.Equal(t, "peer1", in.PeerId) + return &p2p_api.GetPeerResponse{ + Found: true, + Peer: &p2p_api.PeerRegistryInfo{ + Id: "12D3KooWBhWMmHCXuyfM48dEPRsBzkemQQu71yC9rR2zHGmAjzQz", + Height: 17, + }, + }, nil + }, + }) + info, err := client.GetPeer(context.Background(), "peer1") + assert.NoError(t, err) + assert.NotNil(t, info) + assert.Equal(t, uint32(17), info.Height) + }) + t.Run("not_found", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeerFunc: func(ctx context.Context, in *p2p_api.GetPeerRequest, opts ...grpc.CallOption) (*p2p_api.GetPeerResponse, error) { + return &p2p_api.GetPeerResponse{Found: false}, nil + }, + }) + info, err := client.GetPeer(context.Background(), "peer1") + assert.NoError(t, err) + assert.Nil(t, info, "not-found should return nil PeerInfo with no error") + }) + t.Run("grpc_error", func(t *testing.T) { + client := newClientWithMock(&MockPeerServiceClient{ + GetPeerFunc: func(ctx context.Context, in *p2p_api.GetPeerRequest, opts ...grpc.CallOption) (*p2p_api.GetPeerResponse, error) { + return nil, assert.AnError + }, + }) + _, err := client.GetPeer(context.Background(), "peer1") + assert.Error(t, err) + }) +} diff --git a/services/p2p/mock.go b/services/p2p/mock.go index de6a2385e..fa01b2947 100644 --- a/services/p2p/mock.go +++ b/services/p2p/mock.go @@ -29,8 +29,9 @@ import ( // mockNode.On("Start", mock.Anything, mock.Anything, mock.Anything).Return(nil) // // Use mockNode in place of real P2PNodeI implementation type MockServerP2PClient struct { - mock.Mock // Embedded mock for method call recording and expectations - peerID peer.ID // Configurable peer ID for testing scenarios + mock.Mock // Embedded mock for method call recording and expectations + peerID peer.ID // Configurable peer ID for testing scenarios + peers []p2pMessageBus.PeerInfo // If non-nil, returned by GetPeers() instead of the default } func (m *MockServerP2PClient) Start(ctx context.Context, streamHandler func(network.Stream), topicNames ...string) error { @@ -68,6 +69,10 @@ func (m *MockServerP2PClient) Connect(ctx context.Context, peerMultiaddr string) } func (m *MockServerP2PClient) GetPeers() []p2pMessageBus.PeerInfo { + if m.peers != nil { + return m.peers + } + peers := []p2pMessageBus.PeerInfo{} peers = append(peers, p2pMessageBus.PeerInfo{}) diff --git a/services/p2p/server_handler_test.go b/services/p2p/server_handler_test.go index 7861d25fd..94cc1c180 100644 --- a/services/p2p/server_handler_test.go +++ b/services/p2p/server_handler_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/bsv-blockchain/go-bt/v2/chainhash" + p2pMessageBus "github.com/bsv-blockchain/go-p2p-message-bus" "github.com/bsv-blockchain/teranode/model" "github.com/bsv-blockchain/teranode/services/blockchain" "github.com/bsv-blockchain/teranode/services/blockchain/blockchain_api" @@ -1280,6 +1281,383 @@ func TestHandleSubtreeTopic_OversizedDropped(t *testing.T) { assert.True(t, info.LastMessageTime.IsZero(), "oversized subtree must not advance LastMessageTime") } +// --- handleNodeStatusTopic branch coverage --- +// +// TestServerHandleNodeStatusTopic in Server_test.go covers self/remote happy +// paths; TestHandleNodeStatusTopic_OversizedDropped covers the size guard. +// These tests fill in the validation/error branches: bad JSON, peer ID +// spoofing, SSRF rejection, invalid block-hash, full notification channel, +// and the storage-update side effect. + +func TestHandleNodeStatusTopic_BadJSON(t *testing.T) { + server, remotePeerID, registry := newSizeLimitTestServer(t) + + // Garbage that passes the size guard but fails json.Unmarshal. + server.handleNodeStatusTopic(context.Background(), []byte("{not-json"), remotePeerID.String()) + + select { + case msg := <-server.notificationCh: + t.Fatalf("bad JSON must not produce a notification, got %+v", msg) + default: + } + info, exists := registry.Get(remotePeerID) + require.True(t, exists) + assert.True(t, info.LastMessageTime.IsZero(), "bad JSON must not advance LastMessageTime") +} + +func TestHandleNodeStatusTopic_PeerIDSpoofing(t *testing.T) { + server, remotePeerID, _ := newSizeLimitTestServer(t) + + // Make claimed peer differ from the gossip sender. + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + otherPeerID, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + msgBytes, err := json.Marshal(NodeStatusMessage{ + PeerID: otherPeerID.String(), + BestBlockHash: "0000000000000000000000000000000000000000000000000000000000000001", + BestHeight: 1, + }) + require.NoError(t, err) + + server.handleNodeStatusTopic(context.Background(), msgBytes, remotePeerID.String()) + + score, _, _ := server.banManager.GetBanScore(remotePeerID.String()) + assert.Equal(t, 20, score, "spoofing should add ReasonProtocolViolation (20) to sender score") + + select { + case msg := <-server.notificationCh: + t.Fatalf("spoofing must short-circuit before notification, got %+v", msg) + default: + } +} + +func TestHandleNodeStatusTopic_InvalidBaseURL(t *testing.T) { + server, remotePeerID, _ := newSizeLimitTestServer(t) + // AllowPrivateIPs is false by default in createBaseTestSettings, so a + // loopback URL is rejected by validateDataHubURL. + + msgBytes, err := json.Marshal(NodeStatusMessage{ + PeerID: remotePeerID.String(), + BaseURL: "http://127.0.0.1:8080", + }) + require.NoError(t, err) + + server.handleNodeStatusTopic(context.Background(), msgBytes, remotePeerID.String()) + + score, _, _ := server.banManager.GetBanScore(remotePeerID.String()) + assert.Equal(t, 20, score, "invalid BaseURL should add ReasonProtocolViolation (20) to sender score") + + select { + case msg := <-server.notificationCh: + t.Fatalf("SSRF rejection must short-circuit before notification, got %+v", msg) + default: + } +} + +func TestHandleNodeStatusTopic_InvalidBestBlockHash(t *testing.T) { + server, remotePeerID, registry := newSizeLimitTestServer(t) + + // Hash that fails chainhash.NewHashFromStr — but height > 0 to enter the + // branch in the first place. The notification fires before the hash + // parse, so it must still arrive. + msgBytes, err := json.Marshal(NodeStatusMessage{ + PeerID: remotePeerID.String(), + BestHeight: 42, + BestBlockHash: "not-a-real-hex-hash", + }) + require.NoError(t, err) + + server.handleNodeStatusTopic(context.Background(), msgBytes, remotePeerID.String()) + + select { + case msg := <-server.notificationCh: + assert.Equal(t, remotePeerID.String(), msg.PeerID) + default: + t.Fatal("notification should fire before invalid block-hash check") + } + + info, exists := registry.Get(remotePeerID) + require.True(t, exists) + assert.Equal(t, uint32(0), info.Height, "invalid block hash must abort before peer height update") +} + +func TestHandleNodeStatusTopic_NotificationChannelFull(t *testing.T) { + server, remotePeerID, _ := newSizeLimitTestServer(t) + // Replace the buffered channel with an unbuffered one so the non-blocking + // send hits the default branch. + server.notificationCh = make(chan *notificationMsg) + + msgBytes, err := json.Marshal(NodeStatusMessage{ + PeerID: remotePeerID.String(), + }) + require.NoError(t, err) + + // Must not panic or deadlock. + server.handleNodeStatusTopic(context.Background(), msgBytes, remotePeerID.String()) +} + +func TestHandleNodeStatusTopic_StorageUpdate(t *testing.T) { + server, remotePeerID, registry := newSizeLimitTestServer(t) + + validHash := "0000000000000000000000000000000000000000000000000000000000000001" + msgBytes, err := json.Marshal(NodeStatusMessage{ + PeerID: remotePeerID.String(), + BestHeight: 7, + BestBlockHash: validHash, + Storage: "full", + }) + require.NoError(t, err) + + server.handleNodeStatusTopic(context.Background(), msgBytes, remotePeerID.String()) + + info, exists := registry.Get(remotePeerID) + require.True(t, exists) + assert.Equal(t, "full", info.Storage, "storage mode should be propagated to the registry") + assert.Equal(t, uint32(7), info.Height) +} + +// --- shouldSkipDuringSync branch coverage --- +// +// shouldSkipDuringSync gates announcement processing while we're catching up +// from a designated sync peer. The function has six exit paths; these tests +// cover each one. Tests build a SyncCoordinator and write directly to its +// currentSyncPeer field (matching the pattern in sync_coordinator_test.go). + +func newSyncSkipTestServer(t *testing.T, fsm blockchain_api.FSMStateType, syncPeer peer.ID, syncPeerHeight uint32) *Server { + t.Helper() + + tSettings := createBaseTestSettings() + registry := NewPeerRegistry() + if syncPeer != "" && syncPeerHeight > 0 { + registry.Put(syncPeer, "", syncPeerHeight, nil, "") + } + + mockBC := new(blockchain.Mock) + state := fsm + mockBC.On("GetFSMCurrentState", mock.Anything).Return(&state, nil).Maybe() + + selector := NewPeerSelector(ulogger.New("test"), tSettings) + banManager := NewPeerBanManager(context.Background(), nil, tSettings, registry) + + sc := NewSyncCoordinator( + ulogger.New("test"), + tSettings, + registry, + selector, + banManager, + mockBC, + nil, + ) + if syncPeer != "" { + sc.mu.Lock() + sc.currentSyncPeer = syncPeer + sc.mu.Unlock() + } + + return &Server{ + logger: ulogger.New("test"), + settings: tSettings, + blockchainClient: mockBC, + peerRegistry: registry, + syncCoordinator: sc, + gCtx: context.Background(), + } +} + +func TestShouldSkipDuringSync_NoSyncPeer(t *testing.T) { + // syncCoordinator nil → getSyncPeer returns "" → function exits before + // touching the blockchain client. + server := &Server{ + logger: ulogger.New("test"), + settings: createBaseTestSettings(), + blockchainClient: nil, + peerRegistry: NewPeerRegistry(), + gCtx: context.Background(), + } + + skip := server.shouldSkipDuringSync("from", "originator", 100, "block") + assert.False(t, skip, "no sync peer must not skip") +} + +func TestShouldSkipDuringSync_NotSyncing(t *testing.T) { + // Sync peer is set but FSM reports RUNNING — we're caught up, so the + // announcement should pass through. + syncPeer := peer.ID("sync-peer") + server := newSyncSkipTestServer(t, blockchain_api.FSMStateType_RUNNING, syncPeer, 10) + + skip := server.shouldSkipDuringSync("from", "originator", 100, "block") + assert.False(t, skip, "RUNNING FSM must not skip") +} + +func TestShouldSkipDuringSync_BelowSyncPeerHeight(t *testing.T) { + // Syncing and announcement is older than where the sync peer already is. + syncPeer := peer.ID("sync-peer-1") + server := newSyncSkipTestServer(t, blockchain_api.FSMStateType_CATCHINGBLOCKS, syncPeer, 100) + + skip := server.shouldSkipDuringSync("from", syncPeer.String(), 50, "block") + assert.True(t, skip, "announcement below sync peer height must skip") +} + +func TestShouldSkipDuringSync_NotFromSyncPeer(t *testing.T) { + // Syncing, height ok, but originator is not the sync peer. + syncPeer := peer.ID("sync-peer-2") + server := newSyncSkipTestServer(t, blockchain_api.FSMStateType_CATCHINGBLOCKS, syncPeer, 10) + + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + otherPeer, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + skip := server.shouldSkipDuringSync("from", otherPeer.String(), 100, "block") + assert.True(t, skip, "announcement from non-sync peer must skip") +} + +func TestShouldSkipDuringSync_InvalidOriginator(t *testing.T) { + // Syncing, height ok, but originatorPeerID doesn't decode. Falls into the + // same "not from sync peer" branch via the err != nil short-circuit. + syncPeer := peer.ID("sync-peer-3") + server := newSyncSkipTestServer(t, blockchain_api.FSMStateType_LEGACYSYNCING, syncPeer, 10) + + skip := server.shouldSkipDuringSync("from", "not-a-valid-peer-id", 100, "block") + assert.True(t, skip, "undecodable originator must skip") +} + +func TestShouldSkipDuringSync_FromSyncPeer(t *testing.T) { + // All gates pass: sync peer set, FSM CATCHINGBLOCKS, height ok, originator + // is the sync peer. Announcement is allowed through. + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + syncPeer, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + server := newSyncSkipTestServer(t, blockchain_api.FSMStateType_CATCHINGBLOCKS, syncPeer, 10) + + skip := server.shouldSkipDuringSync("from", syncPeer.String(), 100, "block") + assert.False(t, skip, "announcement from sync peer at higher height must not skip") +} + +// --- handleBanEvent / disconnectBannedPeerByID branch coverage --- +// +// handleBanEvent dispatches BanEvents from the BanList; only "add" actions +// with a non-empty PeerID lead to a disconnect. disconnectBannedPeerByID +// iterates the connected-peers list and removes the peer if found. + +func TestHandleBanEvent_NonAddAction(t *testing.T) { + // "remove" / unset / anything other than banActionAdd should return + // before parsing the PeerID — so even an empty event is a no-op. + server := &Server{logger: ulogger.New("test")} + + server.handleBanEvent(context.Background(), BanEvent{Action: "remove", PeerID: "anything"}) + // Reaching this point without a panic confirms the early return. +} + +func TestHandleBanEvent_EmptyPeerID(t *testing.T) { + // PeerID-only banning: an "add" event without a PeerID is logged and + // dropped, never reaching peer.Decode. + server := &Server{logger: ulogger.New("test")} + + server.handleBanEvent(context.Background(), BanEvent{Action: banActionAdd, PeerID: ""}) +} + +func TestHandleBanEvent_InvalidPeerID(t *testing.T) { + // peer.Decode failure short-circuits before disconnectBannedPeerByID, + // so P2PClient can be nil — if disconnect ran, GetPeers would panic. + server := &Server{logger: ulogger.New("test")} + + server.handleBanEvent(context.Background(), BanEvent{ + Action: banActionAdd, + PeerID: "not-a-real-peer-id", + Reason: "test", + }) +} + +func TestHandleBanEvent_ValidPeerIDDispatchesDisconnect(t *testing.T) { + // End-to-end happy path: handleBanEvent decodes the PeerID and reaches + // disconnectBannedPeerByID, which finds the peer in GetPeers and removes + // it from the registry. + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + bannedPeer, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + mockP2P := new(MockServerP2PClient) + mockP2P.peers = []p2pMessageBus.PeerInfo{{ID: bannedPeer.String()}} + + registry := NewPeerRegistry() + registry.Put(bannedPeer, "", 0, nil, "") + + server := &Server{ + logger: ulogger.New("test"), + P2PClient: mockP2P, + peerRegistry: registry, + } + + server.handleBanEvent(context.Background(), BanEvent{ + Action: banActionAdd, + PeerID: bannedPeer.String(), + Reason: "spam", + }) + + _, exists := registry.Get(bannedPeer) + assert.False(t, exists, "banned peer should be removed from the registry after dispatch") +} + +func TestDisconnectBannedPeerByID_PeerFound(t *testing.T) { + // Direct test of the "peer in connected list" path: the registry entry + // should be cleared. + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + bannedPeer, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + mockP2P := new(MockServerP2PClient) + mockP2P.peers = []p2pMessageBus.PeerInfo{ + {ID: "some-other-peer"}, + {ID: bannedPeer.String()}, + } + + registry := NewPeerRegistry() + registry.Put(bannedPeer, "", 0, nil, "") + + server := &Server{ + logger: ulogger.New("test"), + P2PClient: mockP2P, + peerRegistry: registry, + } + + server.disconnectBannedPeerByID(context.Background(), bannedPeer, "manual") + + _, exists := registry.Get(bannedPeer) + assert.False(t, exists, "found peer must be removed from registry") +} + +func TestDisconnectBannedPeerByID_PeerNotFound(t *testing.T) { + // Peer is not in the connected list → debug log, no registry mutation. + _, pub, err := crypto.GenerateKeyPair(crypto.RSA, 2048) + require.NoError(t, err) + bannedPeer, err := peer.IDFromPublicKey(pub) + require.NoError(t, err) + + mockP2P := new(MockServerP2PClient) + mockP2P.peers = []p2pMessageBus.PeerInfo{{ID: "unrelated-peer"}} + + registry := NewPeerRegistry() + registry.Put(bannedPeer, "", 0, nil, "") + + server := &Server{ + logger: ulogger.New("test"), + P2PClient: mockP2P, + peerRegistry: registry, + } + + server.disconnectBannedPeerByID(context.Background(), bannedPeer, "manual") + + _, exists := registry.Get(bannedPeer) + assert.True(t, exists, "untracked peer must not affect registry") +} + // --- startPeerRegistryCleanup tests --- func TestStartPeerRegistryCleanup_NilRegistryReturnsEarly(t *testing.T) {