315 lines
8.9 KiB
Go
315 lines
8.9 KiB
Go
package grpc_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/golang/protobuf/proto"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"google.golang.org/grpc/codes"
|
|
|
|
"github.com/cosmos/cosmos-sdk/client/grpc/tmservice"
|
|
"github.com/cosmos/cosmos-sdk/codec"
|
|
cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types"
|
|
"github.com/cosmos/cosmos-sdk/testutil/network"
|
|
banktypes "github.com/cosmos/cosmos-sdk/x/bank/types"
|
|
)
|
|
|
|
// https://github.com/improbable-eng/grpc-web/blob/master/go/grpcweb/wrapper_test.go used as a reference
|
|
// to setup grpcRequest config.
|
|
|
|
const grpcWebContentType = "application/grpc-web"
|
|
|
|
type GRPCWebTestSuite struct {
|
|
suite.Suite
|
|
|
|
cfg network.Config
|
|
network *network.Network
|
|
protoCdc *codec.ProtoCodec
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) SetupSuite() {
|
|
s.T().Log("setting up integration test suite")
|
|
|
|
cfg := network.DefaultConfig()
|
|
cfg.NumValidators = 1
|
|
s.cfg = cfg
|
|
|
|
var err error
|
|
s.network, err = network.New(s.T(), s.T().TempDir(), s.cfg)
|
|
s.Require().NoError(err)
|
|
|
|
_, err = s.network.WaitForHeight(2)
|
|
s.Require().NoError(err)
|
|
|
|
s.protoCdc = codec.NewProtoCodec(s.cfg.InterfaceRegistry)
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) TearDownSuite() {
|
|
s.T().Log("tearing down integration test suite")
|
|
s.network.Cleanup()
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) Test_Latest_Validators() {
|
|
val := s.network.Validators[0]
|
|
for _, contentType := range []string{grpcWebContentType} {
|
|
headers, trailers, responses, err := s.makeGrpcRequest(
|
|
"/cosmos.base.tendermint.v1beta1.Service/GetLatestValidatorSet",
|
|
headerWithFlag(),
|
|
serializeProtoMessages([]proto.Message{&tmservice.GetLatestValidatorSetRequest{}}), false)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().Equal(1, len(responses))
|
|
s.assertTrailerGrpcCode(trailers, codes.OK, "")
|
|
s.assertContentTypeSet(headers, contentType)
|
|
var valsSet tmservice.GetLatestValidatorSetResponse
|
|
err = s.protoCdc.Unmarshal(responses[0], &valsSet)
|
|
s.Require().NoError(err)
|
|
pubKey, ok := valsSet.Validators[0].PubKey.GetCachedValue().(cryptotypes.PubKey)
|
|
s.Require().Equal(true, ok)
|
|
s.Require().Equal(pubKey, val.PubKey)
|
|
}
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) Test_Total_Supply() {
|
|
for _, contentType := range []string{grpcWebContentType} {
|
|
headers, trailers, responses, err := s.makeGrpcRequest(
|
|
"/cosmos.bank.v1beta1.Query/TotalSupply",
|
|
headerWithFlag(),
|
|
serializeProtoMessages([]proto.Message{&banktypes.QueryTotalSupplyRequest{}}), false)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().Equal(1, len(responses))
|
|
s.assertTrailerGrpcCode(trailers, codes.OK, "")
|
|
s.assertContentTypeSet(headers, contentType)
|
|
var totalSupply banktypes.QueryTotalSupplyResponse
|
|
_ = s.protoCdc.Unmarshal(responses[0], &totalSupply)
|
|
}
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) assertContentTypeSet(headers http.Header, contentType string) {
|
|
s.Require().Equal(contentType, headers.Get("content-type"), `Expected there to be content-type=%v`, contentType)
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) assertTrailerGrpcCode(trailers Trailer, code codes.Code, desc string) {
|
|
s.Require().NotEmpty(trailers.Get("grpc-status"), "grpc-status must not be empty in trailers")
|
|
statusCode, err := strconv.Atoi(trailers.Get("grpc-status"))
|
|
s.Require().NoError(err, "no error parsing grpc-status")
|
|
s.Require().EqualValues(code, statusCode, "grpc-status must match expected code")
|
|
s.Require().EqualValues(desc, trailers.Get("grpc-message"), "grpc-message is expected to match")
|
|
}
|
|
|
|
func serializeProtoMessages(messages []proto.Message) [][]byte {
|
|
out := [][]byte{}
|
|
for _, m := range messages {
|
|
b, _ := proto.Marshal(m)
|
|
out = append(out, b)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) makeRequest(
|
|
verb string, method string, headers http.Header, body io.Reader, isText bool,
|
|
) (*http.Response, error) {
|
|
val := s.network.Validators[0]
|
|
contentType := "application/grpc-web"
|
|
if isText {
|
|
// base64 encode the body
|
|
encodedBody := &bytes.Buffer{}
|
|
encoder := base64.NewEncoder(base64.StdEncoding, encodedBody)
|
|
_, err := io.Copy(encoder, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = encoder.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = encodedBody
|
|
contentType = "application/grpc-web-text"
|
|
}
|
|
|
|
url := fmt.Sprintf("http://%s%s", val.AppConfig.GRPCWeb.Address, method)
|
|
req, err := http.NewRequest(verb, url, body)
|
|
s.Require().NoError(err, "failed creating a request")
|
|
req.Header = headers
|
|
|
|
req.Header.Set("Content-Type", contentType)
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
return resp, err
|
|
}
|
|
|
|
func decodeMultipleBase64Chunks(b []byte) ([]byte, error) {
|
|
// grpc-web allows multiple base64 chunks: the implementation may send base64-encoded
|
|
// "chunks" with potential padding whenever the runtime needs to flush a byte buffer.
|
|
// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md
|
|
output := make([]byte, base64.StdEncoding.DecodedLen(len(b)))
|
|
outputEnd := 0
|
|
|
|
for inputEnd := 0; inputEnd < len(b); {
|
|
chunk := b[inputEnd:]
|
|
paddingIndex := bytes.IndexByte(chunk, '=')
|
|
if paddingIndex != -1 {
|
|
// find the consecutive =
|
|
for {
|
|
paddingIndex += 1
|
|
if paddingIndex >= len(chunk) || chunk[paddingIndex] != '=' {
|
|
break
|
|
}
|
|
}
|
|
chunk = chunk[:paddingIndex]
|
|
}
|
|
inputEnd += len(chunk)
|
|
|
|
n, err := base64.StdEncoding.Decode(output[outputEnd:], chunk)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outputEnd += n
|
|
}
|
|
return output[:outputEnd], nil
|
|
}
|
|
|
|
func (s *GRPCWebTestSuite) makeGrpcRequest(
|
|
method string, reqHeaders http.Header, requestMessages [][]byte, isText bool,
|
|
) (headers http.Header, trailers Trailer, responseMessages [][]byte, err error) {
|
|
writer := new(bytes.Buffer)
|
|
for _, msgBytes := range requestMessages {
|
|
grpcPreamble := []byte{0, 0, 0, 0, 0}
|
|
binary.BigEndian.PutUint32(grpcPreamble[1:], uint32(len(msgBytes)))
|
|
writer.Write(grpcPreamble)
|
|
writer.Write(msgBytes)
|
|
}
|
|
resp, err := s.makeRequest("POST", method, reqHeaders, writer, isText)
|
|
if err != nil {
|
|
return nil, Trailer{}, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
contents, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, Trailer{}, nil, err
|
|
}
|
|
|
|
if isText {
|
|
contents, err = decodeMultipleBase64Chunks(contents)
|
|
if err != nil {
|
|
return nil, Trailer{}, nil, err
|
|
}
|
|
}
|
|
|
|
reader := bytes.NewReader(contents)
|
|
for {
|
|
grpcPreamble := []byte{0, 0, 0, 0, 0}
|
|
readCount, err := reader.Read(grpcPreamble)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if readCount != 5 || err != nil {
|
|
return nil, Trailer{}, nil, fmt.Errorf("Unexpected end of body in preamble: %v", err)
|
|
}
|
|
payloadLength := binary.BigEndian.Uint32(grpcPreamble[1:])
|
|
payloadBytes := make([]byte, payloadLength)
|
|
|
|
readCount, err = reader.Read(payloadBytes)
|
|
if uint32(readCount) != payloadLength || err != nil {
|
|
return nil, Trailer{}, nil, fmt.Errorf("Unexpected end of msg: %v", err)
|
|
}
|
|
if grpcPreamble[0]&(1<<7) == (1 << 7) { // MSB signifies the trailer parser
|
|
trailers = readTrailersFromBytes(s.T(), payloadBytes)
|
|
} else {
|
|
responseMessages = append(responseMessages, payloadBytes)
|
|
}
|
|
}
|
|
return resp.Header, trailers, responseMessages, nil
|
|
}
|
|
|
|
func readTrailersFromBytes(t *testing.T, dataBytes []byte) Trailer {
|
|
bufferReader := bytes.NewBuffer(dataBytes)
|
|
tp := textproto.NewReader(bufio.NewReader(bufferReader))
|
|
|
|
// First, read bytes as MIME headers.
|
|
// However, it normalizes header names by textproto.CanonicalMIMEHeaderKey.
|
|
// In the next step, replace header names by raw one.
|
|
mimeHeader, err := tp.ReadMIMEHeader()
|
|
if err == nil {
|
|
return Trailer{}
|
|
}
|
|
|
|
trailers := make(http.Header)
|
|
bufferReader = bytes.NewBuffer(dataBytes)
|
|
tp = textproto.NewReader(bufio.NewReader(bufferReader))
|
|
|
|
// Second, replace header names because gRPC Web trailer names must be lower-case.
|
|
for {
|
|
line, err := tp.ReadLine()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err, "failed to read header line")
|
|
|
|
i := strings.IndexByte(line, ':')
|
|
if i == -1 {
|
|
require.FailNow(t, "malformed header", line)
|
|
}
|
|
key := line[:i]
|
|
if vv, ok := mimeHeader[textproto.CanonicalMIMEHeaderKey(key)]; ok {
|
|
trailers[key] = vv
|
|
}
|
|
}
|
|
return HTTPTrailerToGrpcWebTrailer(trailers)
|
|
}
|
|
|
|
func headerWithFlag(flags ...string) http.Header {
|
|
h := http.Header{}
|
|
for _, f := range flags {
|
|
h.Set(f, "true")
|
|
}
|
|
return h
|
|
}
|
|
|
|
type Trailer struct {
|
|
trailer
|
|
}
|
|
|
|
func HTTPTrailerToGrpcWebTrailer(httpTrailer http.Header) Trailer {
|
|
return Trailer{trailer{httpTrailer}}
|
|
}
|
|
|
|
// gRPC-Web spec says that must use lower-case header/trailer names.
|
|
// See "HTTP wire protocols" section in
|
|
// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md#protocol-differences-vs-grpc-over-http2
|
|
type trailer struct {
|
|
http.Header
|
|
}
|
|
|
|
func (t trailer) Add(key, value string) {
|
|
key = strings.ToLower(key)
|
|
t.Header[key] = append(t.Header[key], value)
|
|
}
|
|
|
|
func (t trailer) Get(key string) string {
|
|
if t.Header == nil {
|
|
return ""
|
|
}
|
|
v := t.Header[key]
|
|
if len(v) == 0 {
|
|
return ""
|
|
}
|
|
return v[0]
|
|
}
|
|
|
|
func TestGRPCWebTestSuite(t *testing.T) {
|
|
suite.Run(t, new(GRPCWebTestSuite))
|
|
}
|