diff --git a/client/accounting_test.go b/client/accounting_test.go index 570d724a..0086a42d 100644 --- a/client/accounting_test.go +++ b/client/accounting_test.go @@ -1,55 +1,50 @@ package client import ( - "bytes" "context" "errors" "fmt" - "math/rand" "testing" - "time" v2accounting "github.com/nspcc-dev/neofs-api-go/v2/accounting" protoaccounting "github.com/nspcc-dev/neofs-api-go/v2/accounting/grpc" - protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" - protostatus "github.com/nspcc-dev/neofs-api-go/v2/status/grpc" - accountingtest "github.com/nspcc-dev/neofs-sdk-go/accounting/test" - apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" - neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" "github.com/nspcc-dev/neofs-sdk-go/stat" "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" - "github.com/nspcc-dev/neofs-sdk-go/version" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/proto" ) -func newDefaultAccountingService(srv protoaccounting.AccountingServiceServer) testService { +// returns Client-compatible Accounting service handled by given server. +// Provided server must implement [protoaccounting.AccountingServiceServer]: the +// parameter is not of this type to support generics. +func newDefaultAccountingService(t testing.TB, srv any) testService { + require.Implements(t, (*protoaccounting.AccountingServiceServer)(nil), srv) return testService{desc: &protoaccounting.AccountingService_ServiceDesc, impl: srv} } -// returns Client of Accounting service provided by given server. -func newTestAccountingClient(t testing.TB, srv protoaccounting.AccountingServiceServer) *Client { - return newClient(t, newDefaultAccountingService(srv)) +// returns Client of Accounting service provided by given server. Provided +// server must implement [protoaccounting.AccountingServiceServer]: the +// parameter is not of this type to support generics. +func newTestAccountingClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultAccountingService(t, srv)) } type testGetBalanceServer struct { protoaccounting.UnimplementedAccountingServiceServer - - reqXHdrs []string - reqAcc []byte - - handlerErr error - - respSleep time.Duration - respUnsigned bool - respSigner neofscrypto.Signer - respMeta *protosession.ResponseMetaHeader - respBodyCons func() *protoaccounting.BalanceResponse_Body + testCommonServerSettings[ + *protoaccounting.BalanceRequest_Body, + v2accounting.BalanceRequestBody, + *v2accounting.BalanceRequestBody, + *protoaccounting.BalanceRequest, + v2accounting.BalanceRequest, + *v2accounting.BalanceRequest, + *protoaccounting.BalanceResponse_Body, + protoaccounting.BalanceResponse, + v2accounting.BalanceResponse, + *v2accounting.BalanceResponse, + ] + reqAcc *user.ID } // returns [protoaccounting.AccountingServiceServer] supporting Balance method @@ -57,128 +52,24 @@ type testGetBalanceServer struct { // responds with any valid message. Some methods allow to tune the behavior. func newTestGetBalanceServer() *testGetBalanceServer { return new(testGetBalanceServer) } -// makes the server to assert that any request has given X-headers. By -// default, no headers are expected. -func (x *testGetBalanceServer) checkRequestXHeaders(xhdrs []string) { - if len(xhdrs)%2 != 0 { - panic("odd number of elements") - } - x.reqXHdrs = xhdrs -} - // makes the server to assert that any request is for the given // account. By default, any account is accepted. func (x *testGetBalanceServer) checkRequestAccount(acc user.ID) { - x.reqAcc = acc[:] -} - -// tells the server whether to sign all the responses or not. By default, any -// response is signed. -// -// Calling with false overrides signResponsesBy. -func (x *testGetBalanceServer) setEnabledResponseSigning(sign bool) { - x.respUnsigned = !sign -} - -// makes the server to always sign responses using given signer. By default, -// random signer is used. -// -// Has no effect with signing is disabled using setEnabledResponseSigning. -func (x *testGetBalanceServer) signResponsesBy(signer neofscrypto.Signer) { - x.respSigner = signer -} - -// makes the server to always respond with the specifically constructed body. By -// default, any valid body is returned. -// -// Conflicts with respondWithBalance. -func (x *testGetBalanceServer) respondWithBody(newBody func() *protoaccounting.BalanceResponse_Body) { - x.respBodyCons = newBody -} - -// makes the server to always respond with the given balance. By default, any -// valid balance is returned. -// -// Conflicts with respondWithBody. -func (x *testGetBalanceServer) respondWithBalance(balance *protoaccounting.Decimal) { - x.respondWithBody(func() *protoaccounting.BalanceResponse_Body { - return &protoaccounting.BalanceResponse_Body{Balance: balance} - }) -} - -// makes the server to always respond with the given meta header. By default, -// empty header is returned. -// -// Conflicts with respondWithStatus. -func (x *testGetBalanceServer) respondWithMeta(meta *protosession.ResponseMetaHeader) { - x.respMeta = meta -} - -// makes the server to always respond with the given status. By default, status -// OK is returned. -// -// Conflicts with respondWithMeta. -func (x *testGetBalanceServer) respondWithStatus(st *protostatus.Status) { - x.respondWithMeta(&protosession.ResponseMetaHeader{Status: st}) -} - -// makes the server to return given error from the handler. By default, some -// response message is returned. -func (x *testGetBalanceServer) setHandlerError(err error) { - x.handlerErr = err -} - -// makes the server to sleep specified time before any request processing. By -// default, and if dur is non-positive, request is handled instantly. -func (x *testGetBalanceServer) setSleepDuration(dur time.Duration) { - x.respSleep = dur + x.reqAcc = &acc } -func (x *testGetBalanceServer) verifyBalanceRequest(req *protoaccounting.BalanceRequest) error { - // signatures - var reqV2 v2accounting.BalanceRequest - if err := reqV2.FromGRPCMessage(req); err != nil { - panic(err) - } - if err := verifyServiceMessage(&reqV2); err != nil { - return newInvalidRequestVerificationHeaderErr(err) +func (x *testGetBalanceServer) verifyRequest(req *protoaccounting.BalanceRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err } // meta header - metaHdr := req.MetaHeader - curVersion := version.Current() - switch { - case metaHdr == nil: - return newInvalidRequestErr(errors.New("missing meta header")) - case metaHdr.Version == nil: - return newInvalidRequestMetaHeaderErr(errors.New("missing protocol version")) - case metaHdr.Version.Major != curVersion.Major() || metaHdr.Version.Minor != curVersion.Minor(): - return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong protocol version v%d.%d, expected %s", - metaHdr.Version.Major, metaHdr.Version.Minor, curVersion)) - case metaHdr.Epoch != 0: - return newInvalidRequestMetaHeaderErr(fmt.Errorf("non-zero epoch #%d", metaHdr.Epoch)) + switch metaHdr := req.MetaHeader; { case metaHdr.Ttl != 2: - return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Epoch)) + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) case metaHdr.SessionToken != nil: return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) case metaHdr.BearerToken != nil: return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) - case metaHdr.MagicNumber != 0: - return newInvalidRequestMetaHeaderErr(fmt.Errorf("non-zero network magic #%d", metaHdr.MagicNumber)) - case metaHdr.Origin != nil: - return newInvalidRequestMetaHeaderErr(errors.New("origin header is presented while should not be")) - case len(metaHdr.XHeaders) != len(x.reqXHdrs)/2: - return newInvalidRequestMetaHeaderErr(fmt.Errorf("number of x-headers %d differs parameterized %d", - len(metaHdr.XHeaders), len(x.reqXHdrs)/2)) - } - for i := range metaHdr.XHeaders { - if metaHdr.XHeaders[i].Key != x.reqXHdrs[2*i] { - return newInvalidRequestMetaHeaderErr(fmt.Errorf("x-header #%d key %q does not equal parameterized %q", - i, metaHdr.XHeaders[i].Key, x.reqXHdrs[2*i])) - } - if metaHdr.XHeaders[i].Value != x.reqXHdrs[2*i+1] { - return newInvalidRequestMetaHeaderErr(fmt.Errorf("x-header #%d value %q does not equal parameterized %q", - i, metaHdr.XHeaders[i].Value, x.reqXHdrs[2*i+1])) - } } // body body := req.Body @@ -188,54 +79,29 @@ func (x *testGetBalanceServer) verifyBalanceRequest(req *protoaccounting.Balance case body.OwnerId == nil: return newErrMissingRequestBodyField("account") } - if x.reqAcc != nil && !bytes.Equal(body.OwnerId.Value, x.reqAcc[:]) { - return newErrInvalidRequestField("account", fmt.Errorf("test input mismatch")) + if x.reqAcc != nil { + if err := checkUserIDTransport(*x.reqAcc, body.OwnerId); err != nil { + return newErrInvalidRequestField("account", err) + } } return nil } func (x *testGetBalanceServer) Balance(_ context.Context, req *protoaccounting.BalanceRequest) (*protoaccounting.BalanceResponse, error) { - time.Sleep(x.respSleep) - - if err := x.verifyBalanceRequest(req); err != nil { + if err := x.verifyRequest(req); err != nil { return nil, err } - if x.handlerErr != nil { - return nil, x.handlerErr - } - resp := protoaccounting.BalanceResponse{ MetaHeader: x.respMeta, } - if x.respBodyCons != nil { - resp.Body = x.respBodyCons() + if x.respBodyForced { + resp.Body = x.respBody } else { - resp.Body = &protoaccounting.BalanceResponse_Body{ - Balance: &protoaccounting.Decimal{ - Value: rand.Int63(), - Precision: rand.Uint32(), - }, - } + resp.Body = proto.Clone(validMinBalanceResponseBody).(*protoaccounting.BalanceResponse_Body) } - if x.respUnsigned { - return &resp, nil - } - - var respV2 v2accounting.BalanceResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) - } - signer := x.respSigner - if signer == nil { - signer = neofscryptotest.Signer() - } - if err := signServiceMessage(signer, &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) - } - - return respV2.ToGRPCMessage().(*protoaccounting.BalanceResponse), nil + return x.signResponse(&resp) } func TestClient_BalanceGet(t *testing.T) { @@ -251,291 +117,137 @@ func TestClient_BalanceGet(t *testing.T) { require.ErrorIs(t, err, ErrMissingAccount) }) }) - t.Run("exact in-out", func(t *testing.T) { + t.Run("messages", func(t *testing.T) { /* This test is dedicated for cases when user input results in sending a certain request to the server and receiving a specific response to it. For user input errors, transport, client internals, etc. see/add other tests. */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestGetBalanceServer() + c := newTestAccountingClient(t, srv) - balance := accountingtest.Decimal() - acc := usertest.ID() - xhdrs := []string{ - "x-key1", "x-val1", - "x-key2", "x-val2", - } - - srv := newTestGetBalanceServer() - srv.checkRequestAccount(acc) - srv.checkRequestXHeaders(xhdrs) - srv.respondWithBalance(&protoaccounting.Decimal{ - Value: balance.Value(), - Precision: balance.Precision(), - }) - - c := newTestAccountingClient(t, srv) - - var prm PrmBalanceGet - prm.SetAccount(acc) - prm.WithXHeaders(xhdrs...) - res, err := c.BalanceGet(ctx, prm) - require.NoError(t, err) - require.Equal(t, balance, res) - - // statuses - type customStatusTestcase struct { - msg string - detail *protostatus.Status_Detail - assert func(testing.TB, error) - } - for _, tc := range []struct { - code uint32 - err error - constErr error - custom []customStatusTestcase - }{ - // TODO: use const codes after transition to current module's proto lib - {code: 1024, err: new(apistatus.ServerInternal), constErr: apistatus.ErrServerInternal, custom: []customStatusTestcase{ - {msg: "some server failure", assert: func(t testing.TB, err error) { - var e *apistatus.ServerInternal - require.ErrorAs(t, err, &e) - require.Equal(t, "some server failure", e.Message()) - }}, - }}, - {code: 1025, err: new(apistatus.WrongMagicNumber), constErr: apistatus.ErrWrongMagicNumber, custom: []customStatusTestcase{ - {assert: func(t testing.TB, err error) { - var e *apistatus.WrongMagicNumber - require.ErrorAs(t, err, &e) - _, ok := e.CorrectMagic() - require.Zero(t, ok) - }}, - { - detail: &protostatus.Status_Detail{Id: 0, Value: []byte{140, 15, 162, 245, 219, 236, 37, 191}}, - assert: func(t testing.TB, err error) { - var e *apistatus.WrongMagicNumber - require.ErrorAs(t, err, &e) - magic, ok := e.CorrectMagic() - require.EqualValues(t, 1, ok) - require.EqualValues(t, uint64(10092464466800944575), magic) - }, - }, - { - detail: &protostatus.Status_Detail{Id: 0, Value: []byte{1, 2, 3}}, - assert: func(t testing.TB, err error) { - var e *apistatus.WrongMagicNumber - require.ErrorAs(t, err, &e) - _, ok := e.CorrectMagic() - require.EqualValues(t, -1, ok) - }, - }, - }}, - {code: 1026, err: new(apistatus.SignatureVerification), constErr: apistatus.ErrSignatureVerification, custom: []customStatusTestcase{ - {msg: "invalid request signature", assert: func(t testing.TB, err error) { - var e *apistatus.SignatureVerification - require.ErrorAs(t, err, &e) - require.Equal(t, "invalid request signature", e.Message()) - }}, - }}, - {code: 1027, err: new(apistatus.NodeUnderMaintenance), constErr: apistatus.ErrNodeUnderMaintenance, custom: []customStatusTestcase{ - {msg: "node is under maintenance", assert: func(t testing.TB, err error) { - var e *apistatus.NodeUnderMaintenance - require.ErrorAs(t, err, &e) - require.Equal(t, "node is under maintenance", e.Message()) - }}, - }}, - } { - st := &protostatus.Status{Code: tc.code} - srv.respondWithStatus(st) - - res, err := c.BalanceGet(ctx, prm) - require.Zero(t, res) - require.ErrorAs(t, err, &tc.err) - require.ErrorIs(t, err, tc.constErr) - - for _, tcCustom := range tc.custom { - st.Message = tcCustom.msg - if tcCustom.detail != nil { - st.Details = []*protostatus.Status_Detail{tcCustom.detail} - } - srv.respondWithStatus(st) + var prm PrmBalanceGet + prm.SetAccount(anyUsr) + srv.checkRequestAccount(anyUsr) + srv.authenticateRequest(c.prm.signer) _, err := c.BalanceGet(ctx, prm) - require.ErrorAs(t, err, &tc.err) - tcCustom.assert(t, tc.err) - } - } + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestGetBalanceServer, newTestAccountingClient, func(c *Client, xhs []string) error { + opts := anyValidPrm + opts.WithXHeaders(xhs...) + _, err := c.BalanceGet(ctx, opts) + return err + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoaccounting.BalanceResponse_Body + }{ + {name: "min", body: validMinBalanceResponseBody}, + {name: "full", body: validFullBalanceResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestGetBalanceServer() + c := newTestAccountingClient(t, srv) + + var prm PrmBalanceGet + prm.SetAccount(anyUsr) + + srv.respondWithBody(tc.body) + balance, err := c.BalanceGet(ctx, anyValidPrm) + require.NoError(t, err) + require.NoError(t, checkBalanceTransport(balance, tc.body.GetBalance())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestGetBalanceServer, newTestAccountingClient, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "accounting.AccountingService", "Balance", func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestGetBalanceServer, newTestAccountingClient, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + tcs := []invalidResponseBodyTestcase[protoaccounting.BalanceResponse_Body]{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing balance field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "missing", body: new(protoaccounting.BalanceResponse_Body), + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing balance field in the response") + }}, + } + + testInvalidResponseBodies(t, newTestGetBalanceServer, newTestAccountingClient, tcs, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestGetBalanceServer, newTestAccountingClient, func(ctx context.Context, c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) }) t.Run("sign request failure", func(t *testing.T) { - c.prm.signer = neofscryptotest.FailSigner(neofscryptotest.Signer()) - _, err := c.BalanceGet(ctx, anyValidPrm) - require.ErrorContains(t, err, "sign request") + testSignRequestFailure(t, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) }) t.Run("transport failure", func(t *testing.T) { - // note: errors returned from gRPC handlers are gRPC statuses, therefore, - // strictly speaking, they are not transport errors (like connection refusal for - // example). At the same time, according to the NeoFS protocol, all its statuses - // are transmitted in the message. So, returning an error from gRPC handler - // instead of a status field in the response is a protocol violation and can be - // equated to a transport error. - transportErr := errors.New("any transport failure") - srv := newTestGetBalanceServer() - srv.setHandlerError(transportErr) - c := newTestAccountingClient(t, srv) - - _, err := c.BalanceGet(ctx, anyValidPrm) - require.ErrorContains(t, err, "rpc failure") - require.ErrorContains(t, err, "write request") - st, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, codes.Unknown, st.Code()) - require.Contains(t, st.Message(), transportErr.Error()) - }) - t.Run("response message decoding failure", func(t *testing.T) { - svc := testService{ - desc: &grpc.ServiceDesc{ServiceName: "neo.fs.v2.accounting.AccountingService", Methods: []grpc.MethodDesc{ - { - MethodName: "Balance", - Handler: func(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { - return timestamppb.Now(), nil // any completely different message - }, - }, - }}, - impl: nil, // disables interface assert - } - c := newClient(t, svc) - _, err := c.BalanceGet(ctx, anyValidPrm) - require.ErrorContains(t, err, "invalid response signature") - // TODO: Although the client will not accept such a response, current error - // does not make it clear what exactly the problem is. It is worth reacting to - // the incorrect structure if possible. - }) - t.Run("invalid response verification header", func(t *testing.T) { - srv := newTestGetBalanceServer() - srv.setEnabledResponseSigning(false) - // TODO: add cases with less radical corruption such as replacing one byte or - // dropping only one of the signatures - c := newTestAccountingClient(t, srv) - - _, err := c.BalanceGet(ctx, anyValidPrm) - require.ErrorContains(t, err, "invalid response signature") - }) - t.Run("invalid response body", func(t *testing.T) { - for _, tc := range []struct { - name string - body *protoaccounting.BalanceResponse_Body - assertErr func(testing.TB, error) - }{ - {name: "missing", body: nil, assertErr: func(t testing.TB, err error) { - require.ErrorIs(t, err, MissingResponseFieldErr{}) - require.EqualError(t, err, "missing balance field in the response") - // TODO: worth clarifying that body is completely missing? - }}, - {name: "missing", body: new(protoaccounting.BalanceResponse_Body), assertErr: func(t testing.TB, err error) { - require.ErrorIs(t, err, MissingResponseFieldErr{}) - require.EqualError(t, err, "missing balance field in the response") - }}, - } { - t.Run(tc.name, func(t *testing.T) { - srv := newTestGetBalanceServer() - srv.respondWithBody(func() *protoaccounting.BalanceResponse_Body { return tc.body }) - c := newTestAccountingClient(t, srv) - - _, err := c.BalanceGet(ctx, anyValidPrm) - tc.assertErr(t, err) - }) - } + testTransportFailure(t, newTestGetBalanceServer, newTestAccountingClient, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) }) t.Run("response callback", func(t *testing.T) { - // NetmapService.LocalNodeInfo is called on dial, so it should also be - // initialized. The handler is called for it too. - nodeInfoSrvSigner := neofscryptotest.Signer() - nodeInfoSrvEpoch := rand.Uint64() - nodeInfoSrv := newTestGetNodeInfoServer() - nodeInfoSrv.respondWithMeta(&protosession.ResponseMetaHeader{Epoch: nodeInfoSrvEpoch}) - nodeInfoSrv.signResponsesBy(nodeInfoSrvSigner) - - balanceSrvSigner := neofscryptotest.Signer() - balanceSrvEpoch := nodeInfoSrvEpoch + 1 - balanceSrv := newTestGetBalanceServer() - balanceSrv.respondWithMeta(&protosession.ResponseMetaHeader{Epoch: balanceSrvEpoch}) - balanceSrv.signResponsesBy(balanceSrvSigner) - - var collected []ResponseMetaInfo - var cbErr error - c := newClientWithResponseCallback(t, func(meta ResponseMetaInfo) error { - collected = append(collected, meta) - return cbErr - }, - newDefaultNetmapServiceDesc(nodeInfoSrv), - newDefaultAccountingService(balanceSrv), - ) - - _, err := c.BalanceGet(ctx, anyValidPrm) - require.NoError(t, err) - require.Equal(t, []ResponseMetaInfo{ - {key: nodeInfoSrvSigner.PublicKeyBytes, epoch: nodeInfoSrvEpoch}, - {key: balanceSrvSigner.PublicKeyBytes, epoch: balanceSrvEpoch}, - }, collected) - - cbErr = errors.New("any response meta handler failure") - _, err = c.BalanceGet(ctx, anyValidPrm) - require.ErrorContains(t, err, "response callback error") - require.ErrorContains(t, err, err.Error()) - require.Len(t, collected, 3) - require.Equal(t, collected[2], collected[1]) + testResponseCallback(t, newTestGetBalanceServer, newDefaultAccountingService, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }) }) t.Run("exec statistics", func(t *testing.T) { - // NetmapService.LocalNodeInfo is called on dial, so it should also be - // initialized. Statistics are tracked for it too. - nodeEndpoint := "grpc://localhost:8082" // any valid - nodePub := []byte("any public key") - - nodeInfoSrv := newTestGetNodeInfoServer() - nodeInfoSrv.respondWithNodePublicKey(nodePub) - - balanceSrv := newTestGetBalanceServer() - - type statItem struct { - mtd stat.Method - dur time.Duration - err error - } - var lastItem *statItem - cb := func(pub []byte, endpoint string, mtd stat.Method, dur time.Duration, err error) { - if lastItem == nil { - require.Nil(t, pub) - } else { - require.Equal(t, nodePub, pub) - } - require.Equal(t, nodeEndpoint, endpoint) - require.Positive(t, dur) - lastItem = &statItem{mtd, dur, err} - } - - c := newCustomClient(t, nodeEndpoint, func(prm *PrmInit) { prm.SetStatisticCallback(cb) }, - newDefaultNetmapServiceDesc(nodeInfoSrv), - newDefaultAccountingService(balanceSrv), + testStatistic(t, newTestGetBalanceServer, newDefaultAccountingService, stat.MethodBalanceGet, + nil, + []testedClientOp{func(c *Client) error { + _, err := c.BalanceGet(ctx, PrmBalanceGet{}) + return err + }}, func(c *Client) error { + _, err := c.BalanceGet(ctx, anyValidPrm) + return err + }, ) - // dial - require.NotNil(t, lastItem) - require.Equal(t, stat.MethodEndpointInfo, lastItem.mtd) - require.Positive(t, lastItem.dur) - require.NoError(t, lastItem.err) - - // failure - _, callErr := c.BalanceGet(ctx, PrmBalanceGet{}) - require.Error(t, callErr) - require.Equal(t, stat.MethodBalanceGet, lastItem.mtd) - require.Positive(t, lastItem.dur) - require.Equal(t, callErr, lastItem.err) - - // OK - sleepDur := 100 * time.Millisecond - // duration is pretty short overall, but most likely larger than the exec time w/o sleep - balanceSrv.setSleepDuration(sleepDur) - _, _ = c.BalanceGet(ctx, anyValidPrm) - require.Equal(t, stat.MethodBalanceGet, lastItem.mtd) - require.Greater(t, lastItem.dur, sleepDur) - require.NoError(t, lastItem.err) }) } diff --git a/client/client_test.go b/client/client_test.go index 03004157..6f182205 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,17 +2,38 @@ package client import ( "context" + "crypto/sha256" + "errors" "fmt" + "math/rand" "net" + "slices" + "strconv" "testing" + "time" + protonetmap "github.com/nspcc-dev/neofs-api-go/v2/netmap/grpc" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + apigrpc "github.com/nspcc-dev/neofs-api-go/v2/rpc/grpc" + apisession "github.com/nspcc-dev/neofs-api-go/v2/session" + protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" + protostatus "github.com/nspcc-dev/neofs-api-go/v2/status/grpc" apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" + neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" + "github.com/nspcc-dev/neofs-sdk-go/eacl" + "github.com/nspcc-dev/neofs-sdk-go/stat" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" + "github.com/nspcc-dev/neofs-sdk-go/version" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) /* @@ -49,6 +70,19 @@ func newErrInvalidRequestField(name string, err error) error { return newInvalidRequestBodyErr(fmt.Errorf("invalid %s field: %w", name, err)) } +// static server settings used for [Client] testing. +var ( + testServerEndpoint = "localhost:8080" + testServerSignerOnDial = neofscryptotest.Signer() + testServerStateOnDial = struct { + pub []byte + epoch uint64 + }{ + pub: neofscrypto.PublicKeyBytes(testServerSignerOnDial.Public()), + epoch: rand.Uint64(), + } +) + // pairs service spec and implementation to-be-registered in some [grpc.Server]. type testService struct { desc *grpc.ServiceDesc @@ -57,7 +91,7 @@ type testService struct { // the most generic alternative of newClient. Both endpoint and parameter setter // are optional. -func newCustomClient(t testing.TB, endpoint string, setPrm func(*PrmInit), svcs ...testService) *Client { +func newCustomClient(t testing.TB, setPrm func(*PrmInit), svcs ...testService) *Client { var prm PrmInit if setPrm != nil { setPrm(&prm) @@ -66,6 +100,66 @@ func newCustomClient(t testing.TB, endpoint string, setPrm func(*PrmInit), svcs c, err := New(prm) require.NoError(t, err) + // serve dial RPC + const netmapSvcName = "neo.fs.v2.netmap.NetmapService" + const nodeInfoMtdName = "LocalNodeInfo" + netmapSvcInd := -1 + nodeInfoMtdInd := -1 +loop: + for i := range svcs { + if svcs[i].desc.ServiceName == netmapSvcName { + netmapSvcInd = i + for j := range svcs[i].desc.Methods { + if svcs[i].desc.Methods[j].MethodName == nodeInfoMtdName { + nodeInfoMtdInd = j + break loop + } + } + } + } + + type nodeInfoServer interface { + LocalNodeInfo(context.Context, *protonetmap.LocalNodeInfoRequest) (*protonetmap.LocalNodeInfoResponse, error) + } + dialSrv := newTestGetNodeInfoServer() + dialSrv.signResponsesBy(testServerSignerOnDial) + dialSrv.respondWithNodePublicKey(testServerStateOnDial.pub) + dialSrv.respondWithMeta(&protosession.ResponseMetaHeader{Epoch: testServerStateOnDial.epoch}) + handleDial := func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + var req protonetmap.LocalNodeInfoRequest + if err := dec(&req); err != nil { + return nil, err + } + return dialSrv.LocalNodeInfo(ctx, &req) + } + + if netmapSvcInd < 0 { + svcs = append(svcs, testService{ + desc: &grpc.ServiceDesc{ + ServiceName: netmapSvcName, + HandlerType: (*nodeInfoServer)(nil), + Methods: []grpc.MethodDesc{{MethodName: nodeInfoMtdName, Handler: handleDial}}, + }, + }) + } else { + dcp := *svcs[netmapSvcInd].desc // safe copy prevents mutation + dcp.Methods = slices.Clone(dcp.Methods) + if nodeInfoMtdInd < 0 { + dcp.Methods = append(dcp.Methods, grpc.MethodDesc{MethodName: nodeInfoMtdName, Handler: handleDial}) + } else { + originalHandler := dcp.Methods[nodeInfoMtdInd].Handler + called := false + dcp.Methods[nodeInfoMtdInd].Handler = func(srv any, ctx context.Context, dec func(any) error, in grpc.UnaryServerInterceptor) (any, error) { + if !called { + called = true + return handleDial(srv, ctx, dec, in) + } + return originalHandler(srv, ctx, dec, in) + } + } + svcs[netmapSvcInd].desc = &dcp + } + srv := grpc.NewServer() for _, svc := range svcs { srv.RegisterService(svc.desc, svc.impl) @@ -75,34 +169,24 @@ func newCustomClient(t testing.TB, endpoint string, setPrm func(*PrmInit), svcs go func() { _ = srv.Serve(lis) }() var dialPrm PrmDial - if endpoint == "" { - endpoint = "grpc://localhost:8080" - } - dialPrm.SetServerURI(endpoint) // any valid + dialPrm.SetServerURI(testServerEndpoint) dialPrm.setDialFunc(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }) err = c.Dial(dialPrm) - if err != nil { - st, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, codes.Unimplemented, st.Code()) - } + require.NoError(t, err) return c } // extends newClient with response meta info callback. -func newClientWithResponseCallback(t testing.TB, cb func(ResponseMetaInfo) error, svcs ...testService) *Client { - return newCustomClient(t, "", func(prm *PrmInit) { prm.SetResponseInfoCallback(cb) }, svcs...) -} // returns ready-to-go [Client] of provided optional services. By default, any // other service is unsupported. // -// If caller registers stat callback (like [PrmInit.SetStatisticCallback] does) -// processing nodeKey, it must include NetmapService with implemented -// LocalNodeInfo method. +// Note: [Client] uses NetmapService.LocalNodeInfo RPC to dial the server. Test +// [Client] always receives testServerStateOnDial. Take this into account if the +// test keeps track of all ops like stat test. func newClient(t testing.TB, svcs ...testService) *Client { - return newCustomClient(t, "", nil, svcs...) + return newCustomClient(t, nil, svcs...) } func TestClient_Dial(t *testing.T) { @@ -210,3 +294,966 @@ type nopSigner struct{} func (nopSigner) Scheme() neofscrypto.Scheme { return neofscrypto.ECDSA_SHA512 } func (nopSigner) Sign([]byte) ([]byte, error) { return []byte("signature"), nil } func (x nopSigner) Public() neofscrypto.PublicKey { return nopPublicKey{} } + +// various cross-service protocol messages. Any message (incl. set elements) +// must be cloned via [proto.Clone] before passing anywhere. +var ( + // correct NeoFS protocol version with required fields only. + validMinProtoVersion = &protorefs.Version{} + // correct NeoFS protocol version with all fields. + validFullProtoVersion = &protorefs.Version{Major: 538919038, Minor: 3957317479} + // set of correct container IDs. + validProtoContainerIDs = []*protorefs.ContainerID{ + {Value: []byte{198, 137, 143, 192, 231, 50, 106, 89, 225, 118, 7, 42, 40, 225, 197, 183, 9, 205, 71, 140, 233, 30, 63, 73, 224, 244, 235, 18, 205, 45, 155, 236}}, + {Value: []byte{26, 71, 99, 242, 146, 121, 0, 142, 95, 50, 78, 190, 222, 104, 252, 72, 48, 219, 67, 226, 30, 90, 103, 51, 1, 234, 136, 143, 200, 240, 75, 250}}, + {Value: []byte{51, 124, 45, 83, 227, 119, 66, 76, 220, 196, 118, 197, 116, 44, 138, 83, 103, 102, 134, 191, 108, 124, 162, 255, 184, 137, 193, 242, 178, 10, 23, 29}}, + } +) + +// TODO: use eacltest.Table() after https://github.com/nspcc-dev/neofs-sdk-go/issues/606 +var anyValidEACL = eacl.NewTableForContainer(cidtest.ID(), []eacl.Record{ + eacl.ConstructRecord(eacl.ActionDeny, eacl.OperationPut, + []eacl.Target{ + eacl.NewTargetByRole(eacl.RoleOthers), + eacl.NewTargetByAccounts(usertest.IDs(3)), + }, + eacl.NewFilterObjectOwnerEquals(usertest.ID()), + eacl.NewObjectPropertyFilter("attr1", eacl.MatchStringEqual, "val1"), + ), +}) + +type ( + invalidSessionTokenProtoTestcase = struct { + name, msg string + corrupt func(*protosession.SessionToken) + } +) + +// various sets of cross-service testcases. +var ( + invalidUUIDProtoTestcases = []struct { + name, msg string + corrupt func(valid []byte) []byte + }{ + {name: "undersize", msg: "invalid UUID (got 15 bytes)", corrupt: func(valid []byte) []byte { + return valid[:15] + }}, + {name: "oversize", msg: "invalid UUID (got 17 bytes)", corrupt: func(valid []byte) []byte { + return append(valid, 1) + }}, + {name: "wrong version", msg: "wrong UUID version 3, expected 4", corrupt: func(valid []byte) []byte { + valid[6] = 3 << 4 + return valid + }}, + } + invalidContainerIDProtoTestcases = []struct { + name, msg string + corrupt func(valid *protorefs.ContainerID) + }{ + {name: "nil", msg: "invalid length 0", corrupt: func(valid *protorefs.ContainerID) { + valid.Value = nil + }}, + {name: "empty", msg: "invalid length 0", corrupt: func(valid *protorefs.ContainerID) { + valid.Value = []byte{} + }}, + {name: "undersize", msg: "invalid length 31", corrupt: func(valid *protorefs.ContainerID) { + valid.Value = valid.Value[:31] + }}, + {name: "oversize", msg: "invalid length 33", corrupt: func(valid *protorefs.ContainerID) { + valid.Value = append(valid.Value, 1) + }}, + {name: "zero", msg: "zero container ID", corrupt: func(valid *protorefs.ContainerID) { + for i := range valid.Value { + valid.Value[i] = 0 + } + }}, + } + invalidUserIDProtoTestcases = []struct { + name, msg string + corrupt func(valid *protorefs.OwnerID) + }{ + {name: "nil", msg: "invalid length 0, expected 25", corrupt: func(valid *protorefs.OwnerID) { + valid.Value = nil + }}, + {name: "empty", msg: "invalid length 0, expected 25", corrupt: func(valid *protorefs.OwnerID) { + valid.Value = []byte{} + }}, + {name: "owner/undersize", msg: "invalid length 24, expected 25", corrupt: func(valid *protorefs.OwnerID) { + valid.Value = valid.Value[:24] + }}, + {name: "owner/oversize", msg: "invalid length 26, expected 25", corrupt: func(valid *protorefs.OwnerID) { + valid.Value = append(valid.Value, 1) + }}, + {name: "owner/wrong prefix", msg: "invalid prefix byte 0x42, expected 0x35", corrupt: func(valid *protorefs.OwnerID) { + valid.Value[0] = 0x42 + h := sha256.Sum256(valid.Value[:21]) + hh := sha256.Sum256(h[:]) + copy(valid.Value[21:], hh[:]) + }}, + {name: "owner/wrong checksum", msg: "checksum mismatch", corrupt: func(valid *protorefs.OwnerID) { + valid.Value[24]++ + }}, + // TODO: would be better to see user.ErrZero in this case + {name: "owner/zero", msg: "invalid prefix byte 0x0, expected 0x35", corrupt: func(valid *protorefs.OwnerID) { + valid.Value = make([]byte, 25) + }}, + } + invalidObjectIDProtoTestcases = []struct { + name, msg string + corrupt func(valid *protorefs.ObjectID) + }{ + {name: "nil", msg: "invalid length 0", corrupt: func(valid *protorefs.ObjectID) { + valid.Value = nil + }}, + {name: "empty", msg: "invalid length 0", corrupt: func(valid *protorefs.ObjectID) { + valid.Value = []byte{} + }}, + {name: "undersize", msg: "invalid length 31", corrupt: func(valid *protorefs.ObjectID) { + valid.Value = valid.Value[:31] + }}, + {name: "oversize", msg: "invalid length 33", corrupt: func(valid *protorefs.ObjectID) { + valid.Value = append(valid.Value, 1) + }}, + {name: "zero", msg: "zero object ID", corrupt: func(valid *protorefs.ObjectID) { + for i := range valid.Value { + valid.Value[i] = 0 + } + }}, + } + invalidChecksumTestcases = []struct { + name, msg string + corrupt func(valid *protorefs.Checksum) + }{ + {name: "negative scheme", msg: "negative type -1", corrupt: func(valid *protorefs.Checksum) { + valid.Type = -1 + }}, + {name: "value/nil", msg: "missing value", corrupt: func(valid *protorefs.Checksum) { + valid.Sum = nil + }}, + {name: "value/empty", msg: "missing value", corrupt: func(valid *protorefs.Checksum) { + valid.Sum = []byte{} + }}, + } + invalidSignatureProtoTestcases = []struct { + name, msg string + corrupt func(valid *protorefs.Signature) + }{ + {name: "negative scheme", msg: "negative scheme -1", corrupt: func(valid *protorefs.Signature) { + valid.Scheme = -1 + }}, + } + invalidCommonSessionTokenProtoTestcases = []invalidSessionTokenProtoTestcase{ + {name: "body/nil", msg: "missing token body", corrupt: func(valid *protosession.SessionToken) { + valid.Body = nil + }}, + {name: "body/ID/nil", msg: "missing session ID", corrupt: func(valid *protosession.SessionToken) { + valid.Body.Id = nil + }}, + {name: "body/ID/empty", msg: "missing session ID", corrupt: func(valid *protosession.SessionToken) { + valid.Body.Id = []byte{} + }}, + // + other ID cases in init + {name: "body/issuer/nil", msg: "missing session issuer", corrupt: func(valid *protosession.SessionToken) { + valid.Body.OwnerId = nil + }}, + // + other issuer cases in init + {name: "body/lifetime", msg: "missing token lifetime", corrupt: func(valid *protosession.SessionToken) { + valid.Body.Lifetime = nil + }}, + {name: "body/session key/nil", msg: "missing session public key", corrupt: func(valid *protosession.SessionToken) { + valid.Body.SessionKey = nil + }}, + {name: "body/session key/empty", msg: "missing session public key", corrupt: func(valid *protosession.SessionToken) { + valid.Body.SessionKey = []byte{} + }}, + {name: "body/context/nil", msg: "missing session context", corrupt: func(valid *protosession.SessionToken) { + valid.Body.Context = nil + }}, + {name: "signature/nil", msg: "missing body signature", corrupt: func(valid *protosession.SessionToken) { + valid.Signature = nil + }}, + // + other signature cases in init + } +) + +func init() { + for _, tc := range invalidUUIDProtoTestcases { + invalidCommonSessionTokenProtoTestcases = append(invalidCommonSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "body/ID/" + tc.name, msg: "invalid session ID: " + tc.msg, + corrupt: func(valid *protosession.SessionToken) { valid.Body.Id = tc.corrupt(valid.Body.Id) }, + }) + } + for _, tc := range invalidUserIDProtoTestcases { + invalidCommonSessionTokenProtoTestcases = append(invalidCommonSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "body/issuer/" + tc.name, msg: "invalid session issuer: " + tc.msg, + corrupt: func(valid *protosession.SessionToken) { tc.corrupt(valid.Body.OwnerId) }, + }) + } + for _, tc := range invalidSignatureProtoTestcases { + invalidCommonSessionTokenProtoTestcases = append(invalidCommonSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "signature/" + tc.name, msg: "invalid body signature: " + tc.msg, + corrupt: func(valid *protosession.SessionToken) { tc.corrupt(valid.Signature) }, + }) + } +} + +// for sharing between servers of requests with required container ID. +type testRequiredContainerIDServerSettings struct { + expectedReqCnrID *cid.ID +} + +// makes the server to assert that any request carries given container ID. By +// default, any ID is accepted. +func (x *testRequiredContainerIDServerSettings) checkRequestContainerID(id cid.ID) { + x.expectedReqCnrID = &id +} + +func (x testRequiredContainerIDServerSettings) verifyRequestContainerID(m *protorefs.ContainerID) error { + if m == nil { + return newErrMissingRequestBodyField("container ID") + } + if x.expectedReqCnrID != nil { + if err := checkContainerIDTransport(*x.expectedReqCnrID, m); err != nil { + return newErrInvalidRequestField("container ID", err) + } + } + return nil +} + +// provides generic server code for various NeoFS API RPC servers. +type testCommonServerSettings[ + REQBODY apigrpc.Message, + REQBODYV2 any, + REQBODYV2PTR interface { + *REQBODYV2 + signedMessageV2 + }, + REQ interface { + GetBody() REQBODY + GetMetaHeader() *protosession.RequestMetaHeader + GetVerifyHeader() *protosession.RequestVerificationHeader + }, + REQV2 any, + REQV2PTR interface { + *REQV2 + FromGRPCMessage(apigrpc.Message) error + }, + RESPBODY proto.Message, + RESP any, + RESPV2 any, + RESPV2PTR interface { + *RESPV2 + ToGRPCMessage() apigrpc.Message + FromGRPCMessage(apigrpc.Message) error + }, +] struct { + handlerErr error + + reqCreds *authCredentials + reqXHdrs []string + + respSleep time.Duration + respUnsigned bool + respSigner neofscrypto.Signer + respMeta *protosession.ResponseMetaHeader + respBody RESPBODY + respBodyForced bool // if respBody = nil is explicitly set +} + +// makes the server to return given error as a gRPC status from the handler. By +// default, and if nil, some response message is returned. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) setHandlerError(err error) { + x.handlerErr = err +} + +// makes the server to assert that any request has given X-headers. By default, +// and if empty, no headers are expected. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) checkRequestXHeaders(xhdrs []string) { + if len(xhdrs)%2 != 0 { + panic("odd number of elements") + } + x.reqXHdrs = xhdrs +} + +// makes the server to sleep specified time before any request processing. By +// default, and if non-positive, request is handled instantly. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) setSleepDuration(dur time.Duration) { + x.respSleep = dur +} + +// tells the server whether to sign all the responses or not. By default, any +// response is signed. +// +// Overrides signResponsesBy. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) respondWithoutSigning() { + x.respUnsigned = true +} + +// makes the server to always sign responses using given signer. By default, and +// if nil, random signer is used. +// +// No-op if signing is disabled using respondWithoutSigning. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) signResponsesBy(signer neofscrypto.Signer) { + x.respSigner = signer +} + +// makes the server to always respond with the given meta header. By default, +// and if nil, no header is attached. +// +// Overrides respondWithStatus. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) respondWithMeta(meta *protosession.ResponseMetaHeader) { + x.respMeta = meta +} + +// makes the server to always respond with the given status. By default, status +// OK is returned. +// +// Overrides respondWithMeta. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) respondWithStatus(st *protostatus.Status) { + x.respondWithMeta(&protosession.ResponseMetaHeader{Status: st}) +} + +// makes the server to always respond with the given body. By default, any valid +// body is returned. +func (x *testCommonServerSettings[_, _, _, _, _, _, RESPBODY, _, _, _]) respondWithBody(body RESPBODY) { + x.respBody = proto.Clone(body).(RESPBODY) + x.respBodyForced = true +} + +// makes the server to assert that any request is signed by s. By default, any +// signer is accepted. +// +// Has no effect with checkRequestDataSignature. +func (x *testCommonServerSettings[_, _, _, _, _, _, _, _, _, _]) authenticateRequest(s neofscrypto.Signer) { + c := authCredentialsFromSigner(s) + x.reqCreds = &c +} + +func (x testCommonServerSettings[REQBODY, REQBODYV2, REQBODYV2PTR, REQ, _, _, _, _, _, _]) verifyRequest(req REQ) error { + time.Sleep(x.respSleep) + + body := req.GetBody() + metaHdr := req.GetMetaHeader() + verifyHdr := req.GetVerifyHeader() + + // signatures + if verifyHdr == nil { + return newInvalidRequestErr(errors.New("missing verification header")) + } + if verifyHdr.Origin != nil { + return newInvalidRequestVerificationHeaderErr(errors.New("origin field is set while should not be")) + } + if err := verifyMessageSignature[REQBODY, REQBODYV2, REQBODYV2PTR]( + body, verifyHdr.BodySignature, x.reqCreds); err != nil { + return newInvalidRequestVerificationHeaderErr(fmt.Errorf("body signature: %w", err)) + } + if err := verifyMessageSignature[*protosession.RequestMetaHeader, apisession.RequestMetaHeader, *apisession.RequestMetaHeader]( + metaHdr, verifyHdr.MetaSignature, x.reqCreds); err != nil { + return newInvalidRequestVerificationHeaderErr(fmt.Errorf("meta signature: %w", err)) + } + if err := verifyMessageSignature[*protosession.RequestVerificationHeader, apisession.RequestVerificationHeader, *apisession.RequestVerificationHeader]( + verifyHdr.Origin, verifyHdr.OriginSignature, x.reqCreds); err != nil { + return newInvalidRequestVerificationHeaderErr(fmt.Errorf("verification header's origin signature: %w", err)) + } + // meta header + curVersion := version.Current() + switch { + case metaHdr == nil: + return newInvalidRequestErr(errors.New("missing meta header")) + case metaHdr.Version == nil: + return newInvalidRequestMetaHeaderErr(errors.New("missing protocol version")) + case metaHdr.Version.Major != curVersion.Major() || metaHdr.Version.Minor != curVersion.Minor(): + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong protocol version v%d.%d, expected %s", + metaHdr.Version.Major, metaHdr.Version.Minor, curVersion)) + case metaHdr.Epoch != 0: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("non-zero epoch #%d", metaHdr.Epoch)) + case metaHdr.MagicNumber != 0: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("non-zero network magic #%d", metaHdr.MagicNumber)) + case metaHdr.Origin != nil: + return newInvalidRequestMetaHeaderErr(errors.New("origin header is presented while should not be")) + case len(metaHdr.XHeaders) != len(x.reqXHdrs)/2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("number of x-headers %d differs parameterized %d", + len(metaHdr.XHeaders), len(x.reqXHdrs)/2)) + } + for i := range metaHdr.XHeaders { + if metaHdr.XHeaders[i].Key != x.reqXHdrs[2*i] { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("x-header #%d key %q does not equal parameterized %q", + i, metaHdr.XHeaders[i].Key, x.reqXHdrs[2*i])) + } + if metaHdr.XHeaders[i].Value != x.reqXHdrs[2*i+1] { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("x-header #%d value %q does not equal parameterized %q", + i, metaHdr.XHeaders[i].Value, x.reqXHdrs[2*i+1])) + } + } + + return x.handlerErr +} + +func (x testCommonServerSettings[_, _, _, REQBODY, REQ, REQV2, _, RESP, RESPV2, RESPV2PTR]) signResponse(resp *RESP) (*RESP, error) { + if x.respUnsigned { + return resp, nil + } + var r RESPV2 + respV2 := RESPV2PTR(&r) + if err := respV2.FromGRPCMessage(resp); err != nil { + panic(err) + } + signer := x.respSigner + if signer == nil { + signer = neofscryptotest.Signer() + } + if err := signServiceMessage(signer, respV2, nil); err != nil { + return nil, fmt.Errorf("sign response message: %w", err) + } + return respV2.ToGRPCMessage().(*RESP), nil +} + +// func signature shortener. +type testedClientOp = func(*Client) error + +// asserts that built test server expecting particular X-headers receives them +// from the connected [Client] through on specified op execution. The op must be +// executed with all the correct parameters to return no error. +func testRequestXHeaders[SRV interface { + checkRequestXHeaders([]string) +}]( + t *testing.T, + newSrv func() SRV, + connect func(testing.TB, any /* SRV */) *Client, + op func(*Client, []string) error, +) { + xhdrs := []string{ + "x-key1", "x-val1", + "x-key2", "x-val2", + } + + srv := newSrv() + c := connect(t, srv) + + srv.checkRequestXHeaders(xhdrs) + err := op(c, xhdrs) + require.NoError(t, err) +} + +func assertSignRequestErr(t testing.TB, err error) { require.ErrorContains(t, err, "sign request") } + +// asserts that given op returns an error when the [Client]'s underlying signer +// fails to sign the request. The op must be executed with all the correct +// parameters. +func testSignRequestFailure(t testing.TB, op testedClientOp) { + c := newClient(t) + c.prm.signer = neofscryptotest.FailSigner(neofscryptotest.Signer()) + assertSignRequestErr(t, op(c)) +} + +func assertTransportErr(t testing.TB, transport, err error) { + require.ErrorContains(t, err, "rpc failure") + st, ok := status.FromError(err) + require.True(t, ok, err) + require.Equal(t, codes.Unknown, st.Code()) + require.Contains(t, st.Message(), transport.Error()) +} + +// asserts that given [Client] op returns an expected error when built test +// server always responds with gRPC status error. The op must be executed with +// all the correct parameters. +func testTransportFailure[SRV interface { + setHandlerError(error) +}]( + t testing.TB, + newSrv func() SRV, + connect func(t testing.TB, srv any) *Client, + op testedClientOp, +) { + transportErr := errors.New("any transport failure") + srv := newSrv() + srv.setHandlerError(transportErr) + c := connect(t, srv) + + err := op(c) + // note: errors returned from gRPC handlers are gRPC statuses, therefore, + // strictly speaking, they are not transport errors (like connection refusal for + // example). At the same time, according to the NeoFS protocol, all its statuses + // are transmitted in the message. So, returning an error from gRPC handler + // instead of a status field in the response is a protocol violation and can be + // equated to a transport error. + assertTransportErr(t, transportErr, err) +} + +// asserts that given [Client] op returns an expected error when built test +// server responds with incorrect verification header. The op must be executed +// with all the correct parameters. +func testInvalidResponseVerificationHeader[SRV interface { + respondWithoutSigning() +}]( + t testing.TB, + newSrv func() SRV, + connect func(t testing.TB, srv any) *Client, + op testedClientOp, +) { + srv := newSrv() + srv.respondWithoutSigning() + // TODO: add cases with less radical corruption such as replacing one byte or + // dropping only one of the signatures + c := connect(t, srv) + require.ErrorContains(t, op(c), "invalid response signature") +} + +type invalidResponseBodyTestcase[BODY any] struct { + name string + body *BODY + assertErr func(testing.TB, error) +} + +// asserts that given [Client] op returns expected errors when built test server +// responds with various invalid bodies. The op must be executed with all the +// correct parameters. +func testInvalidResponseBodies[BODY any, SRV interface { + respondWithBody(*BODY) +}]( + t *testing.T, + newSrv func() SRV, + connect func(t testing.TB, srv any) *Client, + tcs []invalidResponseBodyTestcase[BODY], + op testedClientOp, +) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + srv := newSrv() + srv.respondWithBody(tc.body) + c := connect(t, srv) + err := op(c) + tc.assertErr(t, err) + }) + } +} + +// asserts that given [Client] op returns expected context errors when user +// passes done context. The op must be executed with the provided context and +// correct other parameters. +func testContextErrors[SRV any]( + t *testing.T, + newSrv func() SRV, + connect func(t testing.TB, srv any) *Client, + op func(context.Context, *Client) error, +) { + t.Skip("https://github.com/nspcc-dev/neofs-sdk-go/issues/624") + srv := newSrv() + c := connect(t, srv) + require.NoError(t, op(context.Background(), c)) + t.Run("cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := op(ctx, c) + require.ErrorIs(t, err, context.Canceled) + }) + t.Run("timed out", func(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now()) + t.Cleanup(cancel) + err := op(ctx, c) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) +} + +// asserts that given [Client] op returns expected errors when server responds +// with various NeoFS statuses. The op must be executed with all the correct +// parameters. +func testStatusResponses[SRV interface { + respondWithStatus(*protostatus.Status) +}]( + t *testing.T, + newSrv func() SRV, + connect func(t testing.TB, srv any) *Client, + op testedClientOp, +) { + execWithStatus := func(code uint32, msg string, details []*protostatus.Status_Detail) error { + srv := newSrv() + st := &protostatus.Status{Code: code, Message: msg, Details: details} + srv.respondWithStatus(st) + c := connect(t, srv) + return op(c) + } + + t.Run("OK", func(t *testing.T) { + err := execWithStatus(0, "", make([]*protostatus.Status_Detail, 2)) + require.NoError(t, err) + }) + t.Run("unrecognized", func(t *testing.T) { + for _, code := range []uint32{ + 1, + 1023, + 1028, + 2054, + 3074, + 4098, + } { + t.Run("unrecognized_"+strconv.FormatUint(uint64(code), 10), func(t *testing.T) { + err := execWithStatus(code, "any message", make([]*protostatus.Status_Detail, 2)) + require.ErrorContains(t, err, "status: code = unrecognized message = any message") + require.ErrorIs(t, err, apistatus.ErrUnrecognizedStatusV2) + require.ErrorAs(t, err, new(*apistatus.UnrecognizedStatusV2)) + }) + } + }) + + type testcase struct { + name string + code uint32 + details []*protostatus.Status_Detail + defaultErrMsg string + err, constErr error + extraAssert func(t testing.TB, msg string, err error) + } + tcs := []testcase{ + {name: "internal server error", + // TODO: use const codes after transition to current module's proto lib + code: 1024, details: make([]*protostatus.Status_Detail, 2), + err: new(apistatus.ServerInternal), constErr: apistatus.ErrServerInternal, + extraAssert: func(t testing.TB, msg string, err error) { + var e *apistatus.ServerInternal + require.ErrorAs(t, err, &e) + require.Equal(t, msg, e.Message()) + }, + }, + {name: "invalid response signature", + code: 1026, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "signature verification failed", + err: new(apistatus.SignatureVerification), constErr: apistatus.ErrSignatureVerification, + extraAssert: func(t testing.TB, msg string, err error) { + var e *apistatus.SignatureVerification + require.ErrorAs(t, err, &e) + require.Equal(t, msg, e.Message()) + }, + }, + {name: "node maintenance", + code: 1027, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "node is under maintenance", + err: new(apistatus.NodeUnderMaintenance), constErr: apistatus.ErrNodeUnderMaintenance, + extraAssert: func(t testing.TB, msg string, err error) { + var e *apistatus.NodeUnderMaintenance + require.ErrorAs(t, err, &e) + require.Equal(t, msg, e.Message()) + }, + }, + {name: "missing object", + code: 2049, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "object not found", + err: new(apistatus.ObjectNotFound), constErr: apistatus.ErrObjectNotFound, + }, + {name: "locked object", + code: 2050, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "object is locked", + err: new(apistatus.ObjectLocked), constErr: apistatus.ErrObjectLocked, + }, + {name: "lock irregular object", + code: 2051, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "locking non-regular object is forbidden", + err: new(apistatus.LockNonRegularObject), constErr: apistatus.ErrLockNonRegularObject, + }, + {name: "already removed object", + code: 2052, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "object already removed", + err: new(apistatus.ObjectAlreadyRemoved), constErr: apistatus.ErrObjectAlreadyRemoved, + }, + {name: "out of object range", + code: 2053, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "out of range", + err: new(apistatus.ObjectOutOfRange), constErr: apistatus.ErrObjectOutOfRange, + }, + {name: "missing container", + code: 3072, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "container not found", + err: new(apistatus.ContainerNotFound), constErr: apistatus.ErrContainerNotFound, + }, + {name: "missing eACL", + code: 3073, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "eACL not found", + err: new(apistatus.EACLNotFound), constErr: apistatus.ErrEACLNotFound, + }, + {name: "missing session token", + code: 4096, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "session token not found", + err: new(apistatus.SessionTokenNotFound), constErr: apistatus.ErrSessionTokenNotFound, + }, + {name: "expired session token", + code: 4097, details: make([]*protostatus.Status_Detail, 2), + defaultErrMsg: "expired session token", + err: new(apistatus.SessionTokenExpired), constErr: apistatus.ErrSessionTokenExpired, + }, + } + for _, tc := range []struct { + name string + correctMagicBytes []byte + assert func(testing.TB, *apistatus.WrongMagicNumber) + }{ + { // default + assert: func(tb testing.TB, e *apistatus.WrongMagicNumber) { + _, ok := e.CorrectMagic() + require.Zero(t, ok) + }}, + {name: "undersize", + correctMagicBytes: make([]byte, 7), + assert: func(tb testing.TB, e *apistatus.WrongMagicNumber) { + _, ok := e.CorrectMagic() + require.EqualValues(t, -1, ok) + }}, + {name: "oversize", + correctMagicBytes: make([]byte, 9), + assert: func(tb testing.TB, e *apistatus.WrongMagicNumber) { + _, ok := e.CorrectMagic() + require.EqualValues(t, -1, ok) + }}, + {name: "valid", + correctMagicBytes: []byte{140, 15, 162, 245, 219, 236, 37, 191}, + assert: func(tb testing.TB, e *apistatus.WrongMagicNumber) { + magic, ok := e.CorrectMagic() + require.EqualValues(t, 1, ok) + require.EqualValues(t, uint64(10092464466800944575), magic) + }}, + } { + name := "wrong magic number" + var details []*protostatus.Status_Detail + if tc.correctMagicBytes != nil { + details = []*protostatus.Status_Detail{{Id: 0, Value: tc.correctMagicBytes}} + name += "/with correct magic/" + tc.name + } else { + name += "/default" + } + tcs = append(tcs, testcase{name: name, + code: 1025, details: details, + err: new(apistatus.WrongMagicNumber), constErr: apistatus.ErrWrongMagicNumber, + extraAssert: func(t testing.TB, _ string, err error) { + var e *apistatus.WrongMagicNumber + require.ErrorAs(t, err, &e) + tc.assert(t, e) + }, + }) + } + for _, tc := range []struct { + name string + reason string + assert func(testing.TB, *apistatus.ObjectAccessDenied) + }{ + { // default + assert: func(tb testing.TB, e *apistatus.ObjectAccessDenied) { require.Zero(t, e.Reason()) }}, + {name: "with reason", + reason: "Hello, world!", + assert: func(tb testing.TB, e *apistatus.ObjectAccessDenied) { require.Equal(t, "Hello, world!", e.Reason()) }}, + } { + name := "object access denial" + var details []*protostatus.Status_Detail + if tc.reason != "" { + details = []*protostatus.Status_Detail{{Id: 0, Value: []byte(tc.reason)}} + name += "/with reason/" + tc.name + } else { + name += "/default" + } + tcs = append(tcs, testcase{name: name, + code: 2048, details: details, + err: new(apistatus.ObjectAccessDenied), constErr: apistatus.ErrObjectAccessDenied, + defaultErrMsg: "access to object operation denied", + extraAssert: func(t testing.TB, _ string, err error) { + var e *apistatus.ObjectAccessDenied + require.ErrorAs(t, err, &e) + tc.assert(t, e) + }, + }) + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + checkWithMsg := func(msg string) { + err := execWithStatus(tc.code, msg, tc.details) + require.ErrorAs(t, err, &tc.err) + require.ErrorIs(t, err, tc.constErr) + var expectedErrMsg string + if msg != "" { + expectedErrMsg = fmt.Sprintf("status: code = %d message = %s", tc.code, msg) + } else { + if tc.defaultErrMsg != "" { + expectedErrMsg = fmt.Sprintf("status: code = %d message = %s", tc.code, tc.defaultErrMsg) + } else { + expectedErrMsg = fmt.Sprintf("status: code = %d", tc.code) + } + } + require.ErrorContains(t, err, expectedErrMsg) + if tc.extraAssert != nil { + tc.extraAssert(t, msg, tc.err) + } + } + checkWithMsg("") + checkWithMsg("Hello, world!") + }) + } +} + +// asserts that given [Client] op returns an expected error when some server +// responds with the incorrect message format. The op must be executed with all +// the correct parameters. +func testIncorrectUnaryRPCResponseFormat(t testing.TB, svcName, method string, op testedClientOp) { + svc := testService{ + desc: &grpc.ServiceDesc{ServiceName: "neo.fs.v2." + svcName, Methods: []grpc.MethodDesc{ + { + MethodName: method, + Handler: func(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + return timestamppb.Now(), nil // any completely different message + }, + }, + }}, + impl: nil, // disables interface assert + } + c := newClient(t, svc) + require.ErrorContains(t, op(c), "invalid response signature") + // TODO: Although the client will not accept such a response, current error + // does not make it clear what exactly the problem is. It is worth reacting to + // the incorrect structure if possible. +} + +// asserts that given [Client] op correctly reports meta information received +// from built test server when consuming the specified service. The op must be +// executed with all the correct parameters. +func testResponseCallback[SRV interface { + respondWithMeta(*protosession.ResponseMetaHeader) + signResponsesBy(neofscrypto.Signer) +}]( + t testing.TB, + newSrv func() SRV, + newSvc func(t testing.TB, srv any) testService, + op testedClientOp, +) { + srv := newSrv() + srvSigner := neofscryptotest.Signer() + srvPub := neofscrypto.PublicKeyBytes(srvSigner.Public()) + srv.signResponsesBy(srvSigner) + srvEpoch := rand.Uint64() + srv.respondWithMeta(&protosession.ResponseMetaHeader{Epoch: srvEpoch}) + + var collected []ResponseMetaInfo + var handlerErr error + handler := func(meta ResponseMetaInfo) error { + collected = append(collected, meta) + return handlerErr + } + assert := func(expEpoch uint64, expPub []byte) { + require.Len(t, collected, 1) + require.Equal(t, expEpoch, collected[0].Epoch()) + require.Equal(t, expPub, collected[0].ResponderKey()) + collected = nil + } + + c := newCustomClient(t, func(prm *PrmInit) { prm.SetResponseInfoCallback(handler) }, newSvc(t, srv)) + // [Client.EndpointInfo] is always called to dial the server: this is also submitted + assert(testServerStateOnDial.epoch, testServerStateOnDial.pub) + + err := op(c) + require.NoError(t, err) + assert(srvEpoch, srvPub) + + handlerErr = errors.New("any response meta handler failure") + err = op(c) + require.ErrorContains(t, err, "response callback error") + require.ErrorIs(t, err, handlerErr) + assert(srvEpoch, srvPub) +} + +// checks that the [Client] correctly keeps exec statistics of specified ops +// performing communication with built test server. All operations must comply +// with the tested service. +// +// If non-stat failure cases are specified, they must include request signature +// failure caused by the op signer parameter. +func testStatistic[SRV interface { + setSleepDuration(time.Duration) + setHandlerError(error) +}]( + t testing.TB, + newSrv func() SRV, + newSvc func(t testing.TB, srv any) testService, + expMtd stat.Method, + customNonStatFailures []testedClientOp, + customStatFailures []testedClientOp, + validInputCall testedClientOp, +) { + srv := newSrv() + svc := newSvc(t, srv) + + type collectedItem struct { + pub []byte + endpoint string + mtd stat.Method + dur time.Duration + err error + } + var collected []collectedItem + handler := func(pub []byte, endpoint string, mtd stat.Method, dur time.Duration, err error) { + collected = append(collected, collectedItem{pub: pub, endpoint: endpoint, mtd: mtd, dur: dur, err: err}) + } + assertCommon := func(mtd stat.Method, pub []byte, err error) { + require.Len(t, collected, 1) + require.Equal(t, pub, collected[0].pub) + require.Equal(t, testServerEndpoint, collected[0].endpoint) + require.Equal(t, mtd, collected[0].mtd) + require.Positive(t, collected[0].dur) + require.Equal(t, err, collected[0].err) + } + + c := newCustomClient(t, func(prm *PrmInit) { prm.SetStatisticCallback(handler) }, svc) + // [Client.EndpointInfo] is always called to dial the server: this is also submitted + assertCommon(stat.MethodEndpointInfo, nil, nil) // server key is not yet received + collected = nil + + assert := func(err error) { + assertCommon(expMtd, testServerStateOnDial.pub, err) + } + + // custom non-stat failures + for _, getNonStatErr := range customNonStatFailures { + err := getNonStatErr(c) + require.Error(t, err) + // TODO: strange that stats of such errors are similar to OK + assert(nil) + collected = nil + } + + // custom stat failures + for _, getStatErr := range customStatFailures { + err := getStatErr(c) + require.Error(t, err) + assert(err) + collected = nil + } + + if len(customNonStatFailures) == 0 { + // sign request failure + signerCp := c.prm.signer + c.prm.signer = neofscryptotest.FailSigner(c.prm.signer) + + err := validInputCall(c) + assertSignRequestErr(t, err) + assert(err) + collected = nil + + c.prm.signer = signerCp + } + + // transport + transportErr := errors.New("any transport failure") + srv.setHandlerError(transportErr) + + err := validInputCall(c) + assertTransportErr(t, transportErr, err) + assert(err) + collected = nil + + srv.setHandlerError(nil) + + // OK + const sleepDur = 100 * time.Millisecond + // duration is pretty short overall, but most likely larger than the exec time w/o sleep + srv.setSleepDuration(sleepDur) + + err = validInputCall(c) + require.NoError(t, err) + assert(err) + require.Greater(t, collected[0].dur, sleepDur) +} diff --git a/client/container_statistic_test.go b/client/container_statistic_test.go deleted file mode 100644 index 2412d494..00000000 --- a/client/container_statistic_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package client - -import ( - "context" - "crypto/rand" - "io" - mathRand "math/rand/v2" - "strconv" - "testing" - "time" - - "github.com/google/uuid" - "github.com/nspcc-dev/neofs-sdk-go/container" - "github.com/nspcc-dev/neofs-sdk-go/container/acl" - cid "github.com/nspcc-dev/neofs-sdk-go/container/id" - cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" - "github.com/nspcc-dev/neofs-sdk-go/eacl" - "github.com/nspcc-dev/neofs-sdk-go/netmap" - "github.com/nspcc-dev/neofs-sdk-go/object" - oid "github.com/nspcc-dev/neofs-sdk-go/object/id" - reputation2 "github.com/nspcc-dev/neofs-sdk-go/reputation" - session2 "github.com/nspcc-dev/neofs-sdk-go/session" - "github.com/nspcc-dev/neofs-sdk-go/stat" - "github.com/nspcc-dev/neofs-sdk-go/user" - usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" - "github.com/stretchr/testify/require" -) - -type ( - methodStatistic struct { - requests int - errors int - duration time.Duration - } - - testStatCollector struct { - methods map[stat.Method]*methodStatistic - } -) - -func newCollector() *testStatCollector { - c := testStatCollector{ - methods: make(map[stat.Method]*methodStatistic), - } - - for i := stat.MethodBalanceGet; i < stat.MethodLast; i++ { - c.methods[i] = &methodStatistic{} - } - - return &c -} - -func (c *testStatCollector) Collect(_ []byte, _ string, method stat.Method, duration time.Duration, err error) { - data, ok := c.methods[method] - if ok { - data.duration += duration - if duration > 0 { - data.requests++ - } - - if err != nil { - data.errors++ - } - } -} - -func randBytes(l int) []byte { - r := make([]byte, l) - _, _ = rand.Read(r) - - return r -} - -func prepareContainer(accountID user.ID) container.Container { - cont := container.Container{} - cont.Init() - cont.SetOwner(accountID) - cont.SetBasicACL(acl.PublicRW) - - cont.SetName(strconv.FormatInt(time.Now().UnixNano(), 16)) - cont.SetCreationTime(time.Now().UTC()) - - var pp netmap.PlacementPolicy - var rd netmap.ReplicaDescriptor - rd.SetNumberOfObjects(1) - - pp.SetContainerBackupFactor(1) - pp.SetReplicas([]netmap.ReplicaDescriptor{rd}) - cont.SetPlacementPolicy(pp) - - return cont -} - -func testEaclTable(containerID cid.ID) eacl.Table { - var table eacl.Table - table.SetCID(containerID) - - r := eacl.ConstructRecord(eacl.ActionAllow, eacl.OperationPut, []eacl.Target{eacl.NewTargetByRole(eacl.RoleOthers)}) - table.AddRecord(&r) - - return table -} - -func TestClientStatistic_ContainerPut(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testPutContainerServer - c := newTestContainerClient(t, &srv) - cont := prepareContainer(usr.ID) - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerPut - _, err := c.ContainerPut(ctx, cont, usr.RFC6979, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerPut].requests) -} - -func TestClientStatistic_ContainerGet(t *testing.T) { - ctx := context.Background() - var srv testGetContainerServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerGet - _, err := c.ContainerGet(ctx, cid.ID{}, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerGet].requests) -} - -func TestClientStatistic_ContainerList(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testListContainersServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerList - _, err := c.ContainerList(ctx, usr.ID, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerList].requests) -} - -func TestClientStatistic_ContainerDelete(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testDeleteContainerServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerDelete - err := c.ContainerDelete(ctx, cid.ID{}, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerDelete].requests) -} - -func TestClientStatistic_ContainerEacl(t *testing.T) { - ctx := context.Background() - var srv testGetEACLServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerEACL - _, err := c.ContainerEACL(ctx, cid.ID{}, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerEACL].requests) -} - -func TestClientStatistic_ContainerSetEacl(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testSetEACLServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmContainerSetEACL - table := testEaclTable(cidtest.ID()) - err := c.ContainerSetEACL(ctx, table, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerSetEACL].requests) -} - -func TestClientStatistic_ContainerAnnounceUsedSpace(t *testing.T) { - ctx := context.Background() - var srv testAnnounceContainerSpaceServer - c := newTestContainerClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - estimation := container.SizeEstimation{} - estimation.SetContainer(cidtest.ID()) - estimation.SetValue(mathRand.Uint64()) - estimation.SetEpoch(mathRand.Uint64()) - - var prm PrmAnnounceSpace - err := c.ContainerAnnounceUsedSpace(ctx, []container.SizeEstimation{estimation}, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodContainerAnnounceUsedSpace].requests) -} - -func TestClientStatistic_ContainerSyncContainerWithNetwork(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testGetNetworkInfoServer - c := newTestNetmapClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - cont := prepareContainer(usr.ID) - - err := SyncContainerWithNetwork(ctx, &cont, c) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodNetworkInfo].requests) -} - -func TestClientStatistic_ContainerEndpointInfo(t *testing.T) { - ctx := context.Background() - srv := newTestGetNodeInfoServer() - c := newTestNetmapClient(t, srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - _, err := c.EndpointInfo(ctx, PrmEndpointInfo{}) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodEndpointInfo].requests) -} - -func TestClientStatistic_ContainerNetMapSnapshot(t *testing.T) { - ctx := context.Background() - var srv testNetmapSnapshotServer - c := newTestNetmapClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - _, err := c.NetMapSnapshot(ctx, PrmNetMapSnapshot{}) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodNetMapSnapshot].requests) -} - -func TestClientStatistic_CreateSession(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testCreateSessionServer - c := newTestSessionClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmSessionCreate - - _, err := c.SessionCreate(ctx, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodSessionCreate].requests) -} - -func TestClientStatistic_ObjectPut(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testPutObjectServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var tokenSession session2.Object - tokenSession.SetID(uuid.New()) - tokenSession.SetExp(1) - tokenSession.BindContainer(containerID) - tokenSession.ForVerb(session2.VerbObjectPut) - tokenSession.SetAuthKey(usr.Public()) - tokenSession.SetIssuer(usr.ID) - - err := tokenSession.Sign(usr) - require.NoError(t, err) - - var prm PrmObjectPutInit - prm.WithinSession(tokenSession) - - var hdr object.Object - hdr.SetOwner(usr.ID) - hdr.SetContainerID(containerID) - - writer, err := c.ObjectPutInit(ctx, hdr, usr, prm) - require.NoError(t, err) - - _, err = writer.Write(randBytes(10)) - require.NoError(t, err) - - err = writer.Close() - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectPut].requests) - require.Equal(t, 1, collector.methods[stat.MethodObjectPutStream].requests) -} - -func TestClientStatistic_ObjectDelete(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testDeleteObjectServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - objectID := oid.ID{} - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectDelete - - _, err := c.ObjectDelete(ctx, containerID, objectID, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectDelete].requests) -} - -func TestClientStatistic_ObjectGet(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testGetObjectServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - objectID := oid.ID{} - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectGet - - _, reader, err := c.ObjectGetInit(ctx, containerID, objectID, usr, prm) - require.NoError(t, err) - _, err = io.Copy(io.Discard, reader) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectGet].requests) -} - -func TestClientStatistic_ObjectHead(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testHeadObjectServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - objectID := oid.ID{} - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectHead - - _, err := c.ObjectHead(ctx, containerID, objectID, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectHead].requests) -} - -func TestClientStatistic_ObjectRange(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testGetObjectPayloadRangeServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - objectID := oid.ID{} - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectRange - - reader, err := c.ObjectRangeInit(ctx, containerID, objectID, 0, 1, usr, prm) - require.NoError(t, err) - _, err = io.Copy(io.Discard, reader) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectRange].requests) -} - -func TestClientStatistic_ObjectHash(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testHashObjectPayloadRangesServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - objectID := oid.ID{} - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectHash - prm.SetRangeList(0, 2) - - _, err := c.ObjectHash(ctx, containerID, objectID, usr, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectHash].requests) -} - -func TestClientStatistic_ObjectSearch(t *testing.T) { - usr := usertest.User() - ctx := context.Background() - var srv testSearchObjectsServer - c := newTestObjectClient(t, &srv) - containerID := cidtest.ID() - - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var prm PrmObjectSearch - - reader, err := c.ObjectSearchInit(ctx, containerID, usr, prm) - require.NoError(t, err) - - iterator := func(oid.ID) bool { - return false - } - - err = reader.Iterate(iterator) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodObjectSearch].requests) -} - -func TestClientStatistic_AnnounceIntermediateTrust(t *testing.T) { - ctx := context.Background() - var srv testAnnounceIntermediateReputationServer - c := newTestReputationClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var trust reputation2.PeerToPeerTrust - var prm PrmAnnounceIntermediateTrust - - err := c.AnnounceIntermediateTrust(ctx, 1, trust, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodAnnounceIntermediateTrust].requests) -} - -func TestClientStatistic_MethodAnnounceLocalTrust(t *testing.T) { - ctx := context.Background() - var srv testAnnounceLocalTrustServer - c := newTestReputationClient(t, &srv) - collector := newCollector() - c.prm.statisticCallback = collector.Collect - - var peer reputation2.PeerID - var trust reputation2.Trust - trust.SetPeer(peer) - - var prm PrmAnnounceLocalTrust - - err := c.AnnounceLocalTrust(ctx, 1, []reputation2.Trust{trust}, prm) - require.NoError(t, err) - - require.Equal(t, 1, collector.methods[stat.MethodAnnounceLocalTrust].requests) -} diff --git a/client/container_test.go b/client/container_test.go index 0392ea24..cb7abb96 100644 --- a/client/container_test.go +++ b/client/container_test.go @@ -2,203 +2,1824 @@ package client import ( "context" + "errors" "fmt" "testing" + v2acl "github.com/nspcc-dev/neofs-api-go/v2/acl" protoacl "github.com/nspcc-dev/neofs-api-go/v2/acl/grpc" apicontainer "github.com/nspcc-dev/neofs-api-go/v2/container" protocontainer "github.com/nspcc-dev/neofs-api-go/v2/container/grpc" + protonetmap "github.com/nspcc-dev/neofs-api-go/v2/netmap/grpc" + "github.com/nspcc-dev/neofs-api-go/v2/refs" protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + apigrpc "github.com/nspcc-dev/neofs-api-go/v2/rpc/grpc" + protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" "github.com/nspcc-dev/neofs-sdk-go/container" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" containertest "github.com/nspcc-dev/neofs-sdk-go/container/test" + neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" + neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" "github.com/nspcc-dev/neofs-sdk-go/eacl" + "github.com/nspcc-dev/neofs-sdk-go/session" + sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + "github.com/nspcc-dev/neofs-sdk-go/user" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -// returns Client of Container service provided by given server. -func newTestContainerClient(t testing.TB, srv protocontainer.ContainerServiceServer) *Client { - return newClient(t, testService{desc: &protocontainer.ContainerService_ServiceDesc, impl: srv}) +// returns Client-compatible Container service handled by given server. Provided +// server must implement [protocontainer.ContainerServiceServer]: the parameter +// is not of this type to support generics. +func newDefaultContainerService(t testing.TB, srv any) testService { + require.Implements(t, (*protocontainer.ContainerServiceServer)(nil), srv) + return testService{desc: &protocontainer.ContainerService_ServiceDesc, impl: srv} +} + +// returns Client of Container service provided by given server. Provided server +// must implement [protocontainer.ContainerServiceServer]: the parameter is +// not of this type to support generics. +func newTestContainerClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultContainerService(t, srv)) +} + +// for sharing between servers of requests with RFC 6979 signature of particular +// data. +type testRFC6979DataSignatureServerSettings[ + SIGNED apigrpc.Message, + SIGNEDV2 any, + SIGNEDV2PTR interface { + *SIGNEDV2 + signedMessageV2 + }, +] struct { + reqCreds *authCredentials + reqDataSignature *neofscrypto.Signature +} + +// makes the server to assert that any request's payload is signed by s. By +// default, any signer is accepted. +// +// Has no effect with checkRequestDataSignature. +func (x *testRFC6979DataSignatureServerSettings[_, _, _]) authenticateRequestPayload(s neofscrypto.Signer) { + c := authCredentialsFromSigner(s) + x.reqCreds = &c +} + +// makes the server to assert that any request carries given signature without +// verification. By default, any signature matching the data is accepted. +// +// Overrides checkRequestDataSignerKey. +func (x *testRFC6979DataSignatureServerSettings[_, _, _]) checkRequestDataSignature(s neofscrypto.Signature) { + x.reqDataSignature = &s +} + +func (x testRFC6979DataSignatureServerSettings[SIGNED, SIGNEDV2, SIGNEDV2PTR]) verifyCommon(signedField string, m *protorefs.SignatureRFC6979) error { + field := signedField + " signature" + if m == nil { + return newErrMissingRequestBodyField(field) + } + if x.reqDataSignature != nil { + if err := checkSignatureRFC6979Transport(*x.reqDataSignature, m); err != nil { + return newErrInvalidRequestField(field, err) + } + return nil + } + return nil +} + +func (x testRFC6979DataSignatureServerSettings[_, _, _]) verifyDataSignature(signedField string, data []byte, m *protorefs.SignatureRFC6979) error { + field := signedField + " signature" + if m == nil { + return newErrMissingRequestBodyField(field) + } + if x.reqDataSignature != nil { + if err := checkSignatureRFC6979Transport(*x.reqDataSignature, m); err != nil { + return newErrInvalidRequestField(field, err) + } + return nil + } + if err := verifyDataSignature(data, &protorefs.Signature{ + Key: m.Key, + Sign: m.Sign, + Scheme: protorefs.SignatureScheme_ECDSA_RFC6979_SHA256, + }, x.reqCreds); err != nil { + return newErrInvalidRequestField(field, err) + } + return nil +} + +func (x testRFC6979DataSignatureServerSettings[SIGNED, SIGNEDV2, SIGNEDV2PTR]) verifyMessageSignature(signedField string, signed SIGNED, m *protorefs.SignatureRFC6979) error { + mV2 := SIGNEDV2PTR(new(SIGNEDV2)) + if err := mV2.FromGRPCMessage(signed); err != nil { + panic(err) + } + return x.verifyDataSignature(signedField, mV2.StableMarshal(nil), m) +} + +// for sharing between servers of requests with a container session token. +type testContainerSessionServerSettings struct { + expectedToken *session.Container +} + +// makes the server to assert that any request carries given session token. By +// default, session token must not be attached. +func (x *testContainerSessionServerSettings) checkRequestSessionToken(st session.Container) { + x.expectedToken = &st +} + +func (x testContainerSessionServerSettings) verifySessionToken(m *protosession.SessionToken) error { + if m == nil { + if x.expectedToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("session token is missing while should not be")) + } + return nil + } + if x.expectedToken == nil { + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + } + if err := checkContainerSessionTransport(*x.expectedToken, m); err != nil { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("session token: %w", err)) + } + return nil } type testPutContainerServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.PutRequest_Body, + apicontainer.PutRequestBody, + *apicontainer.PutRequestBody, + *protocontainer.PutRequest, + apicontainer.PutRequest, + *apicontainer.PutRequest, + *protocontainer.PutResponse_Body, + protocontainer.PutResponse, + apicontainer.PutResponse, + *apicontainer.PutResponse, + ] + testContainerSessionServerSettings + testRFC6979DataSignatureServerSettings[*protocontainer.Container, apicontainer.Container, *apicontainer.Container] + reqContainer *container.Container } -func (x *testPutContainerServer) Put(context.Context, *protocontainer.PutRequest) (*protocontainer.PutResponse, error) { - id := cidtest.ID() - resp := protocontainer.PutResponse{ - Body: &protocontainer.PutResponse_Body{ - ContainerId: &protorefs.ContainerID{Value: id[:]}, - }, +// returns [protocontainer.ContainerServiceServer] supporting Put method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestPutContainerServer() *testPutContainerServer { return new(testPutContainerServer) } + +// makes the server to assert that any request carries given container. By +// default, any valid container is accepted. +func (x *testPutContainerServer) checkRequestContainer(cnr container.Container) { + x.reqContainer = &cnr +} + +func (x *testPutContainerServer) verifyRequest(req *protocontainer.PutRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + if err := x.verifySessionToken(req.MetaHeader.SessionToken); err != nil { + return err + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) } + // container + if body.Container == nil { + return newErrMissingRequestBodyField("container") + } + if x.reqContainer != nil { + if err := checkContainerTransport(*x.reqContainer, body.Container); err != nil { + return newErrInvalidRequestField("container", err) + } + } + // signature + return x.verifyMessageSignature("container", body.Container, body.Signature) +} - var respV2 apicontainer.PutResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testPutContainerServer) Put(_ context.Context, req *protocontainer.PutRequest) (*protocontainer.PutResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.PutResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinPutContainerResponseBody).(*protocontainer.PutResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.PutResponse), nil + return x.signResponse(&resp) } type testGetContainerServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.GetRequest_Body, + apicontainer.GetRequestBody, + *apicontainer.GetRequestBody, + *protocontainer.GetRequest, + apicontainer.GetRequest, + *apicontainer.GetRequest, + *protocontainer.GetResponse_Body, + protocontainer.GetResponse, + apicontainer.GetResponse, + *apicontainer.GetResponse, + ] + testRequiredContainerIDServerSettings } -func (x *testGetContainerServer) Get(context.Context, *protocontainer.GetRequest) (*protocontainer.GetResponse, error) { - cnr := containertest.Container() - var cnrV2 apicontainer.Container - cnr.WriteToV2(&cnrV2) - resp := protocontainer.GetResponse{ - Body: &protocontainer.GetResponse_Body{ - Container: cnrV2.ToGRPCMessage().(*protocontainer.Container), - }, +// returns [protocontainer.ContainerServiceServer] supporting Get method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestGetContainerServer() *testGetContainerServer { return new(testGetContainerServer) } + +func (x *testGetContainerServer) verifyRequest(req *protocontainer.GetRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // session token + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // bearer token + if req.MetaHeader.BearerToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + return x.verifyRequestContainerID(body.ContainerId) +} - var respV2 apicontainer.GetResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testGetContainerServer) Get(_ context.Context, req *protocontainer.GetRequest) (*protocontainer.GetResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.GetResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinGetContainerResponseBody).(*protocontainer.GetResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.GetResponse), nil + return x.signResponse(&resp) } type testListContainersServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.ListRequest_Body, + apicontainer.ListRequestBody, + *apicontainer.ListRequestBody, + *protocontainer.ListRequest, + apicontainer.ListRequest, + *apicontainer.ListRequest, + *protocontainer.ListResponse_Body, + protocontainer.ListResponse, + apicontainer.ListResponse, + *apicontainer.ListResponse, + ] + reqOwner *user.ID } -func (x *testListContainersServer) List(context.Context, *protocontainer.ListRequest) (*protocontainer.ListResponse, error) { - var resp protocontainer.ListResponse +// returns [protocontainer.ContainerServiceServer] supporting List method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestListContainersServer() *testListContainersServer { return new(testListContainersServer) } - var respV2 apicontainer.ListResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +// makes the server to assert that any request carries given owner. By default, +// any user is accepted. +func (x *testListContainersServer) checkOwner(owner user.ID) { x.reqOwner = &owner } + +func (x *testListContainersServer) verifyRequest(req *protocontainer.ListRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // owner + if body.OwnerId == nil { + return newErrMissingRequestBodyField("owner") + } + if x.reqOwner != nil { + if err := checkUserIDTransport(*x.reqOwner, body.OwnerId); err != nil { + return newErrInvalidRequestField("owner", err) + } + } + return nil +} + +func (x *testListContainersServer) List(_ context.Context, req *protocontainer.ListRequest) (*protocontainer.ListResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + + resp := protocontainer.ListResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinListContainersResponseBody).(*protocontainer.ListResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.ListResponse), nil + return x.signResponse(&resp) } type testDeleteContainerServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.DeleteRequest_Body, + apicontainer.DeleteRequestBody, + *apicontainer.DeleteRequestBody, + *protocontainer.DeleteRequest, + apicontainer.DeleteRequest, + *apicontainer.DeleteRequest, + *protocontainer.DeleteResponse_Body, + protocontainer.DeleteResponse, + apicontainer.DeleteResponse, + *apicontainer.DeleteResponse, + ] + testContainerSessionServerSettings + testRequiredContainerIDServerSettings + testRFC6979DataSignatureServerSettings[*protorefs.ContainerID, refs.ContainerID, *refs.ContainerID] } -func (x *testDeleteContainerServer) Delete(context.Context, *protocontainer.DeleteRequest) (*protocontainer.DeleteResponse, error) { - var resp protocontainer.DeleteResponse +// returns [protocontainer.ContainerServiceServer] supporting Delete method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestDeleteContainerServer() *testDeleteContainerServer { return new(testDeleteContainerServer) } - var respV2 apicontainer.DeleteResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testDeleteContainerServer) verifyRequest(req *protocontainer.DeleteRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // session token + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + if err := x.verifySessionToken(req.MetaHeader.SessionToken); err != nil { + return err + } + // bearer token + if req.MetaHeader.BearerToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // ID + mc := body.GetContainerId() + if err := x.verifyRequestContainerID(mc); err != nil { + return err + } + // signature + return x.verifyDataSignature("container ID", mc.GetValue(), body.Signature) +} + +func (x *testDeleteContainerServer) Delete(_ context.Context, req *protocontainer.DeleteRequest) (*protocontainer.DeleteResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.DeleteResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinDeleteContainerResponseBody).(*protocontainer.DeleteResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.DeleteResponse), nil + return x.signResponse(&resp) } type testGetEACLServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.GetExtendedACLRequest_Body, + apicontainer.GetExtendedACLRequestBody, + *apicontainer.GetExtendedACLRequestBody, + *protocontainer.GetExtendedACLRequest, + apicontainer.GetExtendedACLRequest, + *apicontainer.GetExtendedACLRequest, + *protocontainer.GetExtendedACLResponse_Body, + protocontainer.GetExtendedACLResponse, + apicontainer.GetExtendedACLResponse, + *apicontainer.GetExtendedACLResponse, + ] + testRequiredContainerIDServerSettings } -func (x *testGetEACLServer) GetExtendedACL(context.Context, *protocontainer.GetExtendedACLRequest) (*protocontainer.GetExtendedACLResponse, error) { - resp := protocontainer.GetExtendedACLResponse{ - Body: &protocontainer.GetExtendedACLResponse_Body{ - Eacl: new(protoacl.EACLTable), - }, +// returns [protocontainer.ContainerServiceServer] supporting GetExtendedACL +// method only. Default implementation performs common verification of any +// request, and responds with any valid message. Some methods allow to tune the +// behavior. +func newTestGetEACLServer() *testGetEACLServer { return new(testGetEACLServer) } + +func (x *testGetEACLServer) verifyRequest(req *protocontainer.GetExtendedACLRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // ID + return x.verifyRequestContainerID(body.ContainerId) +} - var respV2 apicontainer.GetExtendedACLResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testGetEACLServer) GetExtendedACL(_ context.Context, req *protocontainer.GetExtendedACLRequest) (*protocontainer.GetExtendedACLResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.GetExtendedACLResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinEACLResponseBody).(*protocontainer.GetExtendedACLResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.GetExtendedACLResponse), nil + return x.signResponse(&resp) } type testSetEACLServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.SetExtendedACLRequest_Body, + apicontainer.SetExtendedACLRequestBody, + *apicontainer.SetExtendedACLRequestBody, + *protocontainer.SetExtendedACLRequest, + apicontainer.SetExtendedACLRequest, + *apicontainer.SetExtendedACLRequest, + *protocontainer.SetExtendedACLResponse_Body, + protocontainer.SetExtendedACLResponse, + apicontainer.SetExtendedACLResponse, + *apicontainer.SetExtendedACLResponse, + ] + testContainerSessionServerSettings + testRFC6979DataSignatureServerSettings[*protoacl.EACLTable, v2acl.Table, *v2acl.Table] + reqEACL *eacl.Table } -func (x *testSetEACLServer) SetExtendedACL(context.Context, *protocontainer.SetExtendedACLRequest) (*protocontainer.SetExtendedACLResponse, error) { - var resp protocontainer.SetExtendedACLResponse +// returns [protocontainer.ContainerServiceServer] supporting SetExtendedACL +// method only. Default implementation performs common verification of any +// request, and responds with any valid message. Some methods allow to tune the +// behavior. +func newTestSetEACLServer() *testSetEACLServer { return new(testSetEACLServer) } - var respV2 apicontainer.SetExtendedACLResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +// makes the server to assert that any request carries given eACL. By +// default, any eACL is accepted. +func (x *testSetEACLServer) checkRequestEACL(eACL eacl.Table) { x.reqEACL = &eACL } + +func (x *testSetEACLServer) verifyRequest(req *protocontainer.SetExtendedACLRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // session token + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + if err := x.verifySessionToken(req.MetaHeader.SessionToken); err != nil { + return err + } + // bearer token + if req.MetaHeader.BearerToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + // eACL + if body.Eacl == nil { + return newErrMissingRequestBodyField("eACL") + } + if x.reqEACL != nil { + if err := checkEACLTransport(*x.reqEACL, body.Eacl); err != nil { + return newErrInvalidRequestField("eACL", err) + } + } + // signature + return x.verifyMessageSignature("eACL", body.Eacl, body.Signature) +} + +func (x *testSetEACLServer) SetExtendedACL(_ context.Context, req *protocontainer.SetExtendedACLRequest) (*protocontainer.SetExtendedACLResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.SetExtendedACLResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinSetEACLResponseBody).(*protocontainer.SetExtendedACLResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.SetExtendedACLResponse), nil + return x.signResponse(&resp) } type testAnnounceContainerSpaceServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.AnnounceUsedSpaceRequest_Body, + apicontainer.AnnounceUsedSpaceRequestBody, + *apicontainer.AnnounceUsedSpaceRequestBody, + *protocontainer.AnnounceUsedSpaceRequest, + apicontainer.AnnounceUsedSpaceRequest, + *apicontainer.AnnounceUsedSpaceRequest, + *protocontainer.AnnounceUsedSpaceResponse_Body, + protocontainer.AnnounceUsedSpaceResponse, + apicontainer.AnnounceUsedSpaceResponse, + *apicontainer.AnnounceUsedSpaceResponse, + ] + reqAnnouncements []container.SizeEstimation } -func (x *testAnnounceContainerSpaceServer) AnnounceUsedSpace(context.Context, *protocontainer.AnnounceUsedSpaceRequest) (*protocontainer.AnnounceUsedSpaceResponse, error) { - var resp protocontainer.AnnounceUsedSpaceResponse +// returns [protocontainer.ContainerServiceServer] supporting AnnounceUsedSpace +// method only. Default implementation performs common verification of any +// request, and responds with any valid message. Some methods allow to tune the +// behavior. +func newTestAnnounceContainerSpaceServer() *testAnnounceContainerSpaceServer { + return new(testAnnounceContainerSpaceServer) +} - var respV2 apicontainer.AnnounceUsedSpaceResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +// makes the server to assert that any request carries given announcements. By +// default, any valid values are accepted. +func (x *testAnnounceContainerSpaceServer) checkRequestAnnouncements(els []container.SizeEstimation) { + x.reqAnnouncements = els +} + +func (x *testAnnounceContainerSpaceServer) verifyRequest(req *protocontainer.AnnounceUsedSpaceRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // mead header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // announcements + if len(body.Announcements) == 0 { + return newErrMissingRequestBodyField("announcements") + } + if x.reqAnnouncements != nil { + if v1, v2 := len(x.reqAnnouncements), len(body.Announcements); v1 != v2 { + return fmt.Errorf("number of records (client: %d, message: %d)", v1, v2) + } + for i := range x.reqAnnouncements { + if err := checkContainerSizeEstimationTransport(x.reqAnnouncements[i], body.Announcements[i]); err != nil { + return newErrInvalidRequestField("announcements", fmt.Errorf("elements#%d: %w", i, err)) + } + } + } + return nil +} + +func (x *testAnnounceContainerSpaceServer) AnnounceUsedSpace(_ context.Context, req *protocontainer.AnnounceUsedSpaceRequest) (*protocontainer.AnnounceUsedSpaceResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.AnnounceUsedSpaceResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinUsedSpaceResponseBody).(*protocontainer.AnnounceUsedSpaceResponse_Body) } - return respV2.ToGRPCMessage().(*protocontainer.AnnounceUsedSpaceResponse), nil + return x.signResponse(&resp) } -func TestClient_Container(t *testing.T) { - c := newClient(t) +func TestClient_ContainerPut(t *testing.T) { ctx := context.Background() + var anyValidOpts PrmContainerPut + anyValidContainer := containertest.Container() + anyValidSigner := neofscryptotest.Signer().RFC6979 + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestPutContainerServer() + c := newTestContainerClient(t, srv) - t.Run("missing signer", func(t *testing.T) { - tt := []struct { - name string - methodCall func() error - }{ - { - "put", - func() error { - _, err := c.ContainerPut(ctx, container.Container{}, nil, PrmContainerPut{}) - return err - }, + srv.checkRequestContainer(anyValidContainer) + srv.authenticateRequestPayload(anyValidSigner) + srv.authenticateRequest(c.prm.signer) // TODO: why not parameterized signer? + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, PrmContainerPut{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testStatusResponses(t, newTestPutContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("precalculated container signature", func(t *testing.T) { + srv := newTestPutContainerServer() + c := newTestContainerClient(t, srv) + + var sig neofscrypto.Signature + sig.SetPublicKeyBytes([]byte("any public key")) + sig.SetValue([]byte("any value")) + opts := anyValidOpts + opts.AttachSignature(sig) + + srv.checkRequestDataSignature(sig) + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestPutContainerServer() + c := newTestContainerClient(t, srv) + + st := sessiontest.ContainerSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.PutResponse_Body + }{ + {name: "min", body: validMinPutContainerResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestPutContainerServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + id, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkContainerIDTransport(id, tc.body.GetContainerId())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestPutContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "Put", func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestPutContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protocontainer.PutResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing container ID field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protocontainer.PutResponse_Body), + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing container ID field in the response") + }}, + } + // 1. container ID + for _, tc := range invalidContainerIDProtoTestcases { + id := proto.Clone(validProtoContainerIDs[0]).(*protorefs.ContainerID) + tc.corrupt(id) + body := &protocontainer.PutResponse_Body{ContainerId: id} + tcs = append(tcs, testcase{name: "container ID/" + tc.name, body: body, assertErr: func(tb testing.TB, err error) { + require.EqualError(t, err, "invalid container ID field in the response: "+tc.msg) + }}) + } + + testInvalidResponseBodies(t, newTestPutContainerServer, newTestContainerClient, tcs, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("missing signer", func(t *testing.T) { + _, err := c.ContainerPut(ctx, anyValidContainer, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + }) + t.Run("sign container failure", func(t *testing.T) { + c := newClient(t) + t.Run("wrong scheme", func(t *testing.T) { + _, err := c.ContainerPut(ctx, anyValidContainer, neofsecdsa.Signer(neofscryptotest.ECDSAPrivateKey()), anyValidOpts) + require.EqualError(t, err, "calculate container signature: incorrect signer: expected ECDSA_DETERMINISTIC_SHA256 scheme") + }) + t.Run("signer failure", func(t *testing.T) { + _, err := c.ContainerPut(ctx, anyValidContainer, neofscryptotest.FailSigner(neofscryptotest.Signer()), anyValidOpts) + require.ErrorContains(t, err, "calculate container signature") + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestPutContainerServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestPutContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestPutContainerServer, newDefaultContainerService, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestPutContainerServer, newDefaultContainerService, stat.MethodContainerPut, + []testedClientOp{func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, nil, anyValidOpts) + return err + }}, + []testedClientOp{func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, neofscryptotest.FailSigner(anyValidSigner), anyValidOpts) + return err + }}, func(c *Client) error { + _, err := c.ContainerPut(ctx, anyValidContainer, anyValidSigner, anyValidOpts) + return err }, - { - "delete", - func() error { - return c.ContainerDelete(ctx, cid.ID{}, nil, PrmContainerDelete{}) - }, + ) + }) +} + +func TestClient_ContainerGet(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmContainerGet + anyID := cidtest.ID() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestGetContainerServer() + c := newTestContainerClient(t, srv) + + srv.checkRequestContainerID(anyID) + srv.authenticateRequest(c.prm.signer) + _, err := c.ContainerGet(ctx, anyID, PrmContainerGet{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestGetContainerServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ContainerGet(ctx, anyID, opts) + return err + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + // TODO: add tests asserting result when some optional field is unset (for other methods too) + for _, tc := range []struct { + name string + body *protocontainer.GetResponse_Body + }{ + {name: "min", body: validMinGetContainerResponseBody}, + {name: "full", body: validFullGetContainerResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestGetContainerServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + cnr, err := c.ContainerGet(ctx, anyID, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkContainerTransport(cnr, tc.body.GetContainer())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestGetContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "Get", func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestGetContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protocontainer.GetResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing container in response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protocontainer.GetResponse_Body), + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing container in response") + }}, + } + // 1. container + type invalidContainerTestcase = struct { + name, msg string + corrupt func(valid *protocontainer.Container) + } + // 1.1 version + ctcs := []invalidContainerTestcase{{name: "version/missing", msg: "missing version", corrupt: func(valid *protocontainer.Container) { + valid.Version = nil + }}} + // 1.2 owner + ctcs = append(ctcs, invalidContainerTestcase{name: "owner/missing", msg: "missing owner", corrupt: func(valid *protocontainer.Container) { + valid.OwnerId = nil + }}) + for _, tc := range invalidUserIDProtoTestcases { + ctcs = append(ctcs, invalidContainerTestcase{ + name: "owner/" + tc.name, msg: "invalid owner: " + tc.msg, + corrupt: func(valid *protocontainer.Container) { tc.corrupt(valid.OwnerId) }, + }) + } + // 1.3 nonce + ctcs = append(ctcs, invalidContainerTestcase{name: "nonce/missing", msg: "missing nonce", corrupt: func(valid *protocontainer.Container) { + valid.Nonce = nil + }}) + for _, tc := range invalidUUIDProtoTestcases { + ctcs = append(ctcs, invalidContainerTestcase{ + name: "nonce/" + tc.name, msg: "invalid nonce: " + tc.msg, + corrupt: func(valid *protocontainer.Container) { valid.Nonce = tc.corrupt(valid.Nonce) }, + }) + } + // 1.4 basic ACL + // 1.5 attributes + for _, tc := range []struct { + name, msg string + attrs []string + }{ + {name: "attributes/empty key", msg: "empty attribute key", + attrs: []string{"k1", "v1", "", "v2", "k3", "v3"}}, + {name: "attributes/empty value", msg: "empty attribute value k2", // TODO: message is strange + attrs: []string{"k1", "v1", "k2", "", "k3", "v3"}}, + {name: "attributes/duplicated", msg: "duplicated attribute k1", + attrs: []string{"k1", "v1", "k2", "v2", "k1", "v3"}}, + {name: "attributes/timestamp/invalid", msg: `invalid attribute value Timestamp: foo (strconv.ParseInt: parsing "foo": invalid syntax)`, + attrs: []string{"k1", "v1", "Timestamp", "foo", "k1", "v3"}}, + } { + require.Zero(t, len(tc.attrs)%2) + as := make([]*protocontainer.Container_Attribute, 0, len(tc.attrs)/2) + for i := range len(tc.attrs) / 2 { + as = append(as, &protocontainer.Container_Attribute{Key: tc.attrs[2*i], Value: tc.attrs[2*i+1]}) + } + ctcs = append(ctcs, invalidContainerTestcase{ + name: "attributes/" + tc.name, msg: tc.msg, + corrupt: func(valid *protocontainer.Container) { valid.Attributes = as }, + }) + } + // 1.6 policy + ctcs = append(ctcs, invalidContainerTestcase{name: "policy/missing", msg: "missing placement policy", corrupt: func(valid *protocontainer.Container) { + valid.PlacementPolicy = nil + }}) + for _, tc := range []struct { + name, msg string + corrupt func(valid *protonetmap.PlacementPolicy) + }{ + {name: "missing replicas", msg: "missing replicas", corrupt: func(valid *protonetmap.PlacementPolicy) { + valid.Replicas = nil + }}, + {name: "selectors/clause/negative", msg: "invalid selector #1: negative clause", corrupt: func(valid *protonetmap.PlacementPolicy) { + valid.Selectors[1].Clause = -1 + }}, + {name: "filters/op/negative", msg: "invalid filter #1: negative op", corrupt: func(valid *protonetmap.PlacementPolicy) { + valid.Filters[1].Op = -1 + }}, + } { + ctcs = append(ctcs, invalidContainerTestcase{ + name: "policy" + tc.name, msg: "invalid placement policy: " + tc.msg, + corrupt: func(valid *protocontainer.Container) { tc.corrupt(valid.PlacementPolicy) }, + }) + } + + for _, tc := range ctcs { + body := proto.Clone(validFullGetContainerResponseBody).(*protocontainer.GetResponse_Body) + tc.corrupt(body.Container) + tcs = append(tcs, testcase{ + name: "container/" + tc.name, body: body, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid container in response: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newTestGetContainerServer, newTestContainerClient, tcs, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestGetContainerServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestGetContainerServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestGetContainerServer, newDefaultContainerService, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestGetContainerServer, newDefaultContainerService, stat.MethodContainerGet, + nil, nil, func(c *Client) error { + _, err := c.ContainerGet(ctx, anyID, anyValidOpts) + return err }, - { - "set_eacl", - func() error { - return c.ContainerSetEACL(ctx, eacl.Table{}, nil, PrmContainerSetEACL{}) - }, + ) + }) +} + +func TestClient_ContainerList(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmContainerList + anyUser := usertest.ID() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestListContainersServer() + c := newTestContainerClient(t, srv) + + srv.checkOwner(anyUser) + srv.authenticateRequest(c.prm.signer) + _, err := c.ContainerList(ctx, anyUser, PrmContainerList{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestListContainersServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ContainerList(ctx, anyUser, opts) + return err + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.ListResponse_Body + }{ + {name: "nil", body: nil}, + {name: "min", body: validMinListContainersResponseBody}, + {name: "full", body: validFullListContainersResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestListContainersServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + res, err := c.ContainerList(ctx, anyUser, anyValidOpts) + require.NoError(t, err) + mids := tc.body.GetContainerIds() + require.Len(t, res, len(mids)) + for i := range mids { + require.NoError(t, checkContainerIDTransport(res[i], mids[i]), i) + } + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestListContainersServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "List", func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestListContainersServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protocontainer.ListResponse_Body] + var tcs []testcase + // 1. container IDs + type invalidIDsTestcase = struct { + name, msg string + corrupt func(valid []*protorefs.ContainerID) // 3 elements + } + tcsIDs := []invalidIDsTestcase{ + { + name: "nil element", + msg: "invalid length 0", + corrupt: func(valid []*protorefs.ContainerID) { valid[1] = nil }, + }, + } + for _, tc := range invalidContainerIDProtoTestcases { + tcsIDs = append(tcsIDs, invalidIDsTestcase{ + name: "invalid element/" + tc.name, + msg: tc.msg, + corrupt: func(valid []*protorefs.ContainerID) { tc.corrupt(valid[1]) }, + }) + } + for _, tc := range tcsIDs { + ids := make([]*protorefs.ContainerID, len(validProtoContainerIDs)) + for i, id := range validProtoContainerIDs { + ids[i] = proto.Clone(id).(*protorefs.ContainerID) + } + tc.corrupt(ids) + body := &protocontainer.ListResponse_Body{ContainerIds: ids} + tcs = append(tcs, testcase{name: "container IDs/" + tc.name, body: body, assertErr: func(tb testing.TB, err error) { + require.EqualError(t, err, "invalid ID in the response: "+tc.msg) + }}) + } + + testInvalidResponseBodies(t, newTestListContainersServer, newTestContainerClient, tcs, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestListContainersServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestListContainersServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestListContainersServer, newDefaultContainerService, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestListContainersServer, newDefaultContainerService, stat.MethodContainerList, + nil, nil, func(c *Client) error { + _, err := c.ContainerList(ctx, anyUser, anyValidOpts) + return err }, - } + ) + }) +} + +func TestClient_ContainerDelete(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmContainerDelete + anyValidSigner := neofscryptotest.Signer().RFC6979 + anyID := cidtest.ID() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestDeleteContainerServer() + c := newTestContainerClient(t, srv) - for _, test := range tt { - t.Run(test.name, func(t *testing.T) { - require.ErrorIs(t, test.methodCall(), ErrMissingSigner) + srv.checkRequestContainerID(anyID) + srv.authenticateRequestPayload(anyValidSigner) + srv.authenticateRequest(c.prm.signer) + err := c.ContainerDelete(ctx, anyID, anyValidSigner, PrmContainerDelete{}) + require.NoError(t, err) }) - } + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestDeleteContainerServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return c.ContainerDelete(ctx, anyID, anyValidSigner, opts) + }) + }) + t.Run("precalculated container signature", func(t *testing.T) { + srv := newTestDeleteContainerServer() + c := newTestContainerClient(t, srv) + + var sig neofscrypto.Signature + sig.SetPublicKeyBytes([]byte("any public key")) + sig.SetValue([]byte("any value")) + opts := anyValidOpts + opts.AttachSignature(sig) + + srv.checkRequestDataSignature(sig) + err := c.ContainerDelete(ctx, anyID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestDeleteContainerServer() + c := newTestContainerClient(t, srv) + + st := sessiontest.ContainerSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + err := c.ContainerDelete(ctx, anyID, anyValidSigner, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.DeleteResponse_Body + }{ + {name: "min", body: validMinDeleteContainerResponseBody}, + {name: "full", body: validFullDeleteContainerResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestDeleteContainerServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + err := c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + require.NoError(t, err) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestDeleteContainerServer, newTestContainerClient, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "Delete", func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestDeleteContainerServer, newTestContainerClient, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("missing signer", func(t *testing.T) { + err := c.ContainerDelete(ctx, anyID, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + }) + t.Run("sign ID failure", func(t *testing.T) { + c := newTestContainerClient(t, newTestDeleteContainerServer()) + t.Run("wrong scheme", func(t *testing.T) { + err := c.ContainerDelete(ctx, anyID, neofsecdsa.Signer(neofscryptotest.ECDSAPrivateKey()), anyValidOpts) + // TODO: currently unchecked and request attempt is done. Better to pre-check like Put does + require.EqualError(t, err, "write request: rpc failure: rpc error: code = Unknown desc = invalid request: "+ + "invalid body: invalid container ID signature field: invalid signature length 65, should be 64") + }) + t.Run("signer failure", func(t *testing.T) { + err := c.ContainerDelete(ctx, anyID, neofscryptotest.FailSigner(neofscryptotest.Signer()), anyValidOpts) + // TODO: consider returning 'calculate ID signature' to distinguish from the request signatures + require.ErrorContains(t, err, "calculate signature") + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestDeleteContainerServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestDeleteContainerServer, newTestContainerClient, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestDeleteContainerServer, newDefaultContainerService, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestDeleteContainerServer, newDefaultContainerService, stat.MethodContainerDelete, + []testedClientOp{func(c *Client) error { + return c.ContainerDelete(ctx, anyID, nil, anyValidOpts) + }}, []testedClientOp{func(c *Client) error { + return c.ContainerDelete(ctx, anyID, neofscryptotest.FailSigner(anyValidSigner), anyValidOpts) + }}, func(c *Client) error { + return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + }, + ) + }) +} + +func TestClient_ContainerEACL(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmContainerEACL + anyID := cidtest.ID() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestGetEACLServer() + c := newTestContainerClient(t, srv) + + srv.checkRequestContainerID(anyID) + srv.authenticateRequest(c.prm.signer) + _, err := c.ContainerEACL(ctx, anyID, PrmContainerEACL{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestGetEACLServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ContainerEACL(ctx, anyID, opts) + return err + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.GetExtendedACLResponse_Body + }{ + {name: "min", body: validMinEACLResponseBody}, + {name: "full", body: validFullEACLResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestGetEACLServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + eACL, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkEACLTransport(eACL, tc.body.GetEacl())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestGetEACLServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "GetExtendedACL", func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestGetEACLServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protocontainer.GetExtendedACLResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing eACL field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protocontainer.GetExtendedACLResponse_Body), assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing eACL field in the response") + }}, + } + // 1. eACL + type invalidEACLTestcase = struct { + name, msg string + corrupt func(valid *protoacl.EACLTable) + } + var etcs []invalidEACLTestcase + // 1.2 container ID + for _, tc := range invalidContainerIDProtoTestcases { + etcs = append(etcs, invalidEACLTestcase{ + name: "container ID/" + tc.name, msg: "invalid container ID: " + tc.msg, + corrupt: func(valid *protoacl.EACLTable) { tc.corrupt(valid.ContainerId) }, + }) + } + // 1.3 records + for _, tc := range []struct { + name, msg string + corrupt func(valid *protoacl.EACLRecord) + }{ + {name: "op/negative", msg: "negative op", corrupt: func(valid *protoacl.EACLRecord) { + valid.Operation = -1 + }}, + {name: "action/negative", msg: "negative action", corrupt: func(valid *protoacl.EACLRecord) { + valid.Action = -1 + }}, + {name: "filters/header type/negative", msg: "invalid filter #1: negative header type", corrupt: func(valid *protoacl.EACLRecord) { + valid.Filters = []*protoacl.EACLRecord_Filter{{}, {HeaderType: -1}} + }}, + {name: "filters/matcher/negative", msg: "invalid filter #1: negative matcher", corrupt: func(valid *protoacl.EACLRecord) { + valid.Filters = []*protoacl.EACLRecord_Filter{{}, {MatchType: -1}} + }}, + {name: "targets/role/negative", msg: "invalid target #1: negative role", corrupt: func(valid *protoacl.EACLRecord) { + valid.Targets = []*protoacl.EACLRecord_Target{{}, {Role: -1}} + }}, + } { + etcs = append(etcs, invalidEACLTestcase{ + name: "records/" + tc.name, msg: "invalid record #1: " + tc.msg, + corrupt: func(valid *protoacl.EACLTable) { tc.corrupt(valid.Records[1]) }, + }) + } + + for _, tc := range etcs { + body := proto.Clone(validFullEACLResponseBody).(*protocontainer.GetExtendedACLResponse_Body) + tc.corrupt(body.Eacl) + tcs = append(tcs, testcase{name: "eACL/" + tc.name, body: body, assertErr: func(tb testing.TB, err error) { + require.EqualError(t, err, "invalid eACL field in the response: "+tc.msg) + }}) + } + + testInvalidResponseBodies(t, newTestGetEACLServer, newTestContainerClient, tcs, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestGetEACLServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestGetEACLServer, newTestContainerClient, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestGetEACLServer, newDefaultContainerService, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestGetEACLServer, newDefaultContainerService, stat.MethodContainerEACL, + nil, nil, func(c *Client) error { + _, err := c.ContainerEACL(ctx, anyID, anyValidOpts) + return err + }, + ) + }) +} + +func TestClient_ContainerSetEACL(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmContainerSetEACL + anyValidSigner := usertest.User().RFC6979 + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestSetEACLServer() + c := newTestContainerClient(t, srv) + + srv.checkRequestEACL(anyValidEACL) + srv.authenticateRequestPayload(anyValidSigner) + srv.authenticateRequest(c.prm.signer) + err := c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, PrmContainerSetEACL{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestSetEACLServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, opts) + }) + }) + t.Run("precalculated container signature", func(t *testing.T) { + srv := newTestSetEACLServer() + c := newTestContainerClient(t, srv) + + var sig neofscrypto.Signature + sig.SetPublicKeyBytes([]byte("any public key")) + sig.SetValue([]byte("any value")) + opts := anyValidOpts + opts.AttachSignature(sig) + + srv.checkRequestDataSignature(sig) + err := c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestSetEACLServer() + c := newTestContainerClient(t, srv) + + st := sessiontest.ContainerSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + err := c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.SetExtendedACLResponse_Body + }{ + {name: "min", body: validMinSetEACLResponseBody}, + {name: "full", body: validFullSetEACLResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestSetEACLServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + err := c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + require.NoError(t, err) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestSetEACLServer, newTestContainerClient, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "SetExtendedACL", func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestSetEACLServer, newTestContainerClient, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newTestContainerClient(t, newTestDeleteContainerServer()) + t.Run("missing signer", func(t *testing.T) { + err := c.ContainerSetEACL(ctx, anyValidEACL, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + t.Run("missing container ID in eACL", func(t *testing.T) { + eACL := anyValidEACL + eACL.SetCID(cid.ID{}) + err := c.ContainerSetEACL(ctx, eACL, anyValidSigner, anyValidOpts) + require.ErrorIs(t, err, ErrMissingEACLContainer) + }) + }) + t.Run("sign container failure", func(t *testing.T) { + c := newTestContainerClient(t, newTestSetEACLServer()) + t.Run("wrong scheme", func(t *testing.T) { + err := c.ContainerSetEACL(ctx, anyValidEACL, user.NewAutoIDSigner(neofscryptotest.ECDSAPrivateKey()), anyValidOpts) + // TODO: currently unchecked and request attempt is done. Better to pre-check like Put does + require.EqualError(t, err, "write request: rpc failure: rpc error: code = Unknown desc = invalid request: "+ + "invalid body: invalid eACL signature field: invalid signature length 65, should be 64") + }) + t.Run("signer failure", func(t *testing.T) { + err := c.ContainerSetEACL(ctx, anyValidEACL, usertest.FailSigner(anyValidSigner), anyValidOpts) + // TODO: consider returning 'calculate eACL signature' to distinguish from the request signatures + require.ErrorContains(t, err, "calculate signature") + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestSetEACLServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestSetEACLServer, newTestContainerClient, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestSetEACLServer, newDefaultContainerService, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestSetEACLServer, newDefaultContainerService, stat.MethodContainerSetEACL, + []testedClientOp{func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, nil, anyValidOpts) + }}, []testedClientOp{func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, usertest.FailSigner(anyValidSigner), anyValidOpts) + }}, func(c *Client) error { + return c.ContainerSetEACL(ctx, anyValidEACL, anyValidSigner, anyValidOpts) + }, + ) + }) +} + +func TestClient_ContainerAnnounceUsedSpace(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmAnnounceSpace + anyValidAnnouncements := []container.SizeEstimation{containertest.SizeEstimation(), containertest.SizeEstimation()} + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestAnnounceContainerSpaceServer() + c := newTestContainerClient(t, srv) + + srv.checkRequestAnnouncements(anyValidAnnouncements) + srv.authenticateRequest(c.prm.signer) + err := c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, PrmAnnounceSpace{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestAnnounceContainerSpaceServer, newTestContainerClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, opts) + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protocontainer.AnnounceUsedSpaceResponse_Body + }{ + {name: "min", body: validMinUsedSpaceResponseBody}, + {name: "full", body: validFullUsedSpaceResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestAnnounceContainerSpaceServer() + c := newTestContainerClient(t, srv) + + srv.respondWithBody(tc.body) + err := c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, PrmAnnounceSpace{}) + require.NoError(t, err) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestAnnounceContainerSpaceServer, newTestContainerClient, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "container.ContainerService", "AnnounceUsedSpace", func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestAnnounceContainerSpaceServer, newTestContainerClient, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + t.Run("missing announcements", func(t *testing.T) { + c := newClient(t) + err := c.ContainerAnnounceUsedSpace(ctx, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingAnnouncements) + err = c.ContainerAnnounceUsedSpace(ctx, []container.SizeEstimation{}, anyValidOpts) + require.ErrorIs(t, err, ErrMissingAnnouncements) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestAnnounceContainerSpaceServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestAnnounceContainerSpaceServer, newTestContainerClient, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestAnnounceContainerSpaceServer, newDefaultContainerService, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestAnnounceContainerSpaceServer, newDefaultContainerService, stat.MethodContainerAnnounceUsedSpace, + nil, []testedClientOp{func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, nil, anyValidOpts) + }}, func(c *Client) error { + return c.ContainerAnnounceUsedSpace(ctx, anyValidAnnouncements, anyValidOpts) + }, + ) }) } diff --git a/client/crypto_test.go b/client/crypto_test.go new file mode 100644 index 00000000..6b6cb546 --- /dev/null +++ b/client/crypto_test.go @@ -0,0 +1,167 @@ +package client + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "math/big" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/io" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + apigrpc "github.com/nspcc-dev/neofs-api-go/v2/rpc/grpc" + neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" +) + +var p256Curve = elliptic.P256() + +type signedMessageV2 interface { + FromGRPCMessage(apigrpc.Message) error + StableMarshal([]byte) []byte +} + +// represents tested NeoFS authentication credentials. +type authCredentials struct { + scheme protorefs.SignatureScheme + pub []byte +} + +func authCredentialsFromSigner(s neofscrypto.Signer) authCredentials { + var res authCredentials + res.pub = neofscrypto.PublicKeyBytes(s.Public()) + switch scheme := s.Scheme(); scheme { + default: + res.scheme = protorefs.SignatureScheme(scheme) + case neofscrypto.ECDSA_SHA512: + res.scheme = protorefs.SignatureScheme_ECDSA_SHA512 + case neofscrypto.ECDSA_DETERMINISTIC_SHA256: + res.scheme = protorefs.SignatureScheme_ECDSA_RFC6979_SHA256 + case neofscrypto.ECDSA_WALLETCONNECT: + res.scheme = protorefs.SignatureScheme_ECDSA_RFC6979_SHA256_WALLET_CONNECT + } + return res +} + +func checkAuthCredendials(exp, act authCredentials) error { + if exp.scheme != act.scheme { + return fmt.Errorf("unexpected scheme (client: %v, message: %v)", exp.scheme, act.scheme) + } + if !bytes.Equal(exp.pub, act.pub) { + return fmt.Errorf("unexpected public key (client: %x, message: %x)", exp.pub, act.pub) + } + return nil +} + +func verifyMessageSignature[MESSAGE apigrpc.Message, MESSAGEV2 any, MESSAGEV2PTR interface { + *MESSAGEV2 + signedMessageV2 +}](m MESSAGE, s *protorefs.Signature, expectedCreds *authCredentials) error { + mV2 := MESSAGEV2PTR(new(MESSAGEV2)) + if err := mV2.FromGRPCMessage(m); err != nil { + panic(err) + } + return verifyDataSignature(mV2.StableMarshal(nil), s, expectedCreds) +} + +func verifyDataSignature(data []byte, s *protorefs.Signature, expectedCreds *authCredentials) error { + if s == nil { + return errors.New("missing") + } + creds := authCredentials{scheme: s.Scheme, pub: s.Key} + if err := verifyProtoSignature(creds, s.Sign, data); err != nil { + return err + } + if expectedCreds != nil { + if err := checkAuthCredendials(*expectedCreds, creds); err != nil { + return fmt.Errorf("unexpected credendials: %w", err) + } + } + return nil +} + +func verifyProtoSignature(creds authCredentials, sig, data []byte) error { + switch creds.scheme { + default: + return fmt.Errorf("unsupported scheme: %v", creds.scheme) + case protorefs.SignatureScheme_ECDSA_SHA512: + if len(sig) != keys.SignatureLen+1 { + return fmt.Errorf("invalid signature length %d, should be %d", len(sig), keys.SignatureLen+1) + } + r, s := unmarshalECP256Point([keys.SignatureLen + 1]byte(sig)) + if r == nil { + return fmt.Errorf("invalid signature format %x", sig) + } + x, y := elliptic.UnmarshalCompressed(p256Curve, creds.pub) + if x == nil { + return fmt.Errorf("invalid public key: %x", sig) + } + h := sha512.Sum512(data) + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: p256Curve, X: x, Y: y}, h[:], r, s) { + return errors.New("signature mismatch") + } + case protorefs.SignatureScheme_ECDSA_RFC6979_SHA256: + if len(sig) != keys.SignatureLen { + return fmt.Errorf("invalid signature length %d, should be %d", len(sig), keys.SignatureLen) + } + x, y := elliptic.UnmarshalCompressed(p256Curve, creds.pub) + if x == nil { + return fmt.Errorf("invalid signature's public key: %x", sig) + } + h := sha256.Sum256(data) + r, s := ecP256PointFromBytes([keys.SignatureLen]byte(sig)) + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: p256Curve, X: x, Y: y}, h[:], r, s) { + return errors.New("signature mismatch") + } + case protorefs.SignatureScheme_ECDSA_RFC6979_SHA256_WALLET_CONNECT: + const saltLen = 16 + if len(sig) != keys.SignatureLen+saltLen { + return fmt.Errorf("invalid signature length %d, should be %d", + len(sig), keys.SignatureLen) + } + x, y := elliptic.UnmarshalCompressed(p256Curve, creds.pub) + if x == nil { + return fmt.Errorf("invalid public key: %x", creds.pub) + } + + b64 := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(b64, data) + payloadLen := 2*saltLen + len(b64) + b := make([]byte, 4+io.GetVarSize(payloadLen)+payloadLen+2) + n := copy(b, []byte{0x01, 0x00, 0x01, 0xf0}) + n += io.PutVarUint(b[n:], uint64(payloadLen)) + n += hex.Encode(b[n:], sig[keys.SignatureLen:]) + n += copy(b[n:], b64) + copy(b[n:], []byte{0x00, 0x00}) + + h := sha256.Sum256(b) + r, s := ecP256PointFromBytes([keys.SignatureLen]byte(sig)) + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: p256Curve, X: x, Y: y}, h[:], r, s) { + return errors.New("signature mismatch") + } + } + return nil +} + +func ecP256PointFromBytes(b [keys.SignatureLen]byte) (*big.Int, *big.Int) { + return new(big.Int).SetBytes(b[:32]), new(big.Int).SetBytes(b[32:]) +} + +// decodes a serialized [elliptic.P256] point. It is an error if the point is +// not in uncompressed form, or is the point at infinity. On error, x = nil. +func unmarshalECP256Point(b [keys.SignatureLen + 1]byte) (x, y *big.Int) { + if b[0] != 4 { // uncompressed form + return + } + p := p256Curve.Params().P + x, y = ecP256PointFromBytes([keys.SignatureLen]byte(b[1:])) + if x.Cmp(p) >= 0 || y.Cmp(p) >= 0 { + return nil, nil + } + return x, y +} diff --git a/client/messages_test.go b/client/messages_test.go new file mode 100644 index 00000000..752ff593 --- /dev/null +++ b/client/messages_test.go @@ -0,0 +1,1769 @@ +package client + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math" + "math/rand" + "strconv" + "strings" + + "github.com/google/uuid" + protoaccounting "github.com/nspcc-dev/neofs-api-go/v2/accounting/grpc" + protoacl "github.com/nspcc-dev/neofs-api-go/v2/acl/grpc" + protocontainer "github.com/nspcc-dev/neofs-api-go/v2/container/grpc" + apinetmap "github.com/nspcc-dev/neofs-api-go/v2/netmap" + protonetmap "github.com/nspcc-dev/neofs-api-go/v2/netmap/grpc" + protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + protoreputation "github.com/nspcc-dev/neofs-api-go/v2/reputation/grpc" + protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" + "github.com/nspcc-dev/neofs-sdk-go/accounting" + "github.com/nspcc-dev/neofs-sdk-go/bearer" + "github.com/nspcc-dev/neofs-sdk-go/checksum" + "github.com/nspcc-dev/neofs-sdk-go/container" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" + neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" + neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa" + "github.com/nspcc-dev/neofs-sdk-go/eacl" + "github.com/nspcc-dev/neofs-sdk-go/netmap" + "github.com/nspcc-dev/neofs-sdk-go/object" + oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/reputation" + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/nspcc-dev/neofs-sdk-go/user" + "github.com/nspcc-dev/neofs-sdk-go/version" + "google.golang.org/protobuf/proto" +) + +/* +Various NeoFS protocol messages. Any message (incl. each group element) must be +cloned via [proto.Clone] for the network transmission. +*/ + +// Cross-service. +var ( + // set of correct user IDs. + validProtoUserIDs = []*protorefs.OwnerID{ + {Value: []byte{53, 28, 187, 10, 189, 59, 106, 227, 123, 6, 44, 200, 77, 80, 253, 72, 33, 96, 198, 185, 219, 239, 171, 67, 127}}, + {Value: []byte{53, 161, 152, 17, 205, 254, 2, 98, 40, 148, 36, 174, 244, 176, 247, 233, 240, 190, 173, 56, 130, 127, 109, 227, 193}}, + } + // set of correct object IDs. + validProtoObjectIDs = []*protorefs.ObjectID{ + {Value: []byte{218, 203, 9, 142, 129, 249, 13, 159, 198, 60, 153, 148, 70, 216, 50, 17, 15, 87, 47, 104, 143, 0, 187, 211, 120, 105, 250, 170, 220, 36, 108, 171}}, + {Value: []byte{28, 74, 243, 168, 65, 185, 194, 228, 239, 47, 76, 99, 131, 154, 18, 4, 91, 243, 28, 47, 183, 252, 203, 17, 32, 194, 193, 55, 213, 43, 15, 157}}, + {Value: []byte{64, 228, 234, 193, 115, 188, 136, 160, 127, 238, 221, 164, 4, 75, 158, 61, 82, 183, 241, 130, 189, 122, 192, 191, 244, 181, 98, 91, 179, 36, 197, 47}}, + } + // correct object address with all fields. + validFullProtoObjectAddress = &protorefs.Address{ + ContainerId: proto.Clone(validProtoContainerIDs[0]).(*protorefs.ContainerID), + ObjectId: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + } + // correct signature with required fields only. + validMinProtoSignature = &protorefs.Signature{} + // correct signature with all fields. + validFullProtoSignature = &protorefs.Signature{ + Key: []byte("any_key"), + Sign: []byte("any_signature"), + Scheme: protorefs.SignatureScheme(rand.Int31()), + } +) + +// Accounting service. +var ( + // correct balance with required fields only. + validMinProtoBalance = &protoaccounting.Decimal{} + // correct balance with all fields. + validFullProtoBalance = &protoaccounting.Decimal{Value: 1609926665709559552, Precision: 2322521745} + // correct AccountingService.Balance response payload with required fields only. + validMinBalanceResponseBody = &protoaccounting.BalanceResponse_Body{ + Balance: proto.Clone(validMinProtoBalance).(*protoaccounting.Decimal), + } + // correct AccountingService.Balance response payload with all fields. + validFullBalanceResponseBody = &protoaccounting.BalanceResponse_Body{ + Balance: proto.Clone(validFullProtoBalance).(*protoaccounting.Decimal), + } +) + +// Container service. +var ( + // correct container with required fields only. + validMinProtoContainer = &protocontainer.Container{ + Version: proto.Clone(validMinProtoVersion).(*protorefs.Version), + OwnerId: &protorefs.OwnerID{Value: []byte{53, 233, 31, 174, 37, 64, 241, 22, 182, 130, 7, 210, 222, 150, 85, 18, 106, 4, + 253, 122, 191, 90, 168, 187, 245}}, + Nonce: []byte{207, 5, 57, 28, 224, 103, 76, 207, 133, 186, 108, 96, 185, 52, 37, 205}, + PlacementPolicy: &protonetmap.PlacementPolicy{ + Replicas: make([]*protonetmap.Replica, 1), + }, + } + // correct container with all fields. + validFullProtoContainer = &protocontainer.Container{ + Version: proto.Clone(validFullProtoVersion).(*protorefs.Version), + OwnerId: proto.Clone(validMinProtoContainer.OwnerId).(*protorefs.OwnerID), + Nonce: bytes.Clone(validMinProtoContainer.Nonce), + BasicAcl: 1043832770, + Attributes: []*protocontainer.Container_Attribute{ + {Key: "k1", Value: "v1"}, + {Key: "k2", Value: "v2"}, + {Key: "Name", Value: "any container name"}, + {Key: "Timestamp", Value: "1732577694"}, + {Key: "__NEOFS__NAME", Value: "any domain name"}, + {Key: "__NEOFS__ZONE", Value: "any domain zone"}, + {Key: "__NEOFS__DISABLE_HOMOMORPHIC_HASHING", Value: "true"}, + }, + PlacementPolicy: &protonetmap.PlacementPolicy{ + Replicas: []*protonetmap.Replica{ + {Count: 3060437, Selector: "selector1"}, + {Count: 156936495, Selector: "selector2"}, + }, + ContainerBackupFactor: 920231904, + Selectors: []*protonetmap.Selector{ + {Name: "selector1", Count: 1663184999, Clause: 1, Attribute: "attribute1", Filter: "filter1"}, + {Name: "selector2", Count: 2649065896, Clause: 2, Attribute: "attribute2", Filter: "filter2"}, + {Name: "selector_max", Count: 2649065896, Clause: math.MaxInt32, Attribute: "attribute_max", Filter: "filter_max"}, + }, + Filters: []*protonetmap.Filter{ + {Name: "filter1", Key: "key1", Op: 0, Value: "value1", Filters: []*protonetmap.Filter{ + {}, + {}, + }}, + {Op: 1}, + {Op: 2}, + {Op: 3}, + {Op: 4}, + {Op: 5}, + {Op: 6}, + {Op: 7}, + {Op: 8}, + {Op: math.MaxInt32}, + }, + SubnetId: &protorefs.SubnetID{Value: 987533317}, + }, + } + // correct eACL with required fields only. + validMinEACL = &protoacl.EACLTable{} + // correct eACL with required all fields. + validFullEACL = &protoacl.EACLTable{ + Version: &protorefs.Version{Major: 538919038, Minor: 3957317479}, + ContainerId: proto.Clone(validProtoContainerIDs[0]).(*protorefs.ContainerID), + Records: []*protoacl.EACLRecord{ + {}, + {Operation: 1, Action: 1}, + {Operation: 2, Action: 2}, + {Operation: 3, Action: 3}, + {Operation: 4, Action: math.MaxInt32}, + {Operation: 5}, + {Operation: 6}, + {Operation: 7}, + {Operation: math.MaxInt32}, + {Filters: []*protoacl.EACLRecord_Filter{ + {HeaderType: 0, MatchType: 0, Key: "key1", Value: "val1"}, + {HeaderType: 1, MatchType: 1}, + {HeaderType: 2, MatchType: 2}, + {HeaderType: 3, MatchType: 3}, + {HeaderType: math.MaxInt32, MatchType: 4}, + {MatchType: 5}, + {MatchType: 6}, + {MatchType: 7}, + {MatchType: math.MaxInt32}, + }}, + {Targets: []*protoacl.EACLRecord_Target{ + {Role: 0, Keys: [][]byte{[]byte("key1"), []byte("key2")}}, + {Role: 1}, + {Role: 2}, + {Role: 3}, + {Role: math.MaxInt32}, + }}, + }, + } + // correct ContainerService.Put response payload with required fields only. + validMinPutContainerResponseBody = &protocontainer.PutResponse_Body{ + ContainerId: proto.Clone(validProtoContainerIDs[0]).(*protorefs.ContainerID), + } + // correct ContainerService.Put response payload with all fields. + validFullPutContainerResponseBody = proto.Clone(validMinPutContainerResponseBody).(*protocontainer.PutResponse_Body) + // correct ContainerService.Get response payload with required fields only. + validMinGetContainerResponseBody = &protocontainer.GetResponse_Body{ + Container: proto.Clone(validMinProtoContainer).(*protocontainer.Container), + } + // correct ContainerService.Get response payload with all fields. + validFullGetContainerResponseBody = &protocontainer.GetResponse_Body{ + Container: proto.Clone(validFullProtoContainer).(*protocontainer.Container), + Signature: &protorefs.SignatureRFC6979{Key: []byte("any_key"), Sign: []byte("any_signature")}, + SessionToken: &protosession.SessionToken{ + Body: &protosession.SessionToken_Body{ + Id: []byte("any_ID"), + OwnerId: &protorefs.OwnerID{Value: []byte("any_user")}, + Lifetime: &protosession.SessionToken_Body_TokenLifetime{Exp: 1, Nbf: 2, Iat: 3}, + SessionKey: []byte("any_session_key"), + }, + Signature: proto.Clone(validFullProtoSignature).(*protorefs.Signature), + }, + } + // correct ContainerService.List response payload with required fields only. + validMinListContainersResponseBody = (*protocontainer.ListResponse_Body)(nil) + // correct ContainerService.List response payload with all fields. + validFullListContainersResponseBody = &protocontainer.ListResponse_Body{ + ContainerIds: []*protorefs.ContainerID{ + proto.Clone(validProtoContainerIDs[0]).(*protorefs.ContainerID), + proto.Clone(validProtoContainerIDs[1]).(*protorefs.ContainerID), + proto.Clone(validProtoContainerIDs[2]).(*protorefs.ContainerID), + }, + } + // correct ContainerService.Delete response payload with required fields only. + validMinDeleteContainerResponseBody = (*protocontainer.DeleteResponse_Body)(nil) + // correct ContainerService.Delete response payload with all fields. + validFullDeleteContainerResponseBody = &protocontainer.DeleteResponse_Body{} + // correct ContainerService.GetExtendedACL response payload with required fields only. + validMinEACLResponseBody = &protocontainer.GetExtendedACLResponse_Body{ + Eacl: proto.Clone(validMinEACL).(*protoacl.EACLTable), + } + // correct ContainerService.GetExtendedACL response payload with all fields. + validFullEACLResponseBody = &protocontainer.GetExtendedACLResponse_Body{ + Eacl: proto.Clone(validFullEACL).(*protoacl.EACLTable), + Signature: proto.Clone(validFullGetContainerResponseBody.Signature).(*protorefs.SignatureRFC6979), + SessionToken: proto.Clone(validFullGetContainerResponseBody.SessionToken).(*protosession.SessionToken), + } + // correct ContainerService.SetExtendedACL response payload with required fields only. + validMinSetEACLResponseBody = (*protocontainer.SetExtendedACLResponse_Body)(nil) + // correct ContainerService.SetExtendedACL response payload with all fields. + validFullSetEACLResponseBody = &protocontainer.SetExtendedACLResponse_Body{} + // correct ContainerService.AnnounceUsedSpace response payload with required fields only. + validMinUsedSpaceResponseBody = (*protocontainer.AnnounceUsedSpaceResponse_Body)(nil) + // correct ContainerService.AnnounceUsedSpace response payload with all fields. + validFullUsedSpaceResponseBody = &protocontainer.AnnounceUsedSpaceResponse_Body{} +) + +// Netmap service. +var ( + // correct node info with required fields only. + validMinNodeInfo = &protonetmap.NodeInfo{ + PublicKey: []byte("any_pub"), + Addresses: []string{"any_endpoint"}, + } + // correct node info with all fields. + validFullNodeInfo = newValidFullNodeInfo(0) + // correct network map with required fields only. + validMinProtoNetmap = &protonetmap.Netmap{} + // correct network map with all fields. + validFullProtoNetmap = &protonetmap.Netmap{ + Epoch: 17416815529850981458, + Nodes: []*protonetmap.NodeInfo{newValidFullNodeInfo(0), newValidFullNodeInfo(1), newValidFullNodeInfo(2)}, + } + // correct network info with required fields only. + validMinProtoNetInfo = &protonetmap.NetworkInfo{ + NetworkConfig: &protonetmap.NetworkConfig{ + Parameters: []*protonetmap.NetworkConfig_Parameter{ + {Value: []byte("any")}, // TODO: empty key is OK? + }, + }, + } + // correct network info with all fields. + validFullProtoNetInfo = &protonetmap.NetworkInfo{ + CurrentEpoch: 17416815529850981458, + MagicNumber: 8576993077569092248, + MsPerBlock: 9059417785180743518, + NetworkConfig: &protonetmap.NetworkConfig{ + Parameters: []*protonetmap.NetworkConfig_Parameter{ + {Key: []byte("k1"), Value: []byte("v1")}, + {Key: []byte("k2"), Value: []byte("v2")}, + {Key: []byte("AuditFee"), Value: []byte{148, 103, 221, 13, 230, 131, 76, 41}}, // 2975898477883385748 + {Key: []byte("BasicIncomeRate"), Value: []byte{75, 10, 132, 219, 93, 88, 10, 159}}, // 11460069361935714891 + {Key: []byte("ContainerFee"), Value: []byte{138, 229, 49, 0, 30, 129, 67, 130}}, // 9386488014222517642 + {Key: []byte("ContainerAliasFee"), Value: []byte{138, 229, 49, 0, 30, 129, 67, 130}}, // 9386488014222517642 + {Key: []byte("EigenTrustAlpha"), Value: []byte("5.551764501727871")}, + {Key: []byte("EigenTrustIterations"), Value: []byte{130, 92, 74, 224, 95, 59, 146, 249}}, // 17983501545014713474 + {Key: []byte("EpochDuration"), Value: []byte{161, 231, 2, 119, 184, 52, 66, 217}}, // 15655133221568571297 + {Key: []byte("HomomorphicHashingDisabled"), Value: []byte("any")}, + {Key: []byte("InnerRingCandidateFee"), Value: []byte{0, 11, 236, 200, 112, 164, 1, 217}}, // 15636960185521277696 + {Key: []byte("MaintenanceModeAllowed"), Value: []byte("any")}, + {Key: []byte("MaxObjectSize"), Value: []byte{109, 133, 46, 32, 118, 66, 240, 72}}, // 5255773840254862701 + {Key: []byte("WithdrawFee"), Value: []byte{216, 63, 55, 77, 56, 24, 171, 101}}, // 7325975848940945368 + }, + }, + } + // correct NetmapService.LocalNodeInfo response payload with required fields only. + validMinNodeInfoResponseBody = &protonetmap.LocalNodeInfoResponse_Body{ + Version: proto.Clone(validMinProtoVersion).(*protorefs.Version), + NodeInfo: proto.Clone(validMinNodeInfo).(*protonetmap.NodeInfo), + } + // correct NetmapService.LocalNodeInfo response payload with all fields. + validFullNodeInfoResponseBody = &protonetmap.LocalNodeInfoResponse_Body{ + Version: proto.Clone(validFullProtoVersion).(*protorefs.Version), + NodeInfo: proto.Clone(validFullNodeInfo).(*protonetmap.NodeInfo), + } + // correct NetmapService.NetmapSnapshot response payload with required fields only. + validMinNetmapResponseBody = &protonetmap.NetmapSnapshotResponse_Body{ + Netmap: proto.Clone(validMinProtoNetmap).(*protonetmap.Netmap), + } + // correct NetmapService.NetmapSnapshot response payload with all fields. + validFullNetmapResponseBody = &protonetmap.NetmapSnapshotResponse_Body{ + Netmap: proto.Clone(validFullProtoNetmap).(*protonetmap.Netmap), + } + // correct NetmapService.NetworkInfo response payload with required fields only. + validMinNetInfoResponseBody = &protonetmap.NetworkInfoResponse_Body{ + NetworkInfo: proto.Clone(validMinProtoNetInfo).(*protonetmap.NetworkInfo), + } + // correct NetmapService.NetworkInfo response payload with all fields. + validFullNetInfoResponseBody = &protonetmap.NetworkInfoResponse_Body{ + NetworkInfo: proto.Clone(validFullProtoNetInfo).(*protonetmap.NetworkInfo), + } +) + +// Object service. +var ( + // valid object header with required fields only. + validMinObjectHeader = &protoobject.Header{} + // correct object header with all fields. + validFullObjectHeader = &protoobject.Header{ + Version: &protorefs.Version{Major: 2551725017, Minor: 2526948189}, + ContainerId: &protorefs.ContainerID{Value: []byte{80, 212, 0, 200, 84, 144, 252, 77, 205, 169, 28, 36, 61, 25, 4, 32, + 182, 161, 107, 148, 193, 86, 1, 252, 224, 65, 204, 176, 27, 189, 63, 198}}, + OwnerId: &protorefs.OwnerID{Value: []byte{53, 36, 208, 131, 238, 151, 230, 27, 245, 87, 156, 55, 90, 144, 192, 82, + 205, 97, 243, 240, 98, 0, 4, 202, 190}}, + CreationEpoch: 535166283637641128, + PayloadLength: 7493095166286485665, + PayloadHash: &protorefs.Checksum{Type: 745469659, Sum: []byte("payload_checksum")}, + ObjectType: 1336146323, + HomomorphicHash: &protorefs.Checksum{Type: 56973732, Sum: []byte("homomorphic_checksum")}, + SessionToken: &protosession.SessionToken{ + Body: &protosession.SessionToken_Body{ + Id: []byte{219, 53, 231, 42, 56, 82, 65, 196, 175, 34, 22, 36, 170, 248, 64, 45}, + OwnerId: &protorefs.OwnerID{Value: []byte{53, 79, 105, 50, 97, 214, 227, 217, 243, 111, 24, 28, 164, 116, 174, 36, + 217, 111, 165, 197, 109, 225, 168, 165, 133}}, + Lifetime: &protosession.SessionToken_Body_TokenLifetime{ + Exp: 2306780414485650416, Nbf: 17091941679101563337, Iat: 10428481937388069414, + }, + SessionKey: []byte{3, 47, 174, 204, 218, 71, 223, 103, 27, 142, 185, 141, 190, 177, 199, 235, 100, 168, 68, 216, 253, + 4, 124, 162, 237, 187, 141, 28, 109, 121, 22, 77, 77}, + Context: &protosession.SessionToken_Body_Object{ + Object: &protosession.ObjectSessionContext{Verb: 1849442930, Target: &protosession.ObjectSessionContext_Target{ + Container: &protorefs.ContainerID{Value: []byte{43, 155, 220, 2, 70, 86, 249, 4, 211, 12, 14, 152, 15, 165, + 141, 240, 15, 199, 82, 245, 32, 86, 49, 60, 3, 15, 235, 107, 227, 21, 201, 226}}, + Objects: []*protorefs.ObjectID{ + {Value: []byte{168, 182, 85, 123, 227, 177, 127, 228, 62, 192, 73, 61, 38, 102, 136, 138, 20, 155, 175, + 89, 95, 241, 200, 148, 156, 142, 215, 78, 34, 223, 238, 62}}, + {Value: []byte{104, 187, 144, 239, 201, 242, 213, 136, 32, 1, 74, 125, 157, 143, 114, 57, 57, 182, 218, + 172, 126, 69, 157, 62, 119, 45, 116, 152, 225, 222, 16, 243}}, + {Value: []byte{106, 193, 15, 88, 111, 154, 77, 182, 11, 190, 3, 154, 84, 249, 1, 165, 220, 23, 234, 101, + 210, 105, 114, 230, 251, 102, 164, 142, 128, 6, 35, 131}}, + }, + }}, + }, + }, + Signature: &protorefs.Signature{Key: []byte("any_public_key"), Sign: []byte("any_signature"), Scheme: 343874216}, + }, + Attributes: []*protoobject.Header_Attribute{ + {Key: "k1", Value: "v1"}, + {Key: "k2", Value: "v2"}, + {Key: "__NEOFS__EXPIRATION_EPOCH", Value: "15108052785492221606"}, + }, + Split: &protoobject.Header_Split{ + Parent: &protorefs.ObjectID{Value: []byte{136, 16, 11, 39, 44, 190, 117, 150, 28, 108, 97, 182, 137, 71, 116, 141, + 39, 3, 240, 58, 177, 143, 185, 171, 139, 189, 87, 178, 168, 91, 108, 49}}, + Previous: &protorefs.ObjectID{Value: []byte{70, 184, 70, 223, 213, 136, 169, 221, 63, 103, 244, 43, 109, 226, 9, + 243, 154, 177, 74, 6, 128, 100, 237, 126, 81, 203, 210, 206, 97, 16, 12, 145}}, + ParentSignature: &protorefs.Signature{Key: []byte("any_parent_key"), Sign: []byte("any_parent_signature"), Scheme: 343874216}, + ParentHeader: &protoobject.Header{ + Version: &protorefs.Version{Major: 1650885558, Minor: 1215827697}, + ContainerId: &protorefs.ContainerID{Value: []byte{180, 73, 166, 38, 121, 174, 19, 54, 183, 40, 110, 62, 221, 124, 243, + 108, 222, 97, 21, 41, 154, 159, 92, 217, 99, 136, 75, 2, 71, 243, 230, 33}}, + OwnerId: &protorefs.OwnerID{Value: []byte{53, 147, 252, 32, 131, 247, 225, 223, 238, 111, 227, 232, 235, 86, 220, 225, 95, 68, 242, 143, 250, 19, 209, 207, 137}}, + CreationEpoch: 13908636632389871906, + PayloadLength: 9446280261481989231, + PayloadHash: &protorefs.Checksum{Type: 1764227836, Sum: []byte("parent_payload_checksum")}, + ObjectType: 950142306, + HomomorphicHash: &protorefs.Checksum{Type: 2086030953, Sum: []byte("parent_homomorphic_checksum")}, + Attributes: []*protoobject.Header_Attribute{ + {Key: "parent_k1", Value: "parent_v1"}, + {Key: "parent_k2", Value: "parent_v2"}, + {Key: "__NEOFS__EXPIRATION_EPOCH", Value: "5546294308840974481"}, + }, + }, + Children: []*protorefs.ObjectID{ + {Value: []byte{62, 123, 103, 12, 105, 55, 53, 123, 78, 108, 241, 217, 90, 252, 200, 18, 237, 194, 154, 76, 101, 254, + 10, 80, 245, 97, 195, 227, 184, 247, 23, 2}}, + {Value: []byte{127, 105, 152, 33, 27, 219, 170, 156, 77, 47, 133, 82, 253, 100, 203, 229, 12, 231, 39, 223, 155, 199, + 124, 164, 78, 208, 243, 23, 220, 13, 101, 91}}, + {Value: []byte{232, 111, 102, 246, 179, 18, 108, 53, 36, 150, 64, 248, 108, 100, 161, 85, 82, 27, 39, 90, 97, 184, 146, + 230, 139, 162, 43, 171, 65, 184, 255, 238}}, + }, + SplitId: []byte{161, 132, 100, 12, 194, 100, 65, 179, 165, 156, 156, 2, 173, 208, 33, 45}, + First: &protorefs.ObjectID{Value: []byte{43, 82, 110, 195, 252, 103, 56, 184, 106, 229, 94, 136, 213, 63, 133, + 47, 174, 125, 1, 181, 102, 158, 110, 102, 115, 41, 204, 232, 44, 176, 233, 78}}, + }, + } + // correct split info with required fields only. + validMinSplitInfo = &protoobject.SplitInfo{ + LastPart: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + } + // correct split info with all fields. + validFullSplitInfo = &protoobject.SplitInfo{ + SplitId: []byte{181, 76, 71, 204, 73, 230, 65, 146, 156, 76, 98, 233, 55, 162, 45, 223}, + LastPart: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + Link: proto.Clone(validProtoObjectIDs[1]).(*protorefs.ObjectID), + FirstPart: proto.Clone(validProtoObjectIDs[2]).(*protorefs.ObjectID), + } + // correct ObjectService.Put response payload with required fields only. + validMinPutObjectResponseBody = &protoobject.PutResponse_Body{ + ObjectId: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + } + // correct ObjectService.Put response payload with all fields. + validFullPutObjectResponseBody = &protoobject.PutResponse_Body{ + ObjectId: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + } + // correct ObjectService.Delete response payload with required fields only. + validMinDeleteObjectResponseBody = &protoobject.DeleteResponse_Body{ + Tombstone: &protorefs.Address{ + ObjectId: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + }, + } + // correct ObjectService.Delete response payload with all fields. + validFullDeleteObjectResponseBody = &protoobject.DeleteResponse_Body{ + Tombstone: proto.Clone(validFullProtoObjectAddress).(*protorefs.Address), + } + // correct ObjectService.GetRangeHash response payload with required fields only. + validMinObjectHashResponseBody = &protoobject.GetRangeHashResponse_Body{ + HashList: [][]byte{[]byte("one")}, + } + // correct ObjectService.GetRangeHash response payload with all fields. + validFullObjectHashResponseBody = &protoobject.GetRangeHashResponse_Body{ + Type: protorefs.ChecksumType(rand.Int31()), + HashList: [][]byte{[]byte("one"), []byte("two")}, + } + // correct ObjectService.Head split info response payload with required fields only. + validMinObjectSplitInfoHeadResponseBody = &protoobject.HeadResponse_Body{ + Head: &protoobject.HeadResponse_Body_SplitInfo{ + SplitInfo: proto.Clone(validMinSplitInfo).(*protoobject.SplitInfo), + }, + } + // correct ObjectService.Head split info response payload with all fields. + validFullObjectSplitInfoHeadResponseBody = &protoobject.HeadResponse_Body{ + Head: &protoobject.HeadResponse_Body_SplitInfo{ + SplitInfo: proto.Clone(validFullSplitInfo).(*protoobject.SplitInfo), + }, + } + // correct ObjectService.Head response payload with required fields only. + validMinObjectHeadResponseBody = &protoobject.HeadResponse_Body{ + Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Header: nil, + Signature: nil, + }, + }, + } + // correct ObjectService.Head response payload with all fields. + validFullObjectHeadResponseBody = &protoobject.HeadResponse_Body{ + Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Header: proto.Clone(validFullObjectHeader).(*protoobject.Header), + Signature: proto.Clone(validFullProtoSignature).(*protorefs.Signature), + }, + }, + } +) + +// Reputation service. +var ( + // correct ReputationService.AnnounceIntermediateResult response payload with + // required fields only. + validMinAnnounceIntermediateRepResponseBody = (*protoreputation.AnnounceIntermediateResultResponse_Body)(nil) + // correct ReputationService.AnnounceIntermediateResult response payload with + // all fields. + validFullAnnounceIntermediateRepResponseBody = &protoreputation.AnnounceIntermediateResultResponse_Body{} + // correct ReputationService.AnnounceLocalTrust response payload with required + // fields only. + validMinAnnounceLocalTrustResponseBody = (*protoreputation.AnnounceLocalTrustResponse_Body)(nil) + // correct ReputationService.AnnounceLocalTrust response payload with all + // fields. + validFullAnnounceLocalTrustRepResponseBody = &protoreputation.AnnounceLocalTrustResponse_Body{} +) + +// Session service. +var ( + // correct SessionService.Create response payload with required fields + // only. + validMinCreateSessionResponseBody = &protosession.CreateResponse_Body{ + Id: []byte("any_ID"), + SessionKey: []byte("any_pub"), + } + // correct SessionService.Create response payload with all fields. + validFullCreateSessionResponseBody = proto.Clone(validMinCreateSessionResponseBody).(*protosession.CreateResponse_Body) +) + +func newValidFullNodeInfo(ind int) *protonetmap.NodeInfo { + si := strconv.Itoa(ind) + return &protonetmap.NodeInfo{ + PublicKey: []byte("pub_" + si), + Addresses: []string{"endpoint_" + si + "_0", "endpoint_" + si + "_1"}, + Attributes: []*protonetmap.NodeInfo_Attribute{ + {Key: "attr_key_" + si + "_0", Value: "attr_val_" + si + "_0"}, + {Key: "attr_key_" + si + "_1", Value: "attr_val_" + si + "_1"}, + }, + State: protonetmap.NodeInfo_State(ind), + } +} + +func checkContainerIDTransport(id cid.ID, m *protorefs.ContainerID) error { + if v1, v2 := id[:], m.GetValue(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message: %x)", v1, v2) + } + return nil +} + +func checkObjectIDTransport(id oid.ID, m *protorefs.ObjectID) error { + if v1, v2 := id[:], m.GetValue(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message: %x)", v1, v2) + } + return nil +} + +func checkUserIDTransport(id user.ID, m *protorefs.OwnerID) error { + if v1, v2 := id[:], m.GetValue(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message: %x)", v1, v2) + } + return nil +} + +func checkSignatureTransport(sig neofscrypto.Signature, m *protorefs.Signature) error { + scheme := sig.Scheme() + var expScheme protorefs.SignatureScheme + switch scheme { + default: + expScheme = protorefs.SignatureScheme(scheme) + case neofscrypto.ECDSA_SHA512: + expScheme = protorefs.SignatureScheme_ECDSA_SHA512 + case neofscrypto.ECDSA_DETERMINISTIC_SHA256: + expScheme = protorefs.SignatureScheme_ECDSA_RFC6979_SHA256 + case neofscrypto.ECDSA_WALLETCONNECT: + expScheme = protorefs.SignatureScheme_ECDSA_RFC6979_SHA256_WALLET_CONNECT + } + if actScheme := m.GetScheme(); actScheme != expScheme { + return fmt.Errorf("scheme field (client: %v, message: %v)", actScheme, expScheme) + } + if v1, v2 := sig.PublicKeyBytes(), m.GetKey(); !bytes.Equal(v1, v2) { + return fmt.Errorf("public key field (client: %x, message: %x)", v1, v2) + } + if v1, v2 := sig.Value(), m.GetSign(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message: %x)", v1, v2) + } + return nil +} + +func checkSignatureRFC6979Transport(sig neofscrypto.Signature, m *protorefs.SignatureRFC6979) error { + if v1, v2 := sig.PublicKeyBytes(), m.GetKey(); !bytes.Equal(v1, v2) { + return fmt.Errorf("public key field (client: %x, message: %x)", v1, v2) + } + if v1, v2 := sig.Value(), m.GetSign(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message: %x)", v1, v2) + } + return nil +} + +// returns context oneof field of unexported type. +func checkCommonSessionTransport(t interface { + ID() uuid.UUID + Issuer() user.ID + Exp() uint64 + Nbf() uint64 + Iat() uint64 + IssuerPublicKeyBytes() []byte + AssertAuthKey(neofscrypto.PublicKey) bool + Signature() (neofscrypto.Signature, bool) +}, m *protosession.SessionToken) (any, error) { + body := m.GetBody() + if body == nil { + return nil, errors.New("missing body field in the message") + } + // 1. ID + id := t.ID() + if v1, v2 := id[:], body.GetId(); !bytes.Equal(v1, v2) { + return nil, fmt.Errorf("ID field (client: %x, message: %x)", v1, v2) + } + // 2. issuer + if err := checkUserIDTransport(t.Issuer(), body.GetOwnerId()); err != nil { + return nil, fmt.Errorf("issuer field: %w", err) + } + // 3. lifetime + lt := body.GetLifetime() + if v1, v2 := t.Exp(), lt.GetExp(); v1 != v2 { + return nil, fmt.Errorf("exp lifetime field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := t.Nbf(), lt.GetNbf(); v1 != v2 { + return nil, fmt.Errorf("nbf lifetime field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := t.Iat(), lt.GetIat(); v1 != v2 { + return nil, fmt.Errorf("iat lifetime field (client: %d, message: %d)", v1, v2) + } + // 4. session key + var k neofsecdsa.PublicKey + kb := body.GetSessionKey() + if err := k.Decode(kb); err != nil { + return nil, fmt.Errorf("invalid session key in the message: %w", err) + } + if !t.AssertAuthKey(&k) { + return nil, errors.New("session key mismatch") + } + // 5+. context + c := body.GetContext() + if c == nil { + return nil, errors.New("missing context") + } + + msig := m.GetSignature() + if sig, ok := t.Signature(); ok { + if msig == nil { + return nil, errors.New("missing signature field in the message") + } + if err := checkSignatureTransport(sig, msig); err != nil { + return nil, fmt.Errorf("signature field: %w", err) + } + } else if msig != nil { + return nil, errors.New("signature field is set while should not be") + } + return c, nil +} + +func checkContainerSessionTransport(t session.Container, m *protosession.SessionToken) error { + c, err := checkCommonSessionTransport(&t, m) // TODO: strange that verification requires a pointer + if err != nil { + return err + } + cb, ok := c.(*protosession.SessionToken_Body_Container) + if !ok { + return fmt.Errorf("wrong oneof context field type (client: %T, message: %T)", cb, c) + } + cc := cb.Container + // 1. verb + var expVerb session.ContainerVerb + actVerb := cc.GetVerb() + switch actVerb { + default: + expVerb = session.ContainerVerb(actVerb) + case protosession.ContainerSessionContext_PUT: + expVerb = session.VerbContainerPut + case protosession.ContainerSessionContext_DELETE: + expVerb = session.VerbContainerDelete + case protosession.ContainerSessionContext_SETEACL: + expVerb = session.VerbContainerSetEACL + } + if !t.AssertVerb(expVerb) { + return fmt.Errorf("wrong verb in the context field: %v", actVerb) + } + // 1.2, 1.3 container(s) + wc := cc.GetWildcard() + mc := cc.GetContainerId() + if mc == nil != wc { + return errors.New("wildcard flag conflicts with container ID in the context") + } + if wc { + if !t.AppliedTo(cidtest.ID()) { + return errors.New("wildcard flag is set while should not be") + } + } else { + var expCnr cid.ID + actCnr := mc.GetValue() + if copy(expCnr[:], actCnr); !t.AppliedTo(expCnr) { + return fmt.Errorf("wrong container in the context field: %x", actCnr) + } + } + return nil +} + +func checkObjectSessionTransport(t session.Object, m *protosession.SessionToken) error { + c, err := checkCommonSessionTransport(&t, m) // TODO: strange that verification requires a pointer + if err != nil { + return err + } + co, ok := c.(*protosession.SessionToken_Body_Object) + if !ok { + return fmt.Errorf("wrong oneof context field type (client: %T, message: %T)", co, c) + } + oo := co.Object + // 1. verb + var expVerb session.ObjectVerb + actVerb := oo.GetVerb() + switch actVerb { + default: + expVerb = session.ObjectVerb(actVerb) + case protosession.ObjectSessionContext_PUT: + expVerb = session.VerbObjectPut + case protosession.ObjectSessionContext_GET: + expVerb = session.VerbObjectGet + case protosession.ObjectSessionContext_HEAD: + expVerb = session.VerbObjectHead + case protosession.ObjectSessionContext_SEARCH: + expVerb = session.VerbObjectSearch + case protosession.ObjectSessionContext_DELETE: + expVerb = session.VerbObjectDelete + case protosession.ObjectSessionContext_RANGE: + expVerb = session.VerbObjectRange + case protosession.ObjectSessionContext_RANGEHASH: + expVerb = session.VerbObjectRangeHash + } + if !t.AssertVerb(expVerb) { + return fmt.Errorf("wrong verb in the context field: %v", actVerb) + } + // 2. target + // 2.1. container + mtgt := oo.GetTarget() + mc := mtgt.GetContainer() + if mc == nil { + return errors.New("missing container in the context field") + } + var expCnr cid.ID + actCnr := mc.GetValue() + if copy(expCnr[:], actCnr); !t.AssertContainer(expCnr) { + return fmt.Errorf("wrong container in the context field: %x", actCnr) + } + // 2.2. objects + mo := mtgt.GetObjects() + var expObj oid.ID + for i := range mo { + actObj := mo[i].GetValue() + if copy(expObj[:], actObj); !t.AssertObject(expObj) { + return fmt.Errorf("wrong object #%d in the context field: %x", i, actObj) + } + } + // FIXME: t can have more objects, this is wrong but won't be detected. Full + // list should be accessible to verify. + return nil +} + +func checkBearerTokenTransport(b bearer.Token, m *protoacl.BearerToken) error { + body := m.GetBody() + if body == nil { + return errors.New("missing body field in the message") + } + // 1. eACL + me := body.GetEaclTable() + if me == nil { + return errors.New("missing eACL in the message") + } + if err := checkEACLTransport(b.EACLTable(), me); err != nil { + return fmt.Errorf("eACL field: %w", err) + } + // 2. owner + mo := body.GetOwnerId() + if mo == nil { + return errors.New("missing owner field") + } + var expUsr user.ID + actUsr := mo.GetValue() + if copy(expUsr[:], actUsr); !b.AssertUser(expUsr) { + return fmt.Errorf("wrong owner: %x", actUsr) + } + // 3. lifetime + lt := body.GetLifetime() + if v1, v2 := b.Exp(), lt.GetExp(); v1 != v2 { + return fmt.Errorf("exp lifetime field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := b.Nbf(), lt.GetNbf(); v1 != v2 { + return fmt.Errorf("nbf lifetime field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := b.Iat(), lt.GetIat(); v1 != v2 { + return fmt.Errorf("iat lifetime field (client: %d, message: %d)", v1, v2) + } + // 4. issuer + if err := checkUserIDTransport(b.Issuer(), body.GetIssuer()); err != nil { + return fmt.Errorf("issuer field: %w", err) + } + return nil +} + +func checkVersionTransport(v version.Version, m *protorefs.Version) error { + if v1, v2 := v.Major(), m.GetMajor(); v1 != v2 { + return fmt.Errorf("major field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := v.Minor(), m.GetMinor(); v1 != v2 { + return fmt.Errorf("minor field (client: %d, message: %d)", v1, v2) + } + return nil +} + +func checkBalanceTransport(b accounting.Decimal, m *protoaccounting.Decimal) error { + if v1, v2 := b.Value(), m.GetValue(); v1 != v2 { + return fmt.Errorf("value field (client: %d, message: %d)", v1, v2) + } + if v1, v2 := b.Precision(), m.GetPrecision(); v1 != v2 { + return fmt.Errorf("precision field (client: %d, message: %d)", v1, v2) + } + return nil +} + +func checkStoragePolicyFilterTransport(f netmap.Filter, m *protonetmap.Filter) error { + // 1. name + if v1, v2 := f.Name(), m.GetName(); v1 != v2 { + return fmt.Errorf("name (client: %q, message: %q)", v1, v2) + } + // 2. key + if v1, v2 := f.Key(), m.GetKey(); v1 != v2 { + return fmt.Errorf("key (client: %q, message: %q)", v1, v2) + } + // 3. op + var expOp protonetmap.Operation + switch op := f.Op(); op { + default: + expOp = protonetmap.Operation(op) + case netmap.FilterOpEQ: + expOp = protonetmap.Operation_EQ + case netmap.FilterOpNE: + expOp = protonetmap.Operation_NE + case netmap.FilterOpGT: + expOp = protonetmap.Operation_GT + case netmap.FilterOpGE: + expOp = protonetmap.Operation_GE + case netmap.FilterOpLT: + expOp = protonetmap.Operation_LT + case netmap.FilterOpLE: + expOp = protonetmap.Operation_LE + case netmap.FilterOpOR: + expOp = protonetmap.Operation_OR + case netmap.FilterOpAND: + expOp = protonetmap.Operation_AND + } + if actOp := m.GetOp(); actOp != expOp { + return fmt.Errorf("op (client: %v, message: %v)", expOp, actOp) + } + // 4. value + if v1, v2 := f.Value(), m.GetValue(); v1 != v2 { + return fmt.Errorf("value (client: %q, message: %q)", v1, v2) + } + // 5. sub-filters + cfs, mfs := f.SubFilters(), m.GetFilters() + if v1, v2 := len(cfs), len(mfs); v1 != v2 { + return fmt.Errorf("number of sub-filters (client: %d, message: %d)", v1, v2) + } + for i := range cfs { + if err := checkStoragePolicyFilterTransport(cfs[i], mfs[i]); err != nil { + return fmt.Errorf("sub-filter#%d: %w", i, err) + } + } + return nil +} + +func checkStoragePolicyTransport(p netmap.PlacementPolicy, m *protonetmap.PlacementPolicy) error { + // 1. replicas + crs, mrs := p.Replicas(), m.GetReplicas() + if v1, v2 := len(crs), len(mrs); v1 != v2 { + return fmt.Errorf("number of replicas (client: %d, message: %d)", v1, v2) + } + for i, cr := range crs { + mr := mrs[i] + if v1, v2 := cr.NumberOfObjects(), mr.GetCount(); v1 != v2 { + return fmt.Errorf("replica#%d field: object count (client: %d, message: %d)", i, v1, v2) + } + if v1, v2 := cr.SelectorName(), mr.GetSelector(); v1 != v2 { + return fmt.Errorf("replica#%d field: selector (client: %v, message: %v)", i, v1, v2) + } + } + // 2. backup factor + if v1, v2 := p.ContainerBackupFactor(), m.GetContainerBackupFactor(); v1 != v2 { + return fmt.Errorf("backup factor (client: %d, message: %d)", v1, v2) + } + // 3. selectors + css, mss := p.Selectors(), m.GetSelectors() + if v1, v2 := len(css), len(mss); v1 != v2 { + return fmt.Errorf("number of selectors (client: %d, message: %d)", v1, v2) + } + for i, cs := range css { + ms := mss[i] + // 1. name + if v1, v2 := cs.Name(), ms.GetName(); v1 != v2 { + return fmt.Errorf("selector#%d field: name (client: %q, message: %q)", i, v1, v2) + } + // 2. count + if v1, v2 := cs.NumberOfNodes(), ms.GetCount(); v1 != v2 { + return fmt.Errorf("selector#%d field: node count (client: %d, message: %d)", i, v1, v2) + } + // 3. clause + var expClause protonetmap.Clause + actClause := ms.GetClause() + switch { + default: + // TODO: better to define enum and expose the value + var pV2 apinetmap.PlacementPolicy + p.WriteToV2(&pV2) + expClause = pV2.ToGRPCMessage().(*protonetmap.PlacementPolicy).Selectors[i].Clause + case cs.IsSame(): + expClause = protonetmap.Clause_SAME + case cs.IsDistinct(): + expClause = protonetmap.Clause_DISTINCT + } + if actClause != expClause { + return fmt.Errorf("selector#%d field: clause (client: %v, message: %v)", i, expClause, actClause) + } + // 4. attribute + if v1, v2 := cs.BucketAttribute(), ms.GetAttribute(); v1 != v2 { + return fmt.Errorf("selector#%d field: attribute (client: %q, message: %q)", i, v1, v2) + } + // 5. filter + if v1, v2 := cs.FilterName(), ms.GetFilter(); v1 != v2 { + return fmt.Errorf("selector#%d field: filter (client: %q, message: %q)", i, v1, v2) + } + } + // filters + cfs, mfs := p.Filters(), m.GetFilters() + if v1, v2 := len(cfs), len(mfs); v1 != v2 { + return fmt.Errorf("number of filters (client: %d, message: %d)", v1, v2) + } + for i, mf := range mfs { + if err := checkStoragePolicyFilterTransport(cfs[i], mf); err != nil { + return fmt.Errorf("filter#%d field: %w", i, err) + } + } + return nil +} + +func checkContainerTransport(c container.Container, m *protocontainer.Container) error { + // 1. version + if err := checkVersionTransport(c.Version(), m.GetVersion()); err != nil { + return fmt.Errorf("version field: %w", err) + } + // 2. owner + if err := checkUserIDTransport(c.Owner(), m.GetOwnerId()); err != nil { + return fmt.Errorf("owner field: %w", err) + } + // 3. nonce + // inaccessible from container.Container // TODO: provide access + // 4. basic ACL + if v1, v2 := c.BasicACL().Bits(), m.GetBasicAcl(); v1 != v2 { + return fmt.Errorf("basic ACL field (client: %d, message: %d)", v1, v2) + } + // 5. attributes + var mas, usrAttrs [][2]string + var name, dmn, zone string + var disableHomoHash bool + var timestamp int64 + for _, ma := range m.GetAttributes() { + k, v := ma.GetKey(), ma.GetValue() + mas = append(mas, [2]string{k, v}) + switch k { + case "Name": + name = v + case "Timestamp": + var err error + if timestamp, err = strconv.ParseInt(v, 10, 64); err != nil { + return fmt.Errorf("invalid timestamp attribute value %q in the message: %w", v, err) + } + } + if tail, ok := strings.CutPrefix(k, "__NEOFS__"); ok { + switch tail { + case "NAME": + dmn = v + case "ZONE": + zone = v + case "DISABLE_HOMOMORPHIC_HASHING": + disableHomoHash = v == "true" + } + } else { + usrAttrs = append(usrAttrs, [2]string{k, v}) + } + } + var cas [][2]string + c.IterateAttributes(func(k, v string) { cas = append(cas, [2]string{k, v}) }) + if v1, v2 := len(cas), len(mas); v1 != v2 { + return fmt.Errorf("number of attributes (client: %d, message: %d)", v1, v2) + } + for i, ca := range cas { + if ma := mas[i]; ca != ma { + return fmt.Errorf("attribute #%d (client: %v, message: %v)", i, ca, ma) + } + } + if v1, v2 := c.Name(), name; v1 != v2 { + return fmt.Errorf("name attribute (client: %q, message: %q)", v1, v2) + } + if v1, v2 := c.IsHomomorphicHashingDisabled(), disableHomoHash; v2 != v1 { + return fmt.Errorf("homomorphic hashing flag attribute (client: %t, message: %t)", v1, v2) + } + if v1, v2 := c.CreatedAt().Unix(), timestamp; v2 != v1 { + return fmt.Errorf("timestamp attribute (client: %d, message: %d)", v1, v2) + } + if v1, v2 := c.ReadDomain().Name(), dmn; v1 != v2 { + return fmt.Errorf("domain name attribute (client: %q, message: %q)", v1, v2) + } + if zone == "" { + zone = "container" + } + if v1, v2 := c.ReadDomain().Zone(), zone; v2 != v1 { + return fmt.Errorf("domain zone attribute (client: %q, message: %q)", v1, v2) + } + // 6. policy + mp := m.GetPlacementPolicy() + if mp == nil { + return errors.New("missing storage policy field in the message") + } + if err := checkStoragePolicyTransport(c.PlacementPolicy(), mp); err != nil { + return fmt.Errorf("storage policy field: %w", err) + } + return nil +} + +func checkEACLFilterTransport(f eacl.Filter, m *protoacl.EACLRecord_Filter) error { + // 1. header type + var expHdr protoacl.HeaderType + switch ht := f.From(); ht { + default: + expHdr = protoacl.HeaderType(ht) + case eacl.HeaderFromRequest: + expHdr = protoacl.HeaderType_REQUEST + case eacl.HeaderFromObject: + expHdr = protoacl.HeaderType_OBJECT + case eacl.HeaderFromService: + expHdr = protoacl.HeaderType_SERVICE + } + if act := m.GetHeaderType(); act != expHdr { + return fmt.Errorf("header type (client: %v, message: %v)", act, expHdr) + } + // matcher + var expMatcher protoacl.MatchType + switch m := f.Matcher(); m { + default: + expMatcher = protoacl.MatchType(m) + case eacl.MatchStringEqual: + expMatcher = protoacl.MatchType_STRING_EQUAL + case eacl.MatchStringNotEqual: + expMatcher = protoacl.MatchType_STRING_NOT_EQUAL + case eacl.MatchNotPresent: + expMatcher = protoacl.MatchType_NOT_PRESENT + case eacl.MatchNumGT: + expMatcher = protoacl.MatchType_NUM_GT + case eacl.MatchNumGE: + expMatcher = protoacl.MatchType_NUM_GE + case eacl.MatchNumLT: + expMatcher = protoacl.MatchType_NUM_LT + case eacl.MatchNumLE: + expMatcher = protoacl.MatchType_NUM_LE + } + if act := m.GetMatchType(); act != expMatcher { + return fmt.Errorf("match type (client: %v, message: %v)", act, expMatcher) + } + // 4. key + if v1, v2 := f.Key(), m.GetKey(); v1 != v2 { + return fmt.Errorf("key field (client: %q, message: %q)", v1, v2) + } + // 4. value + if v1, v2 := f.Value(), m.GetValue(); v1 != v2 { + return fmt.Errorf("value field (client: %q, message: %q)", v1, v2) + } + return nil +} + +func checkEACLTargetTransport(t eacl.Target, m *protoacl.EACLRecord_Target) error { + // role + var expRole protoacl.Role + switch r := t.Role(); r { + default: + expRole = protoacl.Role(r) + case eacl.RoleUser: + expRole = protoacl.Role_USER + case eacl.RoleSystem: + expRole = protoacl.Role_SYSTEM + case eacl.RoleOthers: + expRole = protoacl.Role_OTHERS + } + if act := m.GetRole(); act != expRole { + return fmt.Errorf("role (client: %v, message: %v)", act, expRole) + } + // 2. subjects + cks, mks := t.RawSubjects(), m.GetKeys() + if v1, v2 := len(cks), len(mks); v1 != v2 { + return fmt.Errorf("number of subjects (client: %d, message: %d)", v1, v2) + } + for i := range cks { + if !bytes.Equal(cks[i], mks[i]) { + return fmt.Errorf("subject#%d (client: %x, message: %x)", i, cks[i], mks[i]) + } + } + return nil +} + +func checkEACLRecordTransport(r eacl.Record, m *protoacl.EACLRecord) error { + // 1. op + var expOp protoacl.Operation + switch op := r.Operation(); op { + default: + expOp = protoacl.Operation(op) + case eacl.OperationGet: + expOp = protoacl.Operation_GET + case eacl.OperationHead: + expOp = protoacl.Operation_HEAD + case eacl.OperationPut: + expOp = protoacl.Operation_PUT + case eacl.OperationDelete: + expOp = protoacl.Operation_DELETE + case eacl.OperationSearch: + expOp = protoacl.Operation_SEARCH + case eacl.OperationRange: + expOp = protoacl.Operation_GETRANGE + case eacl.OperationRangeHash: + expOp = protoacl.Operation_GETRANGEHASH + } + if act := m.GetOperation(); act != expOp { + return fmt.Errorf("op (client: %v, message: %v)", act, expOp) + } + // 2. action + var expAction protoacl.Action + switch a := r.Action(); a { + default: + expAction = protoacl.Action(a) + case eacl.ActionAllow: + expAction = protoacl.Action_ALLOW + case eacl.ActionDeny: + expAction = protoacl.Action_DENY + } + if act := m.GetAction(); act != expAction { + return fmt.Errorf("action (client: %v, message: %v)", act, expAction) + } + // 3. filters + mfs, cfs := m.GetFilters(), r.Filters() + if v1, v2 := len(cfs), len(mfs); v1 != v2 { + return fmt.Errorf("number of filters (client: %d, message: %d)", v1, v2) + } + for i := range cfs { + if err := checkEACLFilterTransport(cfs[i], mfs[i]); err != nil { + return fmt.Errorf("filter#%d field: %w", i, err) + } + } + // 4. targets + mts, cts := m.GetTargets(), r.Targets() + if v1, v2 := len(cfs), len(mfs); v1 != v2 { + return fmt.Errorf("number of targets (client: %d, message: %d)", v1, v2) + } + for i := range mts { + if err := checkEACLTargetTransport(cts[i], mts[i]); err != nil { + return fmt.Errorf("target#%d field: %w", i, err) + } + } + return nil +} + +func checkEACLTransport(e eacl.Table, m *protoacl.EACLTable) error { + // 1. version + if err := checkVersionTransport(e.Version(), m.GetVersion()); err != nil { + return fmt.Errorf("version field: %w", err) + } + // 2. container ID + mc := m.GetContainerId() + if c := e.GetCID(); c.IsZero() { + if mc != nil { + return errors.New("container ID field is set while should not be") + } + } else { + if mc == nil { + return errors.New("missing container ID field") + } + if err := checkContainerIDTransport(c, mc); err != nil { + return fmt.Errorf("container ID field: %w", err) + } + } + // 3. records + mrs, crs := m.GetRecords(), e.Records() + if v1, v2 := len(crs), len(mrs); v1 != v2 { + return fmt.Errorf("number of records (client: %d, message: %d)", v1, v2) + } + for i := range mrs { + if err := checkEACLRecordTransport(crs[i], mrs[i]); err != nil { + return fmt.Errorf("record#%d field: %w", i, err) + } + } + return nil +} + +func checkContainerSizeEstimationTransport(e container.SizeEstimation, m *protocontainer.AnnounceUsedSpaceRequest_Body_Announcement) error { + // 1. epoch + if v1, v2 := e.Epoch(), m.GetEpoch(); v1 != v2 { + return fmt.Errorf("epoch field (client: %d, message: %d)", v1, v2) + } + // 1. container ID + mc := m.GetContainerId() + if mc == nil { + return newErrMissingRequestBodyField("container ID") + } + if err := checkContainerIDTransport(e.Container(), mc); err != nil { + return fmt.Errorf("container ID field: %w", err) + } + // 3. value + if v1, v2 := e.Value(), m.GetUsedSpace(); v1 != v2 { + return fmt.Errorf("value field (client: %d, message: %d)", v1, v2) + } + return nil +} + +func checkNodeInfoTransport(n netmap.NodeInfo, m *protonetmap.NodeInfo) error { + // 1. public key + if v1, v2 := n.PublicKey(), m.GetPublicKey(); !bytes.Equal(v1, v2) { + return fmt.Errorf("public key field (client: %x, message: %x)", v1, v2) + } + // 2. addresses + maddrs := m.GetAddresses() + var caddrs []string + netmap.IterateNetworkEndpoints(n, func(e string) { caddrs = append(caddrs, e) }) + if v1, v2 := len(caddrs), len(maddrs); v1 != v2 { + return fmt.Errorf("number of addresses (client: %d, message: %d)", v1, v2) + } + for i := range caddrs { + if v1, v2 := caddrs[i], maddrs[i]; v1 != v2 { + return fmt.Errorf("name (client: %q, message: %q)", v1, v2) + } + } + // 3. attributes + attrs, mattrs := n.GetAttributes(), m.GetAttributes() + if v1, v2 := len(attrs), len(mattrs); v1 != v2 { + return fmt.Errorf("number of attributes (client: %d, message: %d)", v1, v2) + } + for i, ma := range mattrs { + a := attrs[i] + if v1, v2 := a[0], ma.GetKey(); v1 != v2 { + return fmt.Errorf("attribute#%d field: key (client: %q, message: %q)", i, v1, v2) + } + if v1, v2 := a[1], ma.GetValue(); v1 != v2 { + return fmt.Errorf("attribute#%d field: value (client: %q, message: %q)", i, v1, v2) + } + if len(ma.GetParents()) > 0 { + return fmt.Errorf("attribute#%d field: parents field is set while should not be", i) + } + } + // 4. state + var expState protonetmap.NodeInfo_State + switch { + default: + // TODO: better to define enum and expose the value + var pV2 apinetmap.NodeInfo + n.WriteToV2(&pV2) + expState = pV2.ToGRPCMessage().(*protonetmap.NodeInfo).State + case n.IsOnline(): + expState = protonetmap.NodeInfo_ONLINE + case n.IsOffline(): + expState = protonetmap.NodeInfo_OFFLINE + case n.IsMaintenance(): + expState = protonetmap.NodeInfo_MAINTENANCE + } + if act := m.GetState(); act != expState { + return fmt.Errorf("state field (client: %v, message: %v)", expState, act) + } + return nil +} + +func checkNetmapTransport(n netmap.NetMap, m *protonetmap.Netmap) error { + // 1. epoch + if v1, v2 := n.Epoch(), m.GetEpoch(); v1 != v2 { + return fmt.Errorf("epoch field (client: %d, message: %d)", v1, v2) + } + // 2. nodes + cns, mns := n.Nodes(), m.GetNodes() + if v1, v2 := len(cns), len(mns); v1 != v2 { + return fmt.Errorf("number of nodes (client: %d, message: %d)", v1, v2) + } + for i := range cns { + if err := checkNodeInfoTransport(cns[i], mns[i]); err != nil { + return fmt.Errorf("node#%d field: %w", i, err) + } + } + return nil +} + +func checkNetInfoTransport(n netmap.NetworkInfo, m *protonetmap.NetworkInfo) error { + // 1. current epoch + if v1, v2 := n.CurrentEpoch(), m.GetCurrentEpoch(); v1 != v2 { + return fmt.Errorf("current epoch field (client: %d, message: %d)", v1, v2) + } + // 2. magic + if v1, v2 := n.MagicNumber(), m.GetMagicNumber(); v1 != v2 { + return fmt.Errorf("network magic field (client: %d, message: %d)", v1, v2) + } + // 3. ms per block + if v1, v2 := n.MsPerBlock(), m.GetMsPerBlock(); v1 != v2 { + return fmt.Errorf("ms per block field (client: %d, message: %d)", v1, v2) + } + // 4. config + mps := m.GetNetworkConfig().GetParameters() + var cps [][2]any + n.IterateRawNetworkParameters(func(name string, value []byte) { cps = append(cps, [2]any{name, value}) }) + if v1, v2 := len(cps), len(mps); v1 != v2 { + return fmt.Errorf("number of config parameters (client: %d, message: %d)", v1, v2) + } + + var auditFee, storagePrice, cnrDmnFee, cnrFee, etIters, epochDur, irFee, maxObjSize, withdrawFee uint64 + var etAlpha float64 + var homoHashDisabled, maintenanceAllowed bool + for _, mp := range mps { + k, v := mp.GetKey(), mp.GetValue() + switch string(k) { + case "AuditFee": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + auditFee = binary.LittleEndian.Uint64(v) + case "BasicIncomeRate": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + storagePrice = binary.LittleEndian.Uint64(v) + case "ContainerAliasFee": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + cnrDmnFee = binary.LittleEndian.Uint64(v) + case "ContainerFee": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + cnrFee = binary.LittleEndian.Uint64(v) + case "EigenTrustAlpha": + var err error + if etAlpha, err = strconv.ParseFloat(string(v), 64); err != nil { + return fmt.Errorf("invalid parameter %q value %q: %w", k, v, err) + } + case "EigenTrustIterations": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + etIters = binary.LittleEndian.Uint64(v) + case "EpochDuration": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + epochDur = binary.LittleEndian.Uint64(v) + case "HomomorphicHashingDisabled": + for _, b := range v { + if homoHashDisabled = b != 0; homoHashDisabled { + break + } + } + case "InnerRingCandidateFee": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + irFee = binary.LittleEndian.Uint64(v) + case "MaintenanceModeAllowed": + for _, b := range v { + if maintenanceAllowed = b != 0; maintenanceAllowed { + break + } + } + case "MaxObjectSize": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + maxObjSize = binary.LittleEndian.Uint64(v) + case "WithdrawFee": + if l := len(v); l < 8 { + return fmt.Errorf("too short parameter %q value: %d bytes", k, l) + } + withdrawFee = binary.LittleEndian.Uint64(v) + } + } + if v1, v2 := n.AuditFee(), auditFee; v1 != v2 { + return fmt.Errorf("audit fee parameter (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.StoragePrice(), storagePrice; v1 != v2 { + return fmt.Errorf("storage price parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.ContainerFee(), cnrFee; v1 != v2 { + return fmt.Errorf("container fee parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.NamedContainerFee(), cnrDmnFee; v1 != v2 { + return fmt.Errorf("container domain fee parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.NumberOfEigenTrustIterations(), etIters; v1 != v2 { + return fmt.Errorf("number of Eigen-Trust iterations parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.EigenTrustAlpha(), etAlpha; v1 != v2 { + return fmt.Errorf("Eigen-Trust alpha parameter value (client: %v, message: %v)", v1, v2) + } + if v1, v2 := n.EpochDuration(), epochDur; v1 != v2 { + return fmt.Errorf("epoch duration parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.IRCandidateFee(), irFee; v1 != v2 { + return fmt.Errorf("IR candidate fee parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.MaintenanceModeAllowed(), maintenanceAllowed; v1 != v2 { + return fmt.Errorf("maintenance mode allowance parameter value (client: %t, message: %t)", v1, v2) + } + if v1, v2 := n.MaxObjectSize(), maxObjSize; v1 != v2 { + return fmt.Errorf("max object size parameter value (client: %d, message: %d)", v1, v2) + } + if v1, v2 := n.WithdrawalFee(), withdrawFee; v1 != v2 { + return fmt.Errorf("withdrawal fee parameter value (client: %d, message: %d)", v1, v2) + } + return nil +} + +func checkReputationPeerTransport(p reputation.PeerID, m *protoreputation.PeerID) error { + if m == nil { + return errors.New("missing peer field") + } + if v1, v2 := p.PublicKey(), m.GetPublicKey(); !bytes.Equal(v1, v2) { + return fmt.Errorf("peer field (client: %x, message: %x)", v1, v2) + } + return nil +} + +func checkTrustTransport(t reputation.Trust, m *protoreputation.Trust) error { + if err := checkReputationPeerTransport(t.Peer(), m.GetPeer()); err != nil { + return fmt.Errorf("peer field: %w", err) + } + if v1, v2 := t.Value(), m.GetValue(); v1 != v2 { + return fmt.Errorf("value field (client: %v, message: %v)", v1, v2) + } + return nil +} + +func checkP2PTrustTransport(t reputation.PeerToPeerTrust, m *protoreputation.PeerToPeerTrust) error { + if err := checkReputationPeerTransport(t.TrustingPeer(), m.GetTrustingPeer()); err != nil { + return fmt.Errorf("trusting peer field: %w", err) + } + if err := checkTrustTransport(t.Trust(), m.GetTrust()); err != nil { + return fmt.Errorf("trust field: %w", err) + } + return nil +} + +func checkHashTransport(c checksum.Checksum, m *protorefs.Checksum) error { + var expType protorefs.ChecksumType + switch typ := c.Type(); typ { + default: + expType = protorefs.ChecksumType(typ) + case checksum.SHA256: + expType = protorefs.ChecksumType_SHA256 + case checksum.TillichZemor: + expType = protorefs.ChecksumType_TZ + } + if actType := m.GetType(); actType != expType { + return fmt.Errorf("type field (client: %v, message %v)", expType, actType) + } + if v1, v2 := c.Value(), m.GetSum(); !bytes.Equal(v1, v2) { + return fmt.Errorf("value field (client: %x, message %x)", v1, v2) + } + return nil +} + +func checkObjectHeaderWithSignatureTransport(o object.Object, m *protoobject.HeaderWithSignature) error { + if err := checkObjectHeaderTransport(o, m.GetHeader()); err != nil { + return fmt.Errorf("header field: %w", err) + } + s := o.Signature() + ms := m.GetSignature() + if s != nil { + if ms == nil { + return errors.New("missing signature field") + } + if err := checkSignatureTransport(*s, ms); err != nil { + return fmt.Errorf("signature field: %w", err) + } + } else { + if ms != nil { + return errors.New("signature field is set while should not be") + } + } + return nil +} + +func checkObjectHeaderTransport(h object.Object, m *protoobject.Header) error { + // 1. version + ver := h.Version() + if ver != nil { + if err := checkVersionTransport(*ver, m.GetVersion()); err != nil { + return fmt.Errorf("version field: %w", err) + } + } else { + if m.GetVersion() != nil { + return errors.New("version field is set while should not") + } + } + // 2. container + cnr := h.GetContainerID() + if cnr.IsZero() { + if m.GetContainerId() != nil { + return errors.New("container field is set while should not") + } + } else { + if err := checkContainerIDTransport(cnr, m.GetContainerId()); err != nil { + return fmt.Errorf("container field: %w", err) + } + } + // 3. owner + ownr := h.Owner() + if ownr.IsZero() { + if m.GetOwnerId() != nil { + return errors.New("owner field is set while should not be") + } + } else { + if err := checkUserIDTransport(ownr, m.GetOwnerId()); err != nil { + return fmt.Errorf("owner field: %w", err) + } + } + // 4. creation epoch + if v1, v2 := h.CreationEpoch(), m.GetCreationEpoch(); v1 != v2 { + return fmt.Errorf("creation epoch field (client: %d, message: %d)", v1, v2) + } + // 5. payload length + if v1, v2 := h.PayloadSize(), m.GetPayloadLength(); v1 != v2 { + return fmt.Errorf("payload length field (client: %d, message: %d)", v1, v2) + } + // 6. payload checksum + cs, ok := h.PayloadChecksum() + mcs := m.GetPayloadHash() + if ok { + if mcs == nil { + return errors.New("missing payload checksum field") + } + if err := checkHashTransport(cs, mcs); err != nil { + return fmt.Errorf("payload checksum field: %w", err) + } + } else { + if mcs != nil { + return errors.New("payload checksum field is set while should not be") + } + } + // 7. type + var expType protoobject.ObjectType + switch typ := h.Type(); typ { + default: + expType = protoobject.ObjectType(typ) + case object.TypeRegular: + expType = protoobject.ObjectType_REGULAR + case object.TypeTombstone: + expType = protoobject.ObjectType_TOMBSTONE + case object.TypeStorageGroup: + expType = protoobject.ObjectType_STORAGE_GROUP + case object.TypeLock: + expType = protoobject.ObjectType_LOCK + case object.TypeLink: + expType = protoobject.ObjectType_LINK + } + if actType := m.GetObjectType(); actType != expType { + return fmt.Errorf("type field (client: %v, message %v)", expType, actType) + } + // 8. payload homomorphic checksum + cs, ok = h.PayloadHomomorphicHash() + mcs = m.GetHomomorphicHash() + if ok { + if mcs == nil { + return errors.New("missing payload homomorphic checksum field") + } + if err := checkHashTransport(cs, mcs); err != nil { + return fmt.Errorf("payload homomorphic checksum field: %w", err) + } + } else { + if mcs != nil { + return errors.New("payload homomorphic checksum field is set while should not be") + } + } + // 9. session token + st := h.SessionToken() + mst := m.GetSessionToken() + if st != nil { + if mst == nil { + return errors.New("missing session token field") + } + if err := checkObjectSessionTransport(*st, mst); err != nil { + return fmt.Errorf("session token field: %w", err) + } + } else { + if mst != nil { + return errors.New("session token field is set while should not be") + } + } + // 10. attributes + as := h.Attributes() + mas := m.GetAttributes() + if v1, v2 := len(as), len(mas); v1 != v2 { + return fmt.Errorf("number of attributes (client: %d, message: %d)", v1, v2) + } + for i := range as { + if v1, v2 := as[i].Key(), mas[i].GetKey(); v1 != v2 { + return fmt.Errorf("attribute#%d: key (client: %q, message: %q)", i, v1, v2) + } + if v1, v2 := as[i].Value(), mas[i].GetValue(); v1 != v2 { + return fmt.Errorf("attribute#%d: value (client: %q, message: %q)", i, v1, v2) + } + } + // 11. split + parID := h.GetParentID() + prev := h.GetPreviousID() + first := h.GetFirstID() + children := h.Children() + parHdr := h.Parent() + splitID := h.SplitID() + sh := m.GetSplit() + if parID.IsZero() && parHdr == nil && prev.IsZero() && first.IsZero() && len(children) == 0 && splitID == nil { + if sh != nil { + return errors.New("split header field is set while should not be") + } + } else { + if err := checkObjectSplitTransport(parID, prev, parHdr, children, splitID, first, sh); err != nil { + return fmt.Errorf("split header field: %w", err) + } + } + return nil +} + +func checkObjectSplitTransport(parID oid.ID, prev oid.ID, parHdrSig *object.Object, children []oid.ID, + splitID *object.SplitID, first oid.ID, m *protoobject.Header_Split) error { + // 1. parent ID + mid := m.GetParent() + if parID.IsZero() { + if mid != nil { + return errors.New("parent ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing parent ID field") + } + if err := checkObjectIDTransport(parID, mid); err != nil { + return fmt.Errorf("parent ID field: %w", err) + } + } + // 2. previous ID + mid = m.GetPrevious() + if prev.IsZero() { + if mid != nil { + return errors.New("previous ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing previous ID field") + } + if err := checkObjectIDTransport(prev, mid); err != nil { + return fmt.Errorf("previous ID field: %w", err) + } + } + // 3,4. parent signature, header + mph := m.GetParentHeader() + mps := m.GetParentSignature() + if parHdrSig != nil { + if mph == nil && mps == nil { + return errors.New("missing both parent header and signature") + } + if mph != nil { + if err := checkObjectHeaderTransport(*parHdrSig, mph); err != nil { + return fmt.Errorf("parent header field: %w", err) + } + } + if ps := parHdrSig.Signature(); ps != nil { + if mps == nil { + return errors.New("missing parent header field") + } + if err := checkSignatureTransport(*ps, mps); err != nil { + return fmt.Errorf("parent signature field: %w", err) + } + } else { + if mps != nil { + return errors.New("parent signature field is set while should not be") + } + } + } else { + if mph != nil { + return errors.New("parent header field is set while should not be") + } + if mps != nil { + return errors.New("parent signature field is set while should not be") + } + } + // 5. children + mc := m.GetChildren() + if v1, v2 := len(children), len(mc); v1 != v2 { + return fmt.Errorf("number of children (client: %d, message: %d)", v1, v2) + } + for i := range children { + if mc[i] == nil { + return fmt.Errorf("children field: nil element #%d", i) + } + if err := checkObjectIDTransport(children[i], mc[i]); err != nil { + return fmt.Errorf("children field: child#%d: %w", i, err) + } + } + // 6. split ID + actSplitID := m.GetSplitId() + if splitID != nil { + if expSplitID := splitID.ToV2(); !bytes.Equal(actSplitID, expSplitID) { + return fmt.Errorf("split ID field (client: %x, message: %x)", expSplitID, actSplitID) + } + } else { + if len(actSplitID) > 0 { + return errors.New("split ID field is set while should not be") + } + } + // 7. first ID + mid = m.GetFirst() + if first.IsZero() { + if mid != nil { + return errors.New("first ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing first ID field") + } + if err := checkObjectIDTransport(first, mid); err != nil { + return fmt.Errorf("first ID field: %w", err) + } + } + return nil +} + +func checkSplitInfoTransport(s object.SplitInfo, m *protoobject.SplitInfo) error { + // 1. split ID + splitID := s.SplitID() + actSplitID := m.GetSplitId() + if splitID != nil { + if expSplitID := splitID.ToV2(); !bytes.Equal(actSplitID, expSplitID) { + return fmt.Errorf("split ID field (client: %x, message: %x)", expSplitID, actSplitID) + } + } else { + if len(actSplitID) > 0 { + return errors.New("split ID field is set while should not be") + } + } + // 2. last ID + id := s.GetLastPart() + mid := m.GetLastPart() + if id.IsZero() { + if mid != nil { + return errors.New("last ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing last ID field") + } + if err := checkObjectIDTransport(id, mid); err != nil { + return fmt.Errorf("last ID field: %w", err) + } + } + // 3. linker + id = s.GetLink() + mid = m.GetLink() + if id.IsZero() { + if mid != nil { + return errors.New("linker ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing linker ID field") + } + if err := checkObjectIDTransport(id, mid); err != nil { + return fmt.Errorf("linker ID field: %w", err) + } + } + // 4. first part + id = s.GetFirstPart() + mid = m.GetFirstPart() + if id.IsZero() { + if mid != nil { + return errors.New("first ID field is set while should not be") + } + } else { + if mid == nil { + return errors.New("missing first ID field") + } + if err := checkObjectIDTransport(id, mid); err != nil { + return fmt.Errorf("first ID field: %w", err) + } + } + return nil +} diff --git a/client/netmap_test.go b/client/netmap_test.go index bf01100c..e46a9a2c 100644 --- a/client/netmap_test.go +++ b/client/netmap_test.go @@ -8,128 +8,200 @@ import ( v2netmap "github.com/nspcc-dev/neofs-api-go/v2/netmap" protonetmap "github.com/nspcc-dev/neofs-api-go/v2/netmap/grpc" - protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" - protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" - protostatus "github.com/nspcc-dev/neofs-api-go/v2/status/grpc" - apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" - neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" - "github.com/nspcc-dev/neofs-sdk-go/netmap" + "github.com/nspcc-dev/neofs-sdk-go/stat" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) -func newDefaultNetmapServiceDesc(srv protonetmap.NetmapServiceServer) testService { +// various sets of Netmap service testcases. +var ( + invalidNodeInfoProtoTestcases = []struct { + name, msg string + corrupt func(valid *protonetmap.NodeInfo) + }{ + {name: "public key/nil", msg: "missing public key", corrupt: func(valid *protonetmap.NodeInfo) { + valid.PublicKey = nil + }}, + {name: "public key/empty", msg: "missing public key", corrupt: func(valid *protonetmap.NodeInfo) { + valid.PublicKey = []byte{} + }}, + {name: "addresses/nil", msg: "missing network endpoints", corrupt: func(valid *protonetmap.NodeInfo) { + valid.Addresses = nil + }}, + {name: "addresses/empty", msg: "missing network endpoints", corrupt: func(valid *protonetmap.NodeInfo) { + valid.Addresses = nil + }}, + {name: "attributes/no key", msg: "empty key of the attribute #1", corrupt: func(valid *protonetmap.NodeInfo) { + valid.Attributes = []*protonetmap.NodeInfo_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "", Value: "v2"}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "attributes/no value", msg: "empty value of the attribute k2", corrupt: func(valid *protonetmap.NodeInfo) { + valid.Attributes = []*protonetmap.NodeInfo_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "k2", Value: ""}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "attributes/capacity", msg: "invalid Capacity attribute: strconv.ParseUint: parsing \"foo\": invalid syntax", + corrupt: func(valid *protonetmap.NodeInfo) { + valid.Attributes = []*protonetmap.NodeInfo_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "Capacity", Value: "foo"}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "attributes/price", msg: "invalid Price attribute: strconv.ParseUint: parsing \"foo\": invalid syntax", + corrupt: func(valid *protonetmap.NodeInfo) { + valid.Attributes = []*protonetmap.NodeInfo_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "Price", Value: "foo"}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "state/negative", msg: "negative state", + corrupt: func(valid *protonetmap.NodeInfo) { + valid.State = -1 + }}, + } +) + +// returns Client-compatible Netmap service handled by given server. Provided +// server must implement [protocontainer.NetmapServiceServer]: the parameter is +// not of this type to support generics. +func newDefaultNetmapServiceDesc(t testing.TB, srv any) testService { + require.Implements(t, (*protonetmap.NetmapServiceServer)(nil), srv) return testService{desc: &protonetmap.NetmapService_ServiceDesc, impl: srv} } -// returns Client of Netmap service provided by given server. -func newTestNetmapClient(t testing.TB, srv protonetmap.NetmapServiceServer) *Client { - return newClient(t, newDefaultNetmapServiceDesc(srv)) +// returns Client of Netmap service provided by given server. Provided server +// must implement [protonetmap.NetmapServiceServer]: the parameter is not of +// this type to support generics. +func newTestNetmapClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultNetmapServiceDesc(t, srv)) } type testNetmapSnapshotServer struct { protonetmap.UnimplementedNetmapServiceServer + testCommonServerSettings[ + *protonetmap.NetmapSnapshotRequest_Body, + v2netmap.SnapshotRequestBody, + *v2netmap.SnapshotRequestBody, + *protonetmap.NetmapSnapshotRequest, + v2netmap.SnapshotRequest, + *v2netmap.SnapshotRequest, + *protonetmap.NetmapSnapshotResponse_Body, + protonetmap.NetmapSnapshotResponse, + v2netmap.SnapshotResponse, + *v2netmap.SnapshotResponse, + ] +} - errTransport error - - unsignedResponse bool - - statusFail bool +// returns [protonetmap.NetmapServiceServer] supporting NetmapSnapshot method +// only. Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestNetmapSnapshotServer() *testNetmapSnapshotServer { return new(testNetmapSnapshotServer) } - unsetNetMap bool - netMap *protonetmap.Netmap +// makes the server to always respond with the given network map. By default, +// any valid network map is returned. +// +// Overrides with respondWithBody. - signer neofscrypto.Signer +func (x *testNetmapSnapshotServer) verifyRequest(req *protonetmap.NetmapSnapshotRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + return nil } func (x *testNetmapSnapshotServer) NetmapSnapshot(_ context.Context, req *protonetmap.NetmapSnapshotRequest) (*protonetmap.NetmapSnapshotResponse, error) { - var reqV2 v2netmap.SnapshotRequest - if err := reqV2.FromGRPCMessage(req); err != nil { - panic(err) - } - - err := verifyServiceMessage(&reqV2) - if err != nil { + if err := x.verifyRequest(req); err != nil { return nil, err } - if x.errTransport != nil { - return nil, x.errTransport - } - - var nm *protonetmap.Netmap - if !x.unsetNetMap { - if x.netMap != nil { - nm = x.netMap - } else { - nm = new(protonetmap.Netmap) - } - } resp := protonetmap.NetmapSnapshotResponse{ - Body: &protonetmap.NetmapSnapshotResponse_Body{ - Netmap: nm, - }, - } - if x.statusFail { - resp.MetaHeader = &protosession.ResponseMetaHeader{ - Status: statusErr.ErrorToV2().ToGRPCMessage().(*protostatus.Status), - } - } - - var respV2 v2netmap.SnapshotResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) - } - signer := x.signer - if signer == nil { - signer = neofscryptotest.Signer() + MetaHeader: x.respMeta, } - if !x.unsignedResponse { - err = signServiceMessage(signer, &respV2, nil) - if err != nil { - panic(fmt.Sprintf("sign response: %v", err)) - } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinNetmapResponseBody).(*protonetmap.NetmapSnapshotResponse_Body) } - return respV2.ToGRPCMessage().(*protonetmap.NetmapSnapshotResponse), nil + return x.signResponse(&resp) } type testGetNetworkInfoServer struct { protonetmap.UnimplementedNetmapServiceServer + testCommonServerSettings[ + *protonetmap.NetworkInfoRequest_Body, + v2netmap.NetworkInfoRequestBody, + *v2netmap.NetworkInfoRequestBody, + *protonetmap.NetworkInfoRequest, + v2netmap.NetworkInfoRequest, + *v2netmap.NetworkInfoRequest, + *protonetmap.NetworkInfoResponse_Body, + protonetmap.NetworkInfoResponse, + v2netmap.NetworkInfoResponse, + *v2netmap.NetworkInfoResponse, + ] } -func (x *testGetNetworkInfoServer) NetworkInfo(context.Context, *protonetmap.NetworkInfoRequest) (*protonetmap.NetworkInfoResponse, error) { - resp := protonetmap.NetworkInfoResponse{ - Body: &protonetmap.NetworkInfoResponse_Body{ - NetworkInfo: &protonetmap.NetworkInfo{ - NetworkConfig: &protonetmap.NetworkConfig{ - Parameters: []*protonetmap.NetworkConfig_Parameter{ - {Value: []byte("any")}, - }, - }, - }, - }, +// returns [protonetmap.NetmapServiceServer] supporting NetworkInfo method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestNetworkInfoServer() *testGetNetworkInfoServer { return new(testGetNetworkInfoServer) } + +func (x *testGetNetworkInfoServer) verifyRequest(req *protonetmap.NetworkInfoRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + return nil +} + +func (x *testGetNetworkInfoServer) NetworkInfo(_ context.Context, req *protonetmap.NetworkInfoRequest) (*protonetmap.NetworkInfoResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - var respV2 v2netmap.NetworkInfoResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) + resp := protonetmap.NetworkInfoResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinNetInfoResponseBody).(*protonetmap.NetworkInfoResponse_Body) } - return respV2.ToGRPCMessage().(*protonetmap.NetworkInfoResponse), nil + return x.signResponse(&resp) } type testGetNodeInfoServer struct { protonetmap.UnimplementedNetmapServiceServer - - respSigner neofscrypto.Signer - respMeta *protosession.ResponseMetaHeader - respNodePub []byte + testCommonServerSettings[ + *protonetmap.LocalNodeInfoRequest_Body, + v2netmap.LocalNodeInfoRequestBody, + *v2netmap.LocalNodeInfoRequestBody, + *protonetmap.LocalNodeInfoRequest, + v2netmap.LocalNodeInfoRequest, + *v2netmap.LocalNodeInfoRequest, + *protonetmap.LocalNodeInfoResponse_Body, + protonetmap.LocalNodeInfoResponse, + v2netmap.LocalNodeInfoResponse, + *v2netmap.LocalNodeInfoResponse, + ] } // returns [protonetmap.NetmapServiceServer] supporting LocalNodeInfo method @@ -137,127 +209,479 @@ type testGetNodeInfoServer struct { // responds with any valid message. Some methods allow to tune the behavior. func newTestGetNodeInfoServer() *testGetNodeInfoServer { return new(testGetNodeInfoServer) } -// makes the server to always sign responses using given signer. By default, -// random signer is used. -func (x *testGetNodeInfoServer) signResponsesBy(signer neofscrypto.Signer) { - x.respSigner = signer +// makes the server to always respond with the given node public key. By +// default, any valid key is returned. +// +// Overrides respondWithBody. +func (x *testGetNodeInfoServer) respondWithNodePublicKey(pub []byte) { + b := proto.Clone(validMinNodeInfoResponseBody).(*protonetmap.LocalNodeInfoResponse_Body) + b.NodeInfo.PublicKey = pub + x.respondWithBody(b) } -// makes the server to always respond with the given meta header. By default, -// empty header is returned. -func (x *testGetNodeInfoServer) respondWithMeta(meta *protosession.ResponseMetaHeader) { - x.respMeta = meta +func (x *testGetNodeInfoServer) verifyRequest(req *protonetmap.LocalNodeInfoRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + return nil } -// makes the server to always respond with the given node public key. By default, -// any key is returned. -func (x *testGetNodeInfoServer) respondWithNodePublicKey(pub []byte) { - x.respNodePub = pub -} +func (x *testGetNodeInfoServer) LocalNodeInfo(_ context.Context, req *protonetmap.LocalNodeInfoRequest) (*protonetmap.LocalNodeInfoResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } -func (x *testGetNodeInfoServer) LocalNodeInfo(context.Context, *protonetmap.LocalNodeInfoRequest) (*protonetmap.LocalNodeInfoResponse, error) { resp := protonetmap.LocalNodeInfoResponse{ - Body: &protonetmap.LocalNodeInfoResponse_Body{ - Version: new(protorefs.Version), - NodeInfo: &protonetmap.NodeInfo{ - Addresses: []string{"any"}, - }, - }, MetaHeader: x.respMeta, } - if x.respNodePub != nil { - resp.Body.NodeInfo.PublicKey = x.respNodePub + if x.respBodyForced { + resp.Body = x.respBody } else { - resp.Body.NodeInfo.PublicKey = []byte("any") + resp.Body = proto.Clone(validMinNodeInfoResponseBody).(*protonetmap.LocalNodeInfoResponse_Body) } - var respV2 v2netmap.LocalNodeInfoResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) - } - signer := x.respSigner - if signer == nil { - signer = neofscryptotest.Signer() - } - if err := signServiceMessage(signer, &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) - } + return x.signResponse(&resp) +} - return respV2.ToGRPCMessage().(*protonetmap.LocalNodeInfoResponse), nil +func TestClient_EndpointInfo(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmEndpointInfo + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestGetNodeInfoServer() + c := newTestNetmapClient(t, srv) + + srv.authenticateRequest(c.prm.signer) + _, err := c.EndpointInfo(ctx, PrmEndpointInfo{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestGetNodeInfoServer, newTestNetmapClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.EndpointInfo(ctx, opts) + return err + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protonetmap.LocalNodeInfoResponse_Body + }{ + {name: "min", body: validMinNodeInfoResponseBody}, + {name: "full", body: validFullNodeInfoResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestGetNodeInfoServer() + c := newTestNetmapClient(t, srv) + + srv.respondWithBody(tc.body) + res, err := c.EndpointInfo(ctx, anyValidOpts) + require.NoError(t, err) + require.NotNil(t, res) + require.NoError(t, checkVersionTransport(res.LatestVersion(), tc.body.GetVersion())) + require.NoError(t, checkNodeInfoTransport(res.NodeInfo(), tc.body.GetNodeInfo())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestGetNodeInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "netmap.NetmapService", "LocalNodeInfo", func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestGetNodeInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protonetmap.LocalNodeInfoResponse_Body] + tcs := []testcase{{name: "missing", body: nil, assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing version field in the response") + // TODO: worth clarifying that body is completely missing + }}} + + type corruptedBodyTestcase = struct { + name string + corrupt func(valid *protonetmap.LocalNodeInfoResponse_Body) + assertErr func(testing.TB, error) + } + // missing fields + ctcs := []corruptedBodyTestcase{ + {name: "version/missing", corrupt: func(valid *protonetmap.LocalNodeInfoResponse_Body) { valid.Version = nil }, + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing version field in the response") + }}, + {name: "node info/missing", corrupt: func(valid *protonetmap.LocalNodeInfoResponse_Body) { valid.NodeInfo = nil }, + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing node info field in the response") + }}, + } + // invalid node info + for _, tc := range invalidNodeInfoProtoTestcases { + ctcs = append(ctcs, corruptedBodyTestcase{ + name: "node info/" + tc.name, + corrupt: func(valid *protonetmap.LocalNodeInfoResponse_Body) { tc.corrupt(valid.NodeInfo) }, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid node info field in the response: "+tc.msg) + }, + }) + } + + for _, tc := range ctcs { + body := proto.Clone(validMinNodeInfoResponseBody).(*protonetmap.LocalNodeInfoResponse_Body) + tc.corrupt(body) + tcs = append(tcs, testcase{name: tc.name, body: body, assertErr: tc.assertErr}) + } + + testInvalidResponseBodies(t, newTestGetNodeInfoServer, newTestNetmapClient, tcs, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestGetNodeInfoServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestGetNodeInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestGetNodeInfoServer, newDefaultNetmapServiceDesc, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestGetNodeInfoServer, newDefaultNetmapServiceDesc, stat.MethodEndpointInfo, + nil, nil, func(c *Client) error { + _, err := c.EndpointInfo(ctx, anyValidOpts) + return err + }, + ) + }) } func TestClient_NetMapSnapshot(t *testing.T) { - var err error - var prm PrmNetMapSnapshot - var res netmap.NetMap - var srv testNetmapSnapshotServer - - signer := neofscryptotest.Signer() - - srv.signer = signer - - c := newTestNetmapClient(t, &srv) ctx := context.Background() + var anyValidOpts PrmNetMapSnapshot + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestNetmapSnapshotServer() + c := newTestNetmapClient(t, srv) + + srv.authenticateRequest(c.prm.signer) + _, err := c.NetMapSnapshot(ctx, PrmNetMapSnapshot{}) + require.NoError(t, err) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protonetmap.NetmapSnapshotResponse_Body + }{ + {name: "min", body: validMinNetmapResponseBody}, + {name: "full", body: validFullNetmapResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestNetmapSnapshotServer() + c := newTestNetmapClient(t, srv) + + srv.respondWithBody(tc.body) + res, err := c.NetMapSnapshot(ctx, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkNetmapTransport(res, tc.body.GetNetmap())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestNetmapSnapshotServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "netmap.NetmapService", "NetmapSnapshot", func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestNetmapSnapshotServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protonetmap.NetmapSnapshotResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing network map field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protonetmap.NetmapSnapshotResponse_Body), + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, ErrMissingResponseField) + require.EqualError(t, err, "missing network map field in the response") + }}, + } + + // 1. network map + for _, tc := range invalidNodeInfoProtoTestcases { + body := &protonetmap.NetmapSnapshotResponse_Body{ + Netmap: proto.Clone(validFullProtoNetmap).(*protonetmap.Netmap), + } + tc.corrupt(body.Netmap.Nodes[1]) + tcs = append(tcs, testcase{ + name: "network map/node info/" + tc.name, + body: body, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid network map field in the response: invalid node info: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newTestNetmapSnapshotServer, newTestNetmapClient, tcs, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestNetmapSnapshotServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestNetmapSnapshotServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestNetmapSnapshotServer, newDefaultNetmapServiceDesc, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestNetmapSnapshotServer, newDefaultNetmapServiceDesc, stat.MethodNetMapSnapshot, + nil, nil, func(c *Client) error { + _, err := c.NetMapSnapshot(ctx, anyValidOpts) + return err + }, + ) + }) +} - // transport error - srv.errTransport = errors.New("any error") - - _, err = c.NetMapSnapshot(ctx, prm) - st, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, codes.Unknown, st.Code()) - require.Contains(t, st.Message(), srv.errTransport.Error()) - - srv.errTransport = nil - - // unsigned response - srv.unsignedResponse = true - _, err = c.NetMapSnapshot(ctx, prm) - require.Error(t, err) - srv.unsignedResponse = false - - // failure error - srv.statusFail = true - _, err = c.NetMapSnapshot(ctx, prm) - require.Error(t, err) - require.ErrorIs(t, err, apistatus.ErrServerInternal) - - srv.statusFail = false - - // missing netmap field - srv.unsetNetMap = true - - _, err = c.NetMapSnapshot(ctx, prm) - require.Error(t, err) - - srv.unsetNetMap = false - - // invalid network map - srv.netMap = &protonetmap.Netmap{ - Nodes: []*protonetmap.NodeInfo{new(protonetmap.NodeInfo)}, - } - - _, err = c.NetMapSnapshot(ctx, prm) - require.Error(t, err) - - // correct network map - // TODO: #260 use instance normalizer - srv.netMap.Nodes[0].PublicKey = []byte{1, 2, 3} - srv.netMap.Nodes[0].Addresses = []string{"1", "2", "3"} - - res, err = c.NetMapSnapshot(ctx, prm) - require.NoError(t, err) - - require.Zero(t, res.Epoch()) - ns := res.Nodes() - require.Len(t, ns, 1) - node := ns[0] - require.False(t, node.IsOnline()) - require.False(t, node.IsOffline()) - require.False(t, node.IsMaintenance()) - require.Zero(t, node.NumberOfAttributes()) - require.Equal(t, srv.netMap.Nodes[0].PublicKey, node.PublicKey()) - var es []string - netmap.IterateNetworkEndpoints(node, func(e string) { es = append(es, e) }) - require.Equal(t, srv.netMap.Nodes[0].Addresses, es) +func TestClient_NetworkInfo(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmNetworkInfo + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestNetworkInfoServer() + c := newTestNetmapClient(t, srv) + + srv.authenticateRequest(c.prm.signer) + _, err := c.NetworkInfo(ctx, anyValidOpts) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + testRequestXHeaders(t, newTestNetworkInfoServer, newTestNetmapClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.NetworkInfo(ctx, opts) + return err + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protonetmap.NetworkInfoResponse_Body + }{ + {name: "min", body: validMinNetInfoResponseBody}, + {name: "full", body: validFullNetInfoResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestNetworkInfoServer() + c := newTestNetmapClient(t, srv) + + srv.respondWithBody(tc.body) + res, err := c.NetworkInfo(ctx, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkNetInfoTransport(res, tc.body.GetNetworkInfo())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestNetworkInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "netmap.NetmapService", "NetworkInfo", func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestNetworkInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protonetmap.NetworkInfoResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing network info field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protonetmap.NetworkInfoResponse_Body), + assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, ErrMissingResponseField) + require.EqualError(t, err, "missing network info field in the response") + }}, + } + + // 1. net info + for _, tc := range []struct { + name, msg string + corrupt func(valid *protonetmap.NetworkInfo) + }{} { + body := &protonetmap.NetworkInfoResponse_Body{ + NetworkInfo: proto.Clone(validFullProtoNetInfo).(*protonetmap.NetworkInfo), + } + tc.corrupt(body.NetworkInfo) + tcs = append(tcs, testcase{ + name: "network info/" + tc.name, + body: body, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid network info field in the response: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newTestNetworkInfoServer, newTestNetmapClient, tcs, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestNetworkInfoServer, newTestContainerClient, func(ctx context.Context, c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestNetworkInfoServer, newTestNetmapClient, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestNetworkInfoServer, newDefaultNetmapServiceDesc, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestNetworkInfoServer, newDefaultNetmapServiceDesc, stat.MethodNetworkInfo, + nil, nil, func(c *Client) error { + _, err := c.NetworkInfo(ctx, anyValidOpts) + return err + }, + ) + }) } diff --git a/client/object_delete_test.go b/client/object_delete_test.go index 93bf875e..25e34041 100644 --- a/client/object_delete_test.go +++ b/client/object_delete_test.go @@ -2,49 +2,277 @@ package client import ( "context" + "errors" "fmt" "testing" apiobject "github.com/nspcc-dev/neofs-api-go/v2/object" protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" - cid "github.com/nspcc-dev/neofs-sdk-go/container/id" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" - oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + bearertest "github.com/nspcc-dev/neofs-sdk-go/bearer/test" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" + sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) type testDeleteObjectServer struct { protoobject.UnimplementedObjectServiceServer + testCommonServerSettings[ + *protoobject.DeleteRequest_Body, + apiobject.DeleteRequestBody, + *apiobject.DeleteRequestBody, + *protoobject.DeleteRequest, + apiobject.DeleteRequest, + *apiobject.DeleteRequest, + *protoobject.DeleteResponse_Body, + protoobject.DeleteResponse, + apiobject.DeleteResponse, + *apiobject.DeleteResponse, + ] + testObjectSessionServerSettings + testBearerTokenServerSettings + testObjectAddressServerSettings } -func (x *testDeleteObjectServer) Delete(context.Context, *protoobject.DeleteRequest) (*protoobject.DeleteResponse, error) { - id := oidtest.ID() - resp := protoobject.DeleteResponse{ - Body: &protoobject.DeleteResponse_Body{ - Tombstone: &protorefs.Address{ - ObjectId: &protorefs.ObjectID{Value: id[:]}, - }, - }, +// returns [protoobject.ObjectServiceServer] supporting Delete method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestDeleteObjectServer() *testDeleteObjectServer { return new(testDeleteObjectServer) } + +func (x *testDeleteObjectServer) verifyRequest(req *protoobject.DeleteRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + if req.MetaHeader.Ttl != 2 { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", req.MetaHeader.Ttl)) + } + if err := x.verifySessionToken(req.MetaHeader.SessionToken); err != nil { + return err + } + if err := x.verifyBearerToken(req.MetaHeader.BearerToken); err != nil { + return err + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) } + // 1. address + if err := x.verifyObjectAddress(body.Address); err != nil { + return err + } + return nil +} - var respV2 apiobject.DeleteResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testDeleteObjectServer) Delete(_ context.Context, req *protoobject.DeleteRequest) (*protoobject.DeleteResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + + resp := protoobject.DeleteResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validFullDeleteObjectResponseBody).(*protoobject.DeleteResponse_Body) } - return respV2.ToGRPCMessage().(*protoobject.DeleteResponse), nil + return x.signResponse(&resp) } func TestClient_ObjectDelete(t *testing.T) { - t.Run("missing signer", func(t *testing.T) { - c := newClient(t) + ctx := context.Background() + var anyValidOpts PrmObjectDelete + anyCID := cidtest.ID() + anyOID := oidtest.ID() + anyValidSigner := usertest.User() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestDeleteObjectServer() + c := newTestObjectClient(t, srv) - _, err := c.ObjectDelete(context.Background(), cid.ID{}, oid.ID{}, nil, PrmObjectDelete{}) - require.ErrorIs(t, err, ErrMissingSigner) + srv.checkRequestObjectAddress(anyCID, anyOID) + // TODO: container PUT/DELETE/SETEACL uses + srv.authenticateRequest(anyValidSigner) + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, PrmObjectDelete{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestDeleteObjectServer, newTestObjectClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, opts) + return err + }) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestDeleteObjectServer() + c := newTestObjectClient(t, srv) + + st := sessiontest.ObjectSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("bearer token", func(t *testing.T) { + srv := newTestDeleteObjectServer() + c := newTestObjectClient(t, srv) + + bt := bearertest.Token() + bt.SetEACLTable(anyValidEACL) // TODO: drop after https://github.com/nspcc-dev/neofs-sdk-go/issues/606 + require.NoError(t, bt.Sign(usertest.User())) + opts := anyValidOpts + opts.WithBearerToken(bt) + + srv.checkRequestBearerToken(bt) + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoobject.DeleteResponse_Body + }{ + {name: "min", body: validMinDeleteObjectResponseBody}, + {name: "full", body: validFullDeleteObjectResponseBody}, + {name: "invalid container ID", body: &protoobject.DeleteResponse_Body{ + Tombstone: &protorefs.Address{ + // TODO: strange to see no error for this + ContainerId: &protorefs.ContainerID{Value: []byte("any_invalid")}, + ObjectId: proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + }, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestDeleteObjectServer() + c := newTestObjectClient(t, srv) + + srv.respondWithBody(tc.body) + id, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + require.NoError(t, err) + require.NoError(t, checkObjectIDTransport(id, tc.body.GetTombstone().GetObjectId())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestDeleteObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "object.ObjectService", "Delete", func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestDeleteObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protoobject.DeleteResponse_Body] + // missing fields + tcs := []testcase{ + {name: "nil", body: nil, assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing tombstone field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protoobject.DeleteResponse_Body), assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing tombstone field in the response") + // TODO: worth clarifying that address is completely missing + }}, + {name: "tombstone address/object ID/missing", body: &protoobject.DeleteResponse_Body{ + Tombstone: new(protorefs.Address), + }, assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, ErrMissingResponseField) + require.EqualError(t, err, "missing tombstone field in the response") + }}, + } + // tombstone ID + for _, tc := range invalidObjectIDProtoTestcases { + id := proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID) + tc.corrupt(id) + tcs = append(tcs, testcase{ + name: "tombstone address/object ID/" + tc.name, + body: &protoobject.DeleteResponse_Body{Tombstone: &protorefs.Address{ObjectId: id}}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid tombstone field in the response: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newTestDeleteObjectServer, newTestObjectClient, tcs, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("missing signer", func(t *testing.T) { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestDeleteObjectServer, newTestObjectClient, func(ctx context.Context, c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + _, err := newClient(t).ObjectDelete(ctx, anyCID, anyOID, usertest.FailSigner(anyValidSigner), anyValidOpts) + assertSignRequestErr(t, err) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestDeleteObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestDeleteObjectServer, newDefaultObjectService, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestDeleteObjectServer, newDefaultObjectService, stat.MethodObjectDelete, + []testedClientOp{func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, nil, anyValidOpts) + return err + }}, nil, func(c *Client) error { + _, err := c.ObjectDelete(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }, + ) }) } diff --git a/client/object_get_test.go b/client/object_get_test.go index f9479d0a..9b19152a 100644 --- a/client/object_get_test.go +++ b/client/object_get_test.go @@ -2,18 +2,56 @@ package client import ( "context" + "errors" "fmt" "testing" apiobject "github.com/nspcc-dev/neofs-api-go/v2/object" protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" - v2refs "github.com/nspcc-dev/neofs-api-go/v2/refs" - cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" + bearertest "github.com/nspcc-dev/neofs-sdk-go/bearer/test" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" - oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/object" + oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" + sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) +type testCommonReadObjectRequestServerSettings struct { + testObjectSessionServerSettings + testBearerTokenServerSettings + testObjectAddressServerSettings + testLocalRequestServerSettings + reqRaw bool +} + +// makes the server to assert that any request is with set raw flag. By default, +// the flag must be unset. +func (x *testCommonReadObjectRequestServerSettings) checkRequestRaw() { x.reqRaw = true } + +// makes the server to assert that any request is with set short header flag. By +// default, the flag must be unset. +func (x *testCommonReadObjectRequestServerSettings) verifyMeta(m *protosession.RequestMetaHeader) error { + // TTL + if err := x.verifyTTL(m); err != nil { + return err + } + // session token + if err := x.verifySessionToken(m.GetSessionToken()); err != nil { + return err + } + // bearer token + if err := x.verifyBearerToken(m.GetBearerToken()); err != nil { + return err + } + return nil +} + type testGetObjectServer struct { protoobject.UnimplementedObjectServiceServer } @@ -69,68 +107,370 @@ func (x *testGetObjectPayloadRangeServer) GetRange(req *protoobject.GetRangeRequ type testHeadObjectServer struct { protoobject.UnimplementedObjectServiceServer + testCommonServerSettings[ + *protoobject.HeadRequest_Body, + apiobject.HeadRequestBody, + *apiobject.HeadRequestBody, + *protoobject.HeadRequest, + apiobject.HeadRequest, + *apiobject.HeadRequest, + *protoobject.HeadResponse_Body, + protoobject.HeadResponse, + apiobject.HeadResponse, + *apiobject.HeadResponse, + ] + testCommonReadObjectRequestServerSettings + reqMainOnly bool } -func (x *testHeadObjectServer) Head(context.Context, *protoobject.HeadRequest) (*protoobject.HeadResponse, error) { - resp := protoobject.HeadResponse{ - Body: &protoobject.HeadResponse_Body{ - Head: &protoobject.HeadResponse_Body_Header{ - Header: new(protoobject.HeaderWithSignature), - }, - }, +// returns [protoobject.ObjectServiceServer] supporting Head method +// only. Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestHeadObjectServer() *testHeadObjectServer { + return new(testHeadObjectServer) +} + +// makes the server to assert that any request is with set short header flag. By +// default, the flag must be unset. +func (x *testHeadObjectServer) checkRequestShortHeader() { x.reqMainOnly = true } + +func (x *testHeadObjectServer) verifyRequest(req *protoobject.HeadRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + if err := x.verifyMeta(req.MetaHeader); err != nil { + return err + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // 1. address + if err := x.verifyObjectAddress(body.Address); err != nil { + return err + } + // 2. main only + if x.reqMainOnly != body.MainOnly { + return newErrInvalidRequestField("main only flag", fmt.Errorf("unexpected value (client: %t, message: %t)", x.reqMainOnly, body.MainOnly)) + } + // 3. raw + if x.reqRaw != body.Raw { + return newErrInvalidRequestField("raw flag", fmt.Errorf("unexpected value (client: %t, message: %t)", x.reqRaw, body.Raw)) } + return nil +} - var respV2 apiobject.HeadResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testHeadObjectServer) Head(_ context.Context, req *protoobject.HeadRequest) (*protoobject.HeadResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + + resp := protoobject.HeadResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinObjectHeadResponseBody).(*protoobject.HeadResponse_Body) } - return respV2.ToGRPCMessage().(*protoobject.HeadResponse), nil + return x.signResponse(&resp) } -func TestClient_Get(t *testing.T) { - t.Run("missing signer", func(t *testing.T) { - c := newClient(t) - ctx := context.Background() - - var nonilAddr v2refs.Address - nonilAddr.SetObjectID(new(v2refs.ObjectID)) - nonilAddr.SetContainerID(new(v2refs.ContainerID)) - - tt := []struct { - name string - methodCall func() error - }{ - { - "get", - func() error { - _, _, err := c.ObjectGetInit(ctx, cid.ID{}, oid.ID{}, nil, PrmObjectGet{prmObjectRead: prmObjectRead{}}) - return err - }, - }, - { - "get_range", - func() error { - _, err := c.ObjectRangeInit(ctx, cid.ID{}, oid.ID{}, 0, 1, nil, PrmObjectRange{prmObjectRead: prmObjectRead{}}) - return err - }, - }, - { - "get_head", - func() error { - _, err := c.ObjectHead(ctx, cid.ID{}, oid.ID{}, nil, PrmObjectHead{prmObjectRead: prmObjectRead{}}) - return err - }, - }, - } +func TestClient_ObjectHead(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmObjectHead + anyCID := cidtest.ID() + anyOID := oidtest.ID() + anyValidSigner := usertest.User() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + srv.checkRequestObjectAddress(anyCID, anyOID) + srv.authenticateRequest(anyValidSigner) + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, PrmObjectHead{}) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestHeadObjectServer, newTestObjectClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, opts) + return err + }) + }) + t.Run("local", func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + opts := anyValidOpts + opts.MarkLocal() + + srv.checkRequestLocal() + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("raw", func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + opts := anyValidOpts + opts.MarkRaw() + + srv.checkRequestRaw() + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + st := sessiontest.ObjectSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) - for _, test := range tt { - t.Run(test.name, func(t *testing.T) { - require.ErrorIs(t, test.methodCall(), ErrMissingSigner) + srv.checkRequestSessionToken(st) + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("bearer token", func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + bt := bearertest.Token() + bt.SetEACLTable(anyValidEACL) // TODO: drop after https://github.com/nspcc-dev/neofs-sdk-go/issues/606 + require.NoError(t, bt.Sign(usertest.User())) + opts := anyValidOpts + opts.WithBearerToken(bt) + + srv.checkRequestBearerToken(bt) + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + type testcase = struct { + name string + body *protoobject.HeadResponse_Body + assert func(testing.TB, *protoobject.HeadResponse_Body, object.Object, error) + } + var tcs []testcase + for _, tc := range []struct { + name string + body *protoobject.HeadResponse_Body + }{ + {name: "min", body: validMinObjectSplitInfoHeadResponseBody}, + {name: "full", body: validFullObjectSplitInfoHeadResponseBody}, + } { + tcs = append(tcs, testcase{name: "split info/" + tc.name, body: tc.body, + assert: func(t testing.TB, body *protoobject.HeadResponse_Body, _ object.Object, err error) { + var e *object.SplitInfoError + require.ErrorAs(t, err, &e) + require.NoError(t, checkSplitInfoTransport(*e.SplitInfo(), body.GetSplitInfo())) + }}) + } + for _, tc := range []struct { + name string + body *protoobject.HeadResponse_Body + }{ + {name: "min", body: validMinObjectHeadResponseBody}, + {name: "full", body: validFullObjectHeadResponseBody}, + } { + tcs = append(tcs, testcase{name: "header with signature/" + tc.name, body: tc.body, + assert: func(t testing.TB, body *protoobject.HeadResponse_Body, hdr object.Object, err error) { + require.NoError(t, err) + require.NoError(t, checkObjectHeaderWithSignatureTransport(hdr, body.GetHeader())) + }}) + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + srv := newTestHeadObjectServer() + c := newTestObjectClient(t, srv) + + srv.respondWithBody(tc.body) + hdr, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + if err != nil { + tc.assert(t, tc.body, object.Object{}, err) + } else { + tc.assert(t, tc.body, *hdr, err) + } + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestHeadObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "object.ObjectService", "Head", func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestHeadObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protoobject.HeadResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type ") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protoobject.HeadResponse_Body), + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type ") // TODO: could be more clear + }}, + {name: "short header oneof/nil", body: &protoobject.HeadResponse_Body{Head: (*protoobject.HeadResponse_Body_ShortHeader)(nil)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type ") // TODO: could be more clear + }}, + {name: "short header oneof/empty", body: &protoobject.HeadResponse_Body{Head: new(protoobject.HeadResponse_Body_ShortHeader)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type *object.ShortHeader") + }}, + {name: "split info oneof/nil", body: &protoobject.HeadResponse_Body{Head: (*protoobject.HeadResponse_Body_SplitInfo)(nil)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type ") + }}, + {name: "split info oneof/empty", body: &protoobject.HeadResponse_Body{Head: new(protoobject.HeadResponse_Body_SplitInfo)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid split info: neither link object ID nor last part object ID is set") + }}, + {name: "header oneof/nil", body: &protoobject.HeadResponse_Body{Head: (*protoobject.HeadResponse_Body_Header)(nil)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "unexpected header type ") + }}, + {name: "header oneof/empty", body: &protoobject.HeadResponse_Body{Head: new(protoobject.HeadResponse_Body_Header)}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing header") + }}, + {name: "header oneof/missing header", body: &protoobject.HeadResponse_Body{Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Signature: proto.Clone(validMinProtoSignature).(*protorefs.Signature), + }, + }}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing header") + }}, + {name: "header oneof/missing signature", body: &protoobject.HeadResponse_Body{ + Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Header: proto.Clone(validMinObjectHeader).(*protoobject.Header), + }, + }}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing object signature") + }}, + } + for _, tc := range invalidObjectHeaderProtoTestcases { + hdr := proto.Clone(validFullObjectHeader).(*protoobject.Header) + tc.corrupt(hdr) + tcs = append(tcs, testcase{ + name: "header oneof/header/" + tc.name, + body: &protoobject.HeadResponse_Body{Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Header: hdr, + Signature: proto.Clone(validMinProtoSignature).(*protorefs.Signature), + }, + }}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid header response: invalid header: "+tc.msg) + }, + }) + } + for _, tc := range invalidSignatureProtoTestcases { + sig := proto.Clone(validFullProtoSignature).(*protorefs.Signature) + tc.corrupt(sig) + tcs = append(tcs, testcase{ + name: "header oneof/signature/" + tc.name, + body: &protoobject.HeadResponse_Body{Head: &protoobject.HeadResponse_Body_Header{ + Header: &protoobject.HeaderWithSignature{ + Header: proto.Clone(validMinObjectHeader).(*protoobject.Header), + Signature: sig, + }, + }}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid header response: invalid header: "+tc.msg) + }, + }) + } + for _, tc := range invalidObjectSplitInfoProtoTestcases { + si := proto.Clone(validFullSplitInfo).(*protoobject.SplitInfo) + tc.corrupt(si) + tcs = append(tcs, testcase{ + name: "split info oneof/split info/" + tc.name, + body: &protoobject.HeadResponse_Body{Head: &protoobject.HeadResponse_Body_SplitInfo{ + SplitInfo: si, + }}, + assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid split info: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newTestHeadObjectServer, newTestObjectClient, tcs, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) }) - } + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestHeadObjectServer, newTestObjectClient, func(ctx context.Context, c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + _, err := newClient(t).ObjectHead(ctx, anyCID, anyOID, usertest.FailSigner(anyValidSigner), anyValidOpts) + assertSignRequestErr(t, err) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestHeadObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestHeadObjectServer, newDefaultObjectService, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestHeadObjectServer, newDefaultObjectService, stat.MethodObjectHead, + []testedClientOp{func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, nil, anyValidOpts) + return err + }}, nil, func(c *Client) error { + _, err := c.ObjectHead(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }, + ) }) } diff --git a/client/object_hash_test.go b/client/object_hash_test.go index 7b4d1188..257163b3 100644 --- a/client/object_hash_test.go +++ b/client/object_hash_test.go @@ -1,51 +1,346 @@ package client import ( + "bytes" "context" + "errors" "fmt" + "math" "testing" v2object "github.com/nspcc-dev/neofs-api-go/v2/object" protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" - cid "github.com/nspcc-dev/neofs-sdk-go/container/id" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" - oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + bearertest "github.com/nspcc-dev/neofs-sdk-go/bearer/test" + cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" + oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" + sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) type testHashObjectPayloadRangesServer struct { protoobject.UnimplementedObjectServiceServer + testCommonServerSettings[ + *protoobject.GetRangeHashRequest_Body, + v2object.GetRangeHashRequestBody, + *v2object.GetRangeHashRequestBody, + *protoobject.GetRangeHashRequest, + v2object.GetRangeHashRequest, + *v2object.GetRangeHashRequest, + *protoobject.GetRangeHashResponse_Body, + protoobject.GetRangeHashResponse, + v2object.GetRangeHashResponse, + *v2object.GetRangeHashResponse, + ] + testCommonReadObjectRequestServerSettings + reqHomo bool + reqRanges []uint64 + reqSalt []byte } -func (x *testHashObjectPayloadRangesServer) GetRangeHash(context.Context, *protoobject.GetRangeHashRequest) (*protoobject.GetRangeHashResponse, error) { - resp := protoobject.GetRangeHashResponse{ - Body: &protoobject.GetRangeHashResponse_Body{ - HashList: [][]byte{{1}}, - }, +// returns [protoobject.ObjectServiceServer] supporting GetRangeHash method +// only. Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestHashObjectServer() *testHashObjectPayloadRangesServer { + return new(testHashObjectPayloadRangesServer) +} + +// makes the server to assert that any request has given payload (offset,len) +// ranges. By default, and if nil, any valid ranges are accepted. +func (x *testHashObjectPayloadRangesServer) checkRequestRanges(rs []uint64) { + if len(rs)%2 != 0 { + panic("odd number of elements") + } + x.reqRanges = rs +} + +// makes the server to assert that any request has given salt. By default, and +// if nil, salt must be empty. +func (x *testHashObjectPayloadRangesServer) checkRequestSalt(salt []byte) { x.reqSalt = salt } + +// makes the server to assert that any request has homomorphic checksum type. +// By default, the type must be SHA-256. +func (x *testHashObjectPayloadRangesServer) checkRequestHomomorphic() { x.reqHomo = true } + +func (x *testHashObjectPayloadRangesServer) verifyRequest(req *protoobject.GetRangeHashRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + if err := x.verifyMeta(req.MetaHeader); err != nil { + return err + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // 1. address + if err := x.verifyObjectAddress(body.Address); err != nil { + return err } + // 2. ranges + if len(body.Ranges) == 0 { + return newErrMissingRequestBodyField("ranges") + } + if x.reqRanges != nil { + if exp, act := len(x.reqRanges), 2*len(body.Ranges); exp != act { + return newErrInvalidRequestField("ranges", fmt.Errorf("number of elements (client: %d, message: %d)", exp, act)) + } + for i, r := range body.Ranges { + if v1, v2 := r.GetOffset(), x.reqRanges[2*i]; v1 != v2 { + return newErrInvalidRequestField("ranges", fmt.Errorf("element#%d: offset field (client: %v, message: %v)", i, v1, v2)) + } + if v1, v2 := r.GetLength(), x.reqRanges[2*i+1]; v1 != v2 { + return newErrInvalidRequestField("ranges", fmt.Errorf("element#%d: length field (client: %v, message: %v)", i, v1, v2)) + } + } + } + // 3. salt + if x.reqSalt != nil && !bytes.Equal(body.Salt, x.reqSalt) { + return newErrInvalidRequestField("salt", fmt.Errorf("unexpected value (client: %x, message: %x)", x.reqSalt, body.Salt)) + } + // 4. type + var expType protorefs.ChecksumType + if x.reqHomo { + expType = protorefs.ChecksumType_TZ + } else { + expType = protorefs.ChecksumType_SHA256 + } + if body.Type != expType { + return newErrInvalidRequestField("type", fmt.Errorf("unexpected value (client: %v, message: %v)", expType, body.Type)) + } + return nil +} - var respV2 v2object.GetRangeHashResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testHashObjectPayloadRangesServer) GetRangeHash(_ context.Context, req *protoobject.GetRangeHashRequest) (*protoobject.GetRangeHashResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + + resp := protoobject.GetRangeHashResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinObjectHashResponseBody).(*protoobject.GetRangeHashResponse_Body) } - return respV2.ToGRPCMessage().(*protoobject.GetRangeHashResponse), nil + return x.signResponse(&resp) } func TestClient_ObjectHash(t *testing.T) { - c := newClient(t) + ctx := context.Background() + var anyValidOpts PrmObjectHash + anyValidOpts.SetRangeList(0, 1) + anyCID := cidtest.ID() + anyOID := oidtest.ID() + anyValidSigner := usertest.User() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) + + rs := []uint64{1, 2, 3, 4, 5, 6} + var opts PrmObjectHash + opts.SetRangeList(rs...) - t.Run("missing signer", func(t *testing.T) { - var reqBody v2object.GetRangeHashRequestBody - reqBody.SetRanges(make([]v2object.Range, 1)) + srv.checkRequestRanges(rs) + srv.authenticateRequest(anyValidSigner) + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestHashObjectServer, newTestObjectClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + return err + }) + }) + t.Run("salt", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) - _, err := c.ObjectHash(context.Background(), cid.ID{}, oid.ID{}, nil, PrmObjectHash{ - body: reqBody, + salt := []byte("any salt") + opts := anyValidOpts + opts.UseSalt(salt) + + srv.checkRequestSalt(salt) + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("homomorphic", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) + + opts := anyValidOpts + opts.TillichZemorAlgo() + + srv.checkRequestHomomorphic() + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("local", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) + + opts := anyValidOpts + opts.MarkLocal() + + srv.checkRequestLocal() + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) + + st := sessiontest.ObjectSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + t.Run("bearer token", func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) + + bt := bearertest.Token() + bt.SetEACLTable(anyValidEACL) // TODO: drop after https://github.com/nspcc-dev/neofs-sdk-go/issues/606 + require.NoError(t, bt.Sign(usertest.User())) + opts := anyValidOpts + opts.WithBearerToken(bt) + + srv.checkRequestBearerToken(bt) + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, opts) + require.NoError(t, err) + }) + }) }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoobject.GetRangeHashResponse_Body + }{ + {name: "min", body: validMinObjectHashResponseBody}, + {name: "full", body: validFullObjectHashResponseBody}, + {name: "type/negative", body: &protoobject.GetRangeHashResponse_Body{ + Type: -1, HashList: validMinObjectHashResponseBody.GetHashList(), // TODO: why is it even needed then? + }}, + {name: "type/unsupported", body: &protoobject.GetRangeHashResponse_Body{ + Type: math.MaxInt32, HashList: validMinObjectHashResponseBody.GetHashList(), + }}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestHashObjectServer() + c := newTestObjectClient(t, srv) - require.ErrorIs(t, err, ErrMissingSigner) + srv.respondWithBody(tc.body) + res, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + require.NoError(t, err) + require.Equal(t, tc.body.GetHashList(), res) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestHashObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "object.ObjectService", "GetRangeHash", func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestHashObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protoobject.GetRangeHashResponse_Body] + // missing fields + tcs := []testcase{ + {name: "nil", body: nil, assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing hash list field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protoobject.GetRangeHashResponse_Body), assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing hash list field in the response") + }}, + } + + testInvalidResponseBodies(t, newTestHashObjectServer, newTestObjectClient, tcs, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("missing signer", func(t *testing.T) { + _, err := c.ObjectHash(ctx, anyCID, anyOID, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestHashObjectServer, newTestObjectClient, func(ctx context.Context, c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + _, err := newClient(t).ObjectHash(ctx, anyCID, anyOID, usertest.FailSigner(anyValidSigner), anyValidOpts) + assertSignRequestErr(t, err) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestHashObjectServer, newTestObjectClient, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestHashObjectServer, newDefaultObjectService, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestHashObjectServer, newDefaultObjectService, stat.MethodObjectHash, + []testedClientOp{func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, nil, anyValidOpts) + return err + }}, []testedClientOp{func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, PrmObjectHash{}) + return err + }}, func(c *Client) error { + _, err := c.ObjectHash(ctx, anyCID, anyOID, anyValidSigner, anyValidOpts) + return err + }, + ) }) } diff --git a/client/object_put.go b/client/object_put.go index e02c1b34..6d829112 100644 --- a/client/object_put.go +++ b/client/object_put.go @@ -352,7 +352,9 @@ func (c *Client) ObjectPutInit(ctx context.Context, hdr object.Object, signer us c.prepareRequest(&w.req, &prm.meta) if err = w.writeHeader(hdr); err != nil { - _ = w.Close() + if errors.Is(err, io.EOF) { + err = w.Close() + } err = fmt.Errorf("header write: %w", err) return nil, err } diff --git a/client/object_put_test.go b/client/object_put_test.go index 3159034d..d73c6138 100644 --- a/client/object_put_test.go +++ b/client/object_put_test.go @@ -1,86 +1,772 @@ package client import ( + "bytes" "context" "errors" "fmt" "io" + "math/rand" "testing" + "time" v2object "github.com/nspcc-dev/neofs-api-go/v2/object" protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" - "github.com/nspcc-dev/neofs-api-go/v2/refs" - protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" - protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" - protostatus "github.com/nspcc-dev/neofs-api-go/v2/status/grpc" - apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" + bearertest "github.com/nspcc-dev/neofs-sdk-go/bearer/test" "github.com/nspcc-dev/neofs-sdk-go/object" - oidtest "github.com/nspcc-dev/neofs-sdk-go/object/id/test" + objecttest "github.com/nspcc-dev/neofs-sdk-go/object/test" + sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" - "github.com/nspcc-dev/neofs-sdk-go/version" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) +const signOneReqCalls = 3 // body+headers + +type nFailedSigner struct { + user.Signer + n, count uint +} + +// returns [user.Signer] failing all Sign calls starting from the n-th one. +func newNFailedSigner(base user.Signer, n uint) user.Signer { + return &nFailedSigner{Signer: base, n: n} +} + +func (x *nFailedSigner) Sign(data []byte) ([]byte, error) { + x.count++ + if x.count < x.n { + return x.Signer.Sign(data) + } + return nil, errors.New("test signer forcefully fails") +} + +// TODO: better to sync w/ other ops to pass assertTransportErr +func assertPutObjectTransportErr(t testing.TB, transportErr, err error) { + require.Error(t, err) + require.NotContains(t, err.Error(), "open stream") // gRPC client cannot catch this + st, ok := status.FromError(err) + require.True(t, ok, err) + require.Equal(t, codes.Unknown, st.Code()) + require.Contains(t, st.Message(), transportErr.Error()) +} + type testPutObjectServer struct { protoobject.UnimplementedObjectServiceServer + testCommonServerSettings[ + *protoobject.PutRequest_Body, + v2object.PutRequestBody, + *v2object.PutRequestBody, + *protoobject.PutRequest, + v2object.PutRequest, + *v2object.PutRequest, + *protoobject.PutResponse_Body, + protoobject.PutResponse, + v2object.PutResponse, + *v2object.PutResponse, + ] + testObjectSessionServerSettings + testBearerTokenServerSettings + testLocalRequestServerSettings + + reqErrN uint + reqErr error + respN uint + reqHdr *object.Object + reqPayload []byte + reqCopies uint32 - denyAccess bool + reqCounter uint + reqPayloadLenCounter int } -func (x *testPutObjectServer) Put(stream protoobject.ObjectService_PutServer) error { - for { - req, err := stream.Recv() - if errors.Is(err, io.EOF) { - break +// returns [protoobject.ObjectServiceServer] supporting Put method only. Default +// implementation performs common verification of any request, and responds with +// any valid message. Some methods allow to tune the behavior. +func newPutObjectServer() *testPutObjectServer { return new(testPutObjectServer) } + +// makes the server to return given error as a gRPC status from the handler +// after the n-th request receipt where n=1 stands for stream init. By default, +// and if n is zero, some response messages are returned. Note that nil error is +// also returned since it leads to a particular gRPC status. +// +// Overrides respondWithStatusOnRequest. +func (x *testPutObjectServer) abortHandlerAfterRequest(n uint, err error) { + x.reqErrN, x.reqErr = n, err +} + +// makes the server to immediately respond after the n-th request received where +// n=1 stands for stream init. +func (x *testPutObjectServer) respondAfterRequest(n uint) { x.respN = n } + +// makes the server to assert that any heading request caries given value in +// copy num field. By default, the field must be zero. +func (x *testPutObjectServer) checkRequestCopiesNumber(n uint32) { x.reqCopies = n } + +// makes the server to assert that any heading request carries given object +// header. By default, any header is accepted. +func (x *testPutObjectServer) checkRequestHeader(hdr object.Object) { x.reqHdr = &hdr } + +// makes the server to assert that any given data is streamed as an object +// payload. By default, and if nil, any payload is accepted. +func (x *testPutObjectServer) checkRequestPayload(data []byte) { x.reqPayload = data } + +func (x *testPutObjectServer) verifyHeadingMessage(m *protoobject.PutRequest_Body_Init) error { + if m.Header == nil { + return errors.New("missing header field") + } + // 4. copies number + if x.reqCopies != m.CopiesNumber { + return fmt.Errorf("copies number field (client: %d, message: %d)", x.reqCopies, m.CopiesNumber) + } + if x.reqHdr == nil { + return nil + } + // 1. ID + id := x.reqHdr.GetID() + mid := m.GetObjectId() + if id.IsZero() { + if mid != nil { + return errors.New("object ID field is set while should not be") } - switch req.GetBody().GetObjectPart().(type) { - case *protoobject.PutRequest_Body_Init_, - *protoobject.PutRequest_Body_Chunk: - default: - return errors.New("excuse me?") + } else { + if mid == nil { + return errors.New("missing object ID field") } + if err := checkObjectIDTransport(id, mid); err != nil { + return fmt.Errorf("object ID field: %w", err) + } + } + // 2. signature + // 3. header + if err := checkObjectHeaderWithSignatureTransport(*x.reqHdr, &protoobject.HeaderWithSignature{ + Header: m.Header, Signature: m.Signature, + }); err != nil { + return fmt.Errorf("header with signature fields: %w", err) } + return nil +} - var v refs.Version - version.Current().WriteToV2(&v) - id := oidtest.ID() - resp := protoobject.PutResponse{ - Body: &protoobject.PutResponse_Body{ - ObjectId: &protorefs.ObjectID{Value: id[:]}, - }, - MetaHeader: &protosession.ResponseMetaHeader{ - Version: v.ToGRPCMessage().(*protorefs.Version), - }, +func (x *testPutObjectServer) verifyPayloadChunkMessage(chunk []byte) error { + if len(chunk) == 0 { + return errors.New("empty payload chunk") + } + if x.reqPayload == nil { + return nil } + if exp := x.reqPayload[x.reqPayloadLenCounter:]; !bytes.HasPrefix(exp, chunk) { + return fmt.Errorf("wrong payload chunk (remains: %dB, message: %dB)", len(exp), len(chunk)) + } + x.reqPayloadLenCounter += len(chunk) + return nil +} - if x.denyAccess { - resp.MetaHeader.Status = apistatus.ErrObjectAccessDenied.ErrorToV2().ToGRPCMessage().(*protostatus.Status) +func (x *testPutObjectServer) verifyRequest(req *protoobject.PutRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + metaHdr := req.MetaHeader + // TTL + if err := x.verifyTTL(metaHdr); err != nil { + return err + } + // session token + if err := x.verifySessionToken(metaHdr.GetSessionToken()); err != nil { + return err + } + // bearer token + if err := x.verifyBearerToken(metaHdr.GetBearerToken()); err != nil { + return err + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + switch v := body.ObjectPart.(type) { + default: + return newErrInvalidRequestField("object part", fmt.Errorf("unsupported oneof type %T", v)) + case nil: + return newErrMissingRequestBodyField("object part") + case *protoobject.PutRequest_Body_Init_: + if x.reqCounter > 0 { + return newErrInvalidRequestField("object part", fmt.Errorf("heading part must be a 1st stream message only, "+ + "but received in #%d one", x.reqCounter)) + } + if v.Init == nil { + panic("nil oneof field container") + } + if err := x.verifyHeadingMessage(v.Init); err != nil { + return newErrInvalidRequestField("heading part", err) + } + case *protoobject.PutRequest_Body_Chunk: + if x.reqCounter == 0 { + return newErrInvalidRequestField("object part", errors.New("payload chunk must not be a 1st stream message")) + } + if err := x.verifyPayloadChunkMessage(v.Chunk); err != nil { + return newErrInvalidRequestField("chunk part", err) + } } + return nil +} - var respV2 v2object.PutResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) +func (x *testPutObjectServer) sendResponse(stream protoobject.ObjectService_PutServer) error { + resp := protoobject.PutResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinPutObjectResponseBody).(*protoobject.PutResponse_Body) + } + + signedResp, err := x.signResponse(&resp) + if err != nil { + return fmt.Errorf("sign response: %w", err) } - return stream.SendAndClose(respV2.ToGRPCMessage().(*protoobject.PutResponse)) + return stream.SendAndClose(signedResp) } -func TestClient_ObjectPutInit(t *testing.T) { - t.Run("EOF-on-status-return", func(t *testing.T) { - srv := testPutObjectServer{ - denyAccess: true, +func (x *testPutObjectServer) Put(stream protoobject.ObjectService_PutServer) error { + defer func() { x.reqCounter, x.reqPayloadLenCounter = 0, 0 }() + time.Sleep(x.respSleep) + if x.reqErrN == 1 { + return x.reqErr + } + if x.respN == 1 { + return x.sendResponse(stream) + } + for { + req, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + if x.reqCounter == 0 { + return errors.New("stream finished without messages") + } + if x.reqPayload != nil && x.reqPayloadLenCounter != len(x.reqPayload) { + return fmt.Errorf("unfinished payload (expected: %dB, received: %dB)", len(x.reqPayload), x.reqPayloadLenCounter) + } + break + } + return err + } + if err := x.verifyRequest(req); err != nil { + return err + } + x.reqCounter++ + if x.reqErrN > 0 && x.reqCounter >= x.reqErrN-1 { + return x.reqErr } - c := newTestObjectClient(t, &srv) - usr := usertest.User() + if x.respN > 0 && x.reqCounter >= x.respN-1 { + break + } + } + return x.sendResponse(stream) +} + +func TestClient_ObjectPut(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmObjectPutInit + anyValidHdr := objecttest.Object() + anyValidSigner := usertest.User() + + execWithOpts := func(c *Client, opts PrmObjectPutInit) error { + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, opts) + if err == nil { + return w.Close() + } + return err + } + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + for _, tc := range []struct { + name string + payloadLen uint + }{ + {name: "no payload", payloadLen: 0}, + {name: "one byte", payloadLen: 1}, + {name: "3MB-1", payloadLen: 3<<20 - 1}, + {name: "3MB", payloadLen: 3 << 20}, + {name: "3MB+1", payloadLen: 3<<20 + 1}, + {name: "6MB-1", payloadLen: 6<<20 - 1}, + {name: "6MB", payloadLen: 6 << 20}, + {name: "6MB+1", payloadLen: 6<<20 + 1}, + {name: "10MB", payloadLen: 10 << 20}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + payload := make([]byte, tc.payloadLen) + rand.Read(payload) + + srv.checkRequestHeader(anyValidHdr) + srv.checkRequestPayload(payload) + srv.authenticateRequest(anyValidSigner) + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, PrmObjectPutInit{}) + require.NoError(t, err) + + chunkLen := len(payload)/10 + 1 + for len(payload) > 0 { + ln := min(chunkLen, len(payload)) + n, err := w.Write(payload[:ln]) + require.NoError(t, err) + require.EqualValues(t, ln, n) + payload = payload[ln:] + } + + require.NoError(t, w.Close()) + }) + } + }) + t.Run("options", func(t *testing.T) { + // TODO: why all opts are transmitted in all stream messages when heading parts + // is enough? + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newPutObjectServer, newTestObjectClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return execWithOpts(c, opts) + }) + }) + t.Run("local", func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + opts := anyValidOpts + opts.MarkLocal() + + srv.checkRequestLocal() + err := execWithOpts(c, opts) + require.NoError(t, err) + }) + t.Run("session token", func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + st := sessiontest.ObjectSigned(usertest.User()) + opts := anyValidOpts + opts.WithinSession(st) + + srv.checkRequestSessionToken(st) + err := execWithOpts(c, opts) + require.NoError(t, err) + }) + t.Run("bearer token", func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) - w, err := c.ObjectPutInit(context.Background(), object.Object{}, usr, PrmObjectPutInit{}) + bt := bearertest.Token() + bt.SetEACLTable(anyValidEACL) // TODO: drop after https://github.com/nspcc-dev/neofs-sdk-go/issues/606 + require.NoError(t, bt.Sign(usertest.User())) + opts := anyValidOpts + opts.WithBearerToken(bt) + + srv.checkRequestBearerToken(bt) + err := execWithOpts(c, opts) + require.NoError(t, err) + }) + t.Run("copies number", func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + n := rand.Uint32() + opts := anyValidOpts + opts.SetCopiesNumber(n) + + srv.checkRequestCopiesNumber(n) + err := execWithOpts(c, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoobject.PutResponse_Body + }{ + {name: "min", body: validMinPutObjectResponseBody}, + {name: "full", body: validFullPutObjectResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + srv.respondWithBody(tc.body) + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + require.NoError(t, err) + _, err = w.Write([]byte{1}) + require.NoError(t, err) + require.NoError(t, w.Close()) + require.NoError(t, checkObjectIDTransport(w.GetResult().StoredObjectID(), tc.body.GetObjectId())) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + streamUntilErr := func(c *Client) error { + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + for err == nil { + _, err = w.Write([]byte{1}) + } + return err + } + for _, tc := range []struct { + name string + n uint + }{ + {name: "on stream init", n: 1}, + {name: "after heading request", n: 2}, + {name: "on payload transmission", n: 10}, + } { + t.Run(tc.name, func(t *testing.T) { + // FIXME: status OK before client finishes the stream is not OK + testStatusResponses(t, func() *testPutObjectServer { + srv := newPutObjectServer() + srv.respondAfterRequest(tc.n) + return srv + }, newTestObjectClient, streamUntilErr) + }) + } + t.Run("after stream finish", func(t *testing.T) { + testStatusResponses(t, newPutObjectServer, newTestObjectClient, func(c *Client) error { + return execWithOpts(c, anyValidOpts) + }) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + exec := func(c *Client) error { + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + for err == nil { + return w.Close() + } + return err + } + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "object.ObjectService", "Put", exec) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newPutObjectServer, newTestObjectClient, exec) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protoobject.PutResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, ErrMissingResponseField) + require.EqualError(t, err, "missing ID field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "empty", body: new(protoobject.PutResponse_Body), assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, ErrMissingResponseField) + require.EqualError(t, err, "missing ID field in the response") + // TODO: worth clarifying that body is completely missing + }}, + } + for _, tc := range invalidObjectIDProtoTestcases { + body := proto.Clone(validFullPutObjectResponseBody).(*protoobject.PutResponse_Body) + tc.corrupt(body.ObjectId) + tcs = append(tcs, testcase{name: "ID/" + tc.name, body: body, assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "invalid ID field in the response: "+tc.msg) + }, + }) + } + + testInvalidResponseBodies(t, newPutObjectServer, newTestObjectClient, tcs, func(c *Client) error { + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + if err == nil { + if _, err = w.Write([]byte{1}); err == nil { + return w.Close() + } + } + return err + }) + }) + }) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newPutObjectServer, newTestObjectClient, func(ctx context.Context, c *Client) error { + _, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + t.Run("heading", func(t *testing.T) { + _, err := newClient(t).ObjectPutInit(ctx, anyValidHdr, usertest.FailSigner(anyValidSigner), anyValidOpts) + require.ErrorContains(t, err, "header write") + require.ErrorContains(t, err, "sign message") + // TODO: better to sync the error w/ other ops to pass assertSignRequestErr(t, err) + }) + t.Run("payload chunks", func(t *testing.T) { + for _, n := range []int{0, 1, 10} { + t.Run(fmt.Sprintf("after %d successes", n), func(t *testing.T) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + okSignings := signOneReqCalls * (n + 1) // +1 for header one + signer := newNFailedSigner(anyValidSigner, uint(okSignings+1)) + w, err := c.ObjectPutInit(ctx, anyValidHdr, signer, anyValidOpts) + require.NoError(t, err) + + for range n { + _, err = w.Write([]byte{1}) + require.NoError(t, err) + } + _, err = w.Write([]byte{1}) + require.ErrorContains(t, err, "sign message") + // TODO: what message? better to mention 'chunk message' + }) + } + }) + }) + t.Run("transport failure", func(t *testing.T) { + test := func(t testing.TB, n uint, handleInit func(testing.TB, io.WriteCloser, error) error) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + transportErr := errors.New("any transport failure") + + srv.abortHandlerAfterRequest(n, transportErr) + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + if handleInit != nil { + err = handleInit(t, w, err) + } + assertPutObjectTransportErr(t, transportErr, err) + } + t.Run("on stream init", func(t *testing.T) { + test(t, 1, func(t testing.TB, _ io.WriteCloser, err error) error { + require.ErrorContains(t, err, "header write") + return err + }) + }) + t.Run("after heading request", func(t *testing.T) { + test := func(t testing.TB, withPayload bool) { + test(t, 2, func(t testing.TB, w io.WriteCloser, err error) error { + require.NoError(t, err) + if withPayload { + _, err = w.Write([]byte{1}) // gRPC client stream does not ACK each request + if err == nil { + // wait for the response + err = w.Close() + } // else it has already come and reflected in err + } else { + err = w.Close() + } + return err + }) + } + t.Run("with payload", func(t *testing.T) { test(t, true) }) + t.Run("without payload", func(t *testing.T) { test(t, false) }) + }) + t.Run("on payload transmission", func(t *testing.T) { + for _, n := range []uint{0, 2, 10} { + t.Run(fmt.Sprintf("after %d successes", n), func(t *testing.T) { + test(t, 3+n, func(t testing.TB, w io.WriteCloser, err error) error { + require.NoError(t, err) + for range n { + _, err = w.Write([]byte{1}) + require.NoError(t, err) + } + _, err = w.Write([]byte{1}) // gRPC client stream does not ACK each request + if err == nil { + // wait for the response + err = w.Close() + } // else it has already come and reflected in err + return err + }) + }) + } + }) + }) + t.Run("no response message", func(t *testing.T) { + // TODO: better to sync the error w/ other ops to pass global assertTransportErr + assertNoResponseErr := func(t testing.TB, err error) { + _, ok := status.FromError(err) + require.False(t, ok) + require.EqualError(t, err, "server finished stream without response") + } + test := func(t testing.TB, n uint, assertStream func(testing.TB, io.WriteCloser, error)) { + srv := newPutObjectServer() + c := newTestObjectClient(t, srv) + + srv.abortHandlerAfterRequest(n, nil) + w, err := c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + assertStream(t, w, err) + } + t.Run("on stream init", func(t *testing.T) { + test(t, 1, func(t testing.TB, _ io.WriteCloser, err error) { assertNoResponseErr(t, err) }) + }) + t.Run("after heading request", func(t *testing.T) { + test := func(t testing.TB, withPayload bool) { + test(t, 2, func(t testing.TB, w io.WriteCloser, err error) { + require.NoError(t, err) + if withPayload { + _, err = w.Write([]byte{1}) // gRPC client stream does not ACK each request + if err == nil { + // wait for the response + err = w.Close() + } // else it has already come and reflected in err + } else { + err = w.Close() + } + assertNoResponseErr(t, err) + }) + } + t.Run("with payload", func(t *testing.T) { test(t, true) }) + t.Run("without payload", func(t *testing.T) { test(t, false) }) + }) + t.Run("on chunk requests", func(t *testing.T) { + for _, n := range []uint{0, 2, 10} { + t.Run(fmt.Sprintf("after %d successes", n), func(t *testing.T) { + test(t, 3+n, func(t testing.TB, w io.WriteCloser, err error) { + require.NoError(t, err) + for range n { + _, err = w.Write([]byte{1}) + require.NoError(t, err) + } + _, err = w.Write([]byte{1}) // gRPC client stream does not ACK each request + if err == nil { + // wait for the response + err = w.Close() + } // else it has already come and reflected in err + assertNoResponseErr(t, err) + }) + }) + } + }) + }) + t.Run("response callback", func(t *testing.T) { + // TODO: implement + // FIXME: ObjectPut does not calls back + }) + t.Run("exec statistics", func(t *testing.T) { + srv := newPutObjectServer() + svc := newDefaultObjectService(t, srv) + + type collectedItem struct { + pub []byte + endpoint string + mtd stat.Method + dur time.Duration + err error + } + var collected []collectedItem + handler := func(pub []byte, endpoint string, mtd stat.Method, dur time.Duration, err error) { + collected = append(collected, collectedItem{pub: pub, endpoint: endpoint, mtd: mtd, dur: dur, err: err}) + } + + c := newCustomClient(t, func(prm *PrmInit) { prm.SetStatisticCallback(handler) }, svc) + // [Client.EndpointInfo] is always called to dial the server: this is also submitted + require.Len(t, collected, 1) + require.Nil(t, collected[0].pub) // server key is not yet received + require.Equal(t, testServerEndpoint, collected[0].endpoint) + require.Equal(t, stat.MethodEndpointInfo, collected[0].mtd) + require.Positive(t, collected[0].dur) + require.NoError(t, collected[0].err) + collected = nil + + assertCommon := func() { + for i := range collected { + require.Equal(t, testServerStateOnDial.pub, collected[i].pub) + require.Equal(t, testServerEndpoint, collected[i].endpoint) + require.Positive(t, collected[i].dur) + } + } + + // non-stat failures + _, err := c.ObjectPutInit(ctx, anyValidHdr, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + assertCommon() + require.Len(t, collected, 1) + require.Equal(t, stat.MethodObjectPut, collected[0].mtd) + require.NoError(t, collected[0].err) + collected = nil + + // sign heading request + _, err = c.ObjectPutInit(ctx, anyValidHdr, usertest.FailSigner(anyValidSigner), anyValidOpts) + require.ErrorContains(t, err, "header write") + require.ErrorContains(t, err, "sign message") + assertCommon() + require.Len(t, collected, 2) + require.Equal(t, stat.MethodObjectPutStream, collected[0].mtd) + require.ErrorContains(t, collected[0].err, "sign message") + require.Equal(t, stat.MethodObjectPut, collected[1].mtd) + require.Equal(t, err, collected[1].err) + collected = nil + + // sign chunk request + w, err := c.ObjectPutInit(ctx, anyValidHdr, newNFailedSigner(anyValidSigner, signOneReqCalls*2+1), anyValidOpts) require.NoError(t, err) + _, err = w.Write([]byte{1}) + require.NoError(t, err) + _, err = w.Write([]byte{1}) + require.ErrorContains(t, err, "sign message") + err = w.Close() + require.ErrorContains(t, err, "sign message") + assertCommon() + require.Len(t, collected, 2) + require.Equal(t, stat.MethodObjectPut, collected[0].mtd) + require.NoError(t, collected[0].err) + require.Equal(t, stat.MethodObjectPutStream, collected[1].mtd) + require.Equal(t, err, collected[1].err) + collected = nil + // transport failure + transportErr := errors.New("any transport failure") + srv.abortHandlerAfterRequest(3, transportErr) + + w, err = c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + require.NoError(t, err) + _, err = w.Write([]byte{1}) + require.NoError(t, err) + _, err = w.Write([]byte{1}) + if err != nil { + assertPutObjectTransportErr(t, transportErr, err) + } err = w.Close() - require.ErrorIs(t, err, apistatus.ErrObjectAccessDenied) + assertPutObjectTransportErr(t, transportErr, err) + assertCommon() + require.Len(t, collected, 2) + require.Equal(t, stat.MethodObjectPut, collected[0].mtd) + require.NoError(t, collected[0].err) + require.Equal(t, stat.MethodObjectPutStream, collected[1].mtd) + require.Equal(t, err, collected[1].err) + collected = nil + + srv.abortHandlerAfterRequest(0, nil) + + // OK + const sleepDur = 100 * time.Millisecond + // duration is pretty short overall, but most likely larger than the exec time w/o sleep + srv.setSleepDuration(sleepDur) + + w, err = c.ObjectPutInit(ctx, anyValidHdr, anyValidSigner, anyValidOpts) + require.NoError(t, err) + _, err = w.Write([]byte{1}) + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + assertCommon() + require.Len(t, collected, 2) + require.Equal(t, stat.MethodObjectPut, collected[0].mtd) + require.NoError(t, collected[0].err) + require.Equal(t, stat.MethodObjectPutStream, collected[1].mtd) + require.NoError(t, err, collected[1].err) + require.Greater(t, collected[1].dur, sleepDur) }) } diff --git a/client/object_test.go b/client/object_test.go index 86cd3650..32cfe387 100644 --- a/client/object_test.go +++ b/client/object_test.go @@ -1,12 +1,344 @@ package client import ( + "errors" + "fmt" + "strings" "testing" + protoacl "github.com/nspcc-dev/neofs-api-go/v2/acl/grpc" protoobject "github.com/nspcc-dev/neofs-api-go/v2/object/grpc" + protorefs "github.com/nspcc-dev/neofs-api-go/v2/refs/grpc" + protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" + "github.com/nspcc-dev/neofs-sdk-go/bearer" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + oid "github.com/nspcc-dev/neofs-sdk-go/object/id" + "github.com/nspcc-dev/neofs-sdk-go/session" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -// returns Client of Object service provided by given server. -func newTestObjectClient(t testing.TB, srv protoobject.ObjectServiceServer) *Client { - return newClient(t, testService{desc: &protoobject.ObjectService_ServiceDesc, impl: srv}) +type ( + invalidObjectSplitInfoProtoTestcase = struct { + name, msg string + corrupt func(valid *protoobject.SplitInfo) + } + invalidObjectHeaderProtoTestcase = struct { + name, msg string + corrupt func(valid *protoobject.Header) + } +) + +// various sets of Object service testcases. +var ( + invalidObjectSplitInfoProtoTestcases = []invalidObjectSplitInfoProtoTestcase{ + {name: "neither linker nor last", msg: "neither link object ID nor last part object ID is set", corrupt: func(valid *protoobject.SplitInfo) { + valid.Reset() + }}, + // + other cases in init + } + invalidObjectSessionTokenProtoTestcases = append(invalidCommonSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "context/wrong", msg: "invalid context: invalid context *session.ContainerSessionContext", + corrupt: func(valid *protosession.SessionToken) { + valid.Body.Context = new(protosession.SessionToken_Body_Container) + }, + }, invalidSessionTokenProtoTestcase{ + name: "context/verb/negative", msg: "invalid context: negative verb -1", + corrupt: func(valid *protosession.SessionToken) { + c := valid.Body.Context.(*protosession.SessionToken_Body_Object).Object + c.Verb = -1 + }, + }, invalidSessionTokenProtoTestcase{ + name: "context/container/nil", msg: "invalid context: missing target container", + corrupt: func(valid *protosession.SessionToken) { + c := valid.Body.Context.(*protosession.SessionToken_Body_Object).Object + c.Target.Container = nil + }, + }) // + other container and object ID cases in init + invalidObjectHeaderProtoTestcases = []invalidObjectHeaderProtoTestcase{ + // 1. version (any accepted, even absent) + // 2. container (init) + // 3. owner (init) + // 4. creation epoch (any accepted) + // 5. payload length (any accepted) + // 6. payload checksum (init) + {name: "type/negative", msg: "negative type -1", corrupt: func(valid *protoobject.Header) { + valid.ObjectType = -1 + }}, + // 8. homomorphic payload checksum (init) + // 9. session token (init) + {name: "attributes/no key", msg: "invalid header: empty key of the attribute #1", + corrupt: func(valid *protoobject.Header) { + valid.Attributes = []*protoobject.Header_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "", Value: "v2"}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "attributes/no value", msg: "invalid header: empty value of the attribute #1 (k2)", + corrupt: func(valid *protoobject.Header) { + valid.Attributes = []*protoobject.Header_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "k2", Value: ""}, {Key: "k3", Value: "v3"}, + } + }}, + {name: "attributes/duplicated", msg: "invalid header: duplicated attribute k1", + corrupt: func(valid *protoobject.Header) { + valid.Attributes = []*protoobject.Header_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}, {Key: "k1", Value: "v3"}, + } + }}, + {name: "attributes/expiration", msg: `invalid header: invalid expiration attribute (must be a uint): strconv.ParseUint: parsing "foo": invalid syntax`, + corrupt: func(valid *protoobject.Header) { + valid.Attributes = []*protoobject.Header_Attribute{ + {Key: "k1", Value: "v1"}, {Key: "__NEOFS__EXPIRATION_EPOCH", Value: "foo"}, {Key: "k3", Value: "v3"}, + } + }}, + // 11. split (init) + } +) + +func init() { + // session token + for _, tc := range invalidContainerIDProtoTestcases { + invalidObjectSessionTokenProtoTestcases = append(invalidObjectSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "context/container/" + tc.name, msg: "invalid context: invalid container ID: " + tc.msg, + corrupt: func(valid *protosession.SessionToken) { + c := valid.Body.Context.(*protosession.SessionToken_Body_Object).Object + tc.corrupt(c.Target.Container) + }, + }) + } + for _, tc := range invalidObjectIDProtoTestcases { + invalidObjectSessionTokenProtoTestcases = append(invalidObjectSessionTokenProtoTestcases, invalidSessionTokenProtoTestcase{ + name: "context/objects/" + tc.name, msg: "invalid context: invalid target object: " + tc.msg, + corrupt: func(valid *protosession.SessionToken) { + c := valid.Body.Context.(*protosession.SessionToken_Body_Object).Object + c.Target.Objects = []*protorefs.ObjectID{ + proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + proto.Clone(validProtoObjectIDs[1]).(*protorefs.ObjectID), + proto.Clone(validProtoObjectIDs[2]).(*protorefs.ObjectID), + } + tc.corrupt(c.Target.Objects[1]) + }, + }) + } + // split info + for _, tc := range invalidUUIDProtoTestcases { + invalidObjectSplitInfoProtoTestcases = append(invalidObjectSplitInfoProtoTestcases, invalidObjectSplitInfoProtoTestcase{ + name: "split ID/" + tc.name, msg: "invalid split ID: " + tc.msg, + corrupt: func(valid *protoobject.SplitInfo) { valid.SplitId = tc.corrupt(valid.SplitId) }, + }) + } + for _, tc := range invalidObjectIDProtoTestcases { + invalidObjectSplitInfoProtoTestcases = append(invalidObjectSplitInfoProtoTestcases, invalidObjectSplitInfoProtoTestcase{ + name: "last ID/" + tc.name, msg: "could not convert last part object ID: " + tc.msg, + corrupt: func(valid *protoobject.SplitInfo) { tc.corrupt(valid.LastPart) }, + }, invalidObjectSplitInfoProtoTestcase{ + name: "linker/" + tc.name, msg: "could not convert link object ID: " + tc.msg, + corrupt: func(valid *protoobject.SplitInfo) { tc.corrupt(valid.Link) }, + }, invalidObjectSplitInfoProtoTestcase{ + name: "first ID/" + tc.name, msg: "could not convert first part object ID: " + tc.msg, + corrupt: func(valid *protoobject.SplitInfo) { tc.corrupt(valid.FirstPart) }, + }) + } + // header + for _, tc := range invalidContainerIDProtoTestcases { + invalidObjectHeaderProtoTestcases = append(invalidObjectHeaderProtoTestcases, invalidObjectHeaderProtoTestcase{ + name: "container/" + tc.name, msg: "invalid container: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.ContainerId) }, + }) + } + for _, tc := range invalidUserIDProtoTestcases { + invalidObjectHeaderProtoTestcases = append(invalidObjectHeaderProtoTestcases, invalidObjectHeaderProtoTestcase{ + name: "owner/" + tc.name, msg: "invalid owner: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.OwnerId) }, + }) + } + for _, tc := range invalidChecksumTestcases { + invalidObjectHeaderProtoTestcases = append(invalidObjectHeaderProtoTestcases, invalidObjectHeaderProtoTestcase{ + name: "payload checksum/" + tc.name, msg: "invalid payload checksum: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.PayloadHash) }, + }, invalidObjectHeaderProtoTestcase{ + name: "payload homomorphic checksum/" + tc.name, msg: "invalid payload homomorphic checksum: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.HomomorphicHash) }, + }) + } + type splitTestcase = struct { + name, msg string + corrupt func(split *protoobject.Header_Split) + } + var splitTestcases []splitTestcase + for _, tc := range invalidObjectHeaderProtoTestcases { + splitTestcases = append(splitTestcases, splitTestcase{ + name: "parent header/" + tc.name, msg: "invalid parent header: " + strings.ReplaceAll(tc.msg, "invalid header: ", ""), + corrupt: func(valid *protoobject.Header_Split) { tc.corrupt(valid.ParentHeader) }, + }) + } + for _, tc := range invalidObjectIDProtoTestcases { + splitTestcases = append(splitTestcases, splitTestcase{ + name: "parent ID/" + tc.name, msg: "invalid parent split member ID: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { tc.corrupt(valid.Parent) }, + }, splitTestcase{ + name: "previous ID/" + tc.name, msg: "invalid previous split member ID: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { tc.corrupt(valid.Previous) }, + }, splitTestcase{ + name: "first ID/" + tc.name, msg: "invalid first split member ID: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { tc.corrupt(valid.First) }, + }, splitTestcase{ + name: "children/" + tc.name, msg: "invalid child split member ID #1: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { + valid.Children = []*protorefs.ObjectID{ + proto.Clone(validProtoObjectIDs[0]).(*protorefs.ObjectID), + proto.Clone(validProtoObjectIDs[1]).(*protorefs.ObjectID), + proto.Clone(validProtoObjectIDs[2]).(*protorefs.ObjectID), + } + tc.corrupt(valid.Children[1]) + }, + }) + } + for _, tc := range invalidSignatureProtoTestcases { + splitTestcases = append(splitTestcases, splitTestcase{ + name: "parent signature/" + tc.name, msg: "invalid parent signature: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { tc.corrupt(valid.ParentSignature) }, + }) + } + for _, tc := range invalidUUIDProtoTestcases { + splitTestcases = append(splitTestcases, splitTestcase{ + name: "split ID/" + tc.name, msg: "invalid split UUID: " + tc.msg, + corrupt: func(valid *protoobject.Header_Split) { valid.SplitId = tc.corrupt(valid.SplitId) }, + }) + } + for _, tc := range splitTestcases { + invalidObjectHeaderProtoTestcases = append(invalidObjectHeaderProtoTestcases, invalidObjectHeaderProtoTestcase{ + name: "split header/" + tc.name, + msg: "invalid split header: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.Split) }, + }) + } + for _, tc := range invalidObjectSessionTokenProtoTestcases { + invalidObjectHeaderProtoTestcases = append(invalidObjectHeaderProtoTestcases, invalidObjectHeaderProtoTestcase{ + name: "session token/" + tc.name, msg: "invalid session token: " + tc.msg, + corrupt: func(valid *protoobject.Header) { tc.corrupt(valid.SessionToken) }, + }) + } +} + +// returns Client-compatible Object service handled by given server. Provided +// server must implement [protoobject.ObjectServiceServer]: the parameter is not +// of this type to support generics. +func newDefaultObjectService(t testing.TB, srv any) testService { + require.Implements(t, (*protoobject.ObjectServiceServer)(nil), srv) + return testService{desc: &protoobject.ObjectService_ServiceDesc, impl: srv} +} + +// returns Client of Object service provided by given server. Provided server +// must implement [protoobject.ObjectServiceServer]: the parameter is not of +// this type to support generics. +func newTestObjectClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultObjectService(t, srv)) +} + +// for sharing between servers of requests that can be for local execution only. +type testLocalRequestServerSettings struct { + reqLocal bool +} + +// makes the server to assert that any request has TTL = 1. By default, TTL must +// be 2. +func (x *testLocalRequestServerSettings) checkRequestLocal() { x.reqLocal = true } + +func (x testLocalRequestServerSettings) verifyTTL(m *protosession.RequestMetaHeader) error { + var exp uint32 + if x.reqLocal { + exp = 1 + } else { + exp = 2 + } + if act := m.GetTtl(); act != exp { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected %d", act, exp)) + } + return nil +} + +// for sharing between servers of requests with required object address. +type testObjectAddressServerSettings struct { + c testRequiredContainerIDServerSettings + expectedReqObjID *oid.ID +} + +// makes the server to assert that any request carries given object address. By +// default, any address is accepted. +func (x *testObjectAddressServerSettings) checkRequestObjectAddress(c cid.ID, o oid.ID) { + x.c.checkRequestContainerID(c) + x.expectedReqObjID = &o +} + +func (x testObjectAddressServerSettings) verifyObjectAddress(m *protorefs.Address) error { + if m == nil { + return newErrMissingRequestBodyField("object address") + } + if err := x.c.verifyRequestContainerID(m.ContainerId); err != nil { + return err + } + if m.ObjectId == nil { + return newErrMissingRequestBodyField("object ID") + } + if x.expectedReqObjID != nil { + if err := checkObjectIDTransport(*x.expectedReqObjID, m.ObjectId); err != nil { + return newErrInvalidRequestField("container ID", err) + } + } + return nil +} + +// for sharing between servers of requests with an object session token. +type testObjectSessionServerSettings struct { + expectedToken *session.Object +} + +// makes the server to assert that any request carries given session token. By +// default, session token must not be attached. +func (x *testObjectSessionServerSettings) checkRequestSessionToken(st session.Object) { + x.expectedToken = &st +} + +func (x testObjectSessionServerSettings) verifySessionToken(m *protosession.SessionToken) error { + if m == nil { + if x.expectedToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("session token is missing while should not be")) + } + return nil + } + if x.expectedToken == nil { + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + } + if err := checkObjectSessionTransport(*x.expectedToken, m); err != nil { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("session token: %w", err)) + } + return nil +} + +// for sharing between servers of requests with a bearer token. +type testBearerTokenServerSettings struct { + expectedToken *bearer.Token +} + +// makes the server to assert that any request carries given bearer token. By +// default, bearer token must not be attached. +func (x *testBearerTokenServerSettings) checkRequestBearerToken(bt bearer.Token) { + x.expectedToken = &bt +} + +func (x testBearerTokenServerSettings) verifyBearerToken(m *protoacl.BearerToken) error { + if m == nil { + if x.expectedToken != nil { + return newInvalidRequestMetaHeaderErr(errors.New("bearer token is missing while should not be")) + } + return nil + } + if x.expectedToken == nil { + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + if err := checkBearerTokenTransport(*x.expectedToken, m); err != nil { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("bearer token: %w", err)) + } + return nil } diff --git a/client/reputation_test.go b/client/reputation_test.go index 975af462..38e4fe66 100644 --- a/client/reputation_test.go +++ b/client/reputation_test.go @@ -2,53 +2,474 @@ package client import ( "context" + "errors" "fmt" + "math/rand" "testing" - "github.com/nspcc-dev/neofs-api-go/v2/reputation" + apireputation "github.com/nspcc-dev/neofs-api-go/v2/reputation" protoreputation "github.com/nspcc-dev/neofs-api-go/v2/reputation/grpc" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" + "github.com/nspcc-dev/neofs-sdk-go/reputation" + reputationtest "github.com/nspcc-dev/neofs-sdk-go/reputation/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -// returns Client of Reputation service provided by given server. -func newTestReputationClient(t testing.TB, srv protoreputation.ReputationServiceServer) *Client { - return newClient(t, testService{desc: &protoreputation.ReputationService_ServiceDesc, impl: srv}) +// returns Client-compatible Reputation service handled by given server. +// Provided server must implement [protoreputation.ReputationServiceServer]: the +// parameter is not of this type to support generics. +func newDefaultReputationServiceDesc(t testing.TB, srv any) testService { + require.Implements(t, (*protoreputation.ReputationServiceServer)(nil), srv) + return testService{desc: &protoreputation.ReputationService_ServiceDesc, impl: srv} +} + +// returns Client of Reputation service provided by given server. Provided +// server must implement [protoreputation.ReputationServiceServer]: the +// parameter is not of this type to support generics. +func newTestReputationClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultReputationServiceDesc(t, srv)) } type testAnnounceIntermediateReputationServer struct { protoreputation.UnimplementedReputationServiceServer + testCommonServerSettings[ + *protoreputation.AnnounceIntermediateResultRequest_Body, + apireputation.AnnounceIntermediateResultRequestBody, + *apireputation.AnnounceIntermediateResultRequestBody, + *protoreputation.AnnounceIntermediateResultRequest, + apireputation.AnnounceIntermediateResultRequest, + *apireputation.AnnounceIntermediateResultRequest, + *protoreputation.AnnounceIntermediateResultResponse_Body, + protoreputation.AnnounceIntermediateResultResponse, + apireputation.AnnounceIntermediateResultResponse, + *apireputation.AnnounceIntermediateResultResponse, + ] + reqEpoch *uint64 + reqIter uint32 + reqTrust *reputation.PeerToPeerTrust +} + +// returns [protoreputation.ReputationServiceServer] supporting +// AnnounceIntermediateResult method only. Default implementation performs +// common verification of any request, and responds with any valid message. Some +// methods allow to tune the behavior. +func newTestAnnounceIntermediateReputationServer() *testAnnounceIntermediateReputationServer { + return new(testAnnounceIntermediateReputationServer) +} + +// makes the server to assert that any request is for the given epoch. By +// default, any epoch is accepted. +func (x *testAnnounceIntermediateReputationServer) checkRequestEpoch(epoch uint64) { + x.reqEpoch = &epoch +} + +// makes the server to assert that any request is for the given iteration. By +// default, iteration must be unset. +func (x *testAnnounceIntermediateReputationServer) checkRequestIteration(iter uint32) { + x.reqIter = iter +} + +// makes the server to assert that any request has given trust. By default, +// any valid trust is accepted. +func (x *testAnnounceIntermediateReputationServer) checkRequestTrust(t reputation.PeerToPeerTrust) { + x.reqTrust = &t +} + +func (x *testAnnounceIntermediateReputationServer) verifyRequest(req *protoreputation.AnnounceIntermediateResultRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // 1. epoch + if body.Epoch == 0 { + return newErrInvalidRequestField("epoch", errors.New("zero")) + } + if x.reqEpoch != nil && body.Epoch != *x.reqEpoch { + return newErrInvalidRequestField("epoch", errors.New("mismatches the test input")) + } + // 2. iteration + if body.Iteration != x.reqIter { + return newErrInvalidRequestField("iteration", errors.New("mismatches the test input")) + } + // 3. trust + if body.Trust == nil { + return newErrMissingRequestBodyField("trust") + } + if x.reqTrust != nil { + if err := checkP2PTrustTransport(*x.reqTrust, body.Trust); err != nil { + return newErrInvalidRequestField("trust", err) + } + } + return nil } -func (x *testAnnounceIntermediateReputationServer) AnnounceIntermediateResult(context.Context, *protoreputation.AnnounceIntermediateResultRequest, +func (x *testAnnounceIntermediateReputationServer) AnnounceIntermediateResult(_ context.Context, req *protoreputation.AnnounceIntermediateResultRequest, ) (*protoreputation.AnnounceIntermediateResultResponse, error) { - var resp protoreputation.AnnounceIntermediateResultResponse + if err := x.verifyRequest(req); err != nil { + return nil, err + } - var respV2 reputation.AnnounceIntermediateResultResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) + resp := protoreputation.AnnounceIntermediateResultResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinAnnounceIntermediateRepResponseBody).(*protoreputation.AnnounceIntermediateResultResponse_Body) } - return respV2.ToGRPCMessage().(*protoreputation.AnnounceIntermediateResultResponse), nil + return x.signResponse(&resp) } type testAnnounceLocalTrustServer struct { protoreputation.UnimplementedReputationServiceServer + testCommonServerSettings[ + *protoreputation.AnnounceLocalTrustRequest_Body, + apireputation.AnnounceLocalTrustRequestBody, + *apireputation.AnnounceLocalTrustRequestBody, + *protoreputation.AnnounceLocalTrustRequest, + apireputation.AnnounceLocalTrustRequest, + *apireputation.AnnounceLocalTrustRequest, + *protoreputation.AnnounceLocalTrustResponse_Body, + protoreputation.AnnounceLocalTrustResponse, + apireputation.AnnounceLocalTrustResponse, + *apireputation.AnnounceLocalTrustResponse, + ] + reqEpoch *uint64 + reqTrusts []reputation.Trust +} + +// returns [protoreputation.ReputationServiceServer] supporting +// AnnounceLocalTrust method only. Default implementation performs common +// verification of any request, and responds with any valid message. Some +// methods allow to tune the behavior. +func newTestAnnounceLocalTrustServer() *testAnnounceLocalTrustServer { + return new(testAnnounceLocalTrustServer) +} + +// makes the server to assert that any request is for the given epoch. By +// default, any epoch is accepted. +func (x *testAnnounceLocalTrustServer) checkRequestEpoch(epoch uint64) { x.reqEpoch = &epoch } + +// makes the server to assert that any request has given trust. By default, and +// if nil, any valid trusts are accepted. +func (x *testAnnounceLocalTrustServer) checkRequestTrusts(ts []reputation.Trust) { + x.reqTrusts = ts +} + +func (x *testAnnounceLocalTrustServer) verifyRequest(req *protoreputation.AnnounceLocalTrustRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) + } + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // 1. epoch + if body.Epoch == 0 { + return newErrInvalidRequestField("epoch", errors.New("zero")) + } + if x.reqEpoch != nil && body.Epoch != *x.reqEpoch { + return newErrInvalidRequestField("epoch", errors.New("mismatches the test input")) + } + // 2. trusts + if len(body.Trusts) == 0 { + return newErrMissingRequestBodyField("trusts") + } + if x.reqTrusts != nil { + if v1, v2 := len(x.reqTrusts), len(body.Trusts); v1 != v2 { + return fmt.Errorf("number of trusts (client: %d, message: %d)", v1, v2) + } + for i := range x.reqTrusts { + if err := checkTrustTransport(x.reqTrusts[i], body.Trusts[i]); err != nil { + return newErrInvalidRequestField("trusts", fmt.Errorf("element #%d: %w", i, err)) + } + } + } + return nil } -func (x *testAnnounceLocalTrustServer) AnnounceLocalTrust(context.Context, *protoreputation.AnnounceLocalTrustRequest, +func (x *testAnnounceLocalTrustServer) AnnounceLocalTrust(_ context.Context, req *protoreputation.AnnounceLocalTrustRequest, ) (*protoreputation.AnnounceLocalTrustResponse, error) { - var resp protoreputation.AnnounceLocalTrustResponse + if err := x.verifyRequest(req); err != nil { + return nil, err + } - var respV2 reputation.AnnounceLocalTrustResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) + resp := protoreputation.AnnounceLocalTrustResponse{ + MetaHeader: x.respMeta, } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinAnnounceLocalTrustResponseBody).(*protoreputation.AnnounceLocalTrustResponse_Body) } - return respV2.ToGRPCMessage().(*protoreputation.AnnounceLocalTrustResponse), nil + return x.signResponse(&resp) +} + +func TestClient_AnnounceIntermediateTrust(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmAnnounceIntermediateTrust + const anyValidEpoch = 123 + anyValidTrust := reputationtest.PeerToPeerTrust() + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestAnnounceIntermediateReputationServer() + c := newTestReputationClient(t, srv) + + srv.checkRequestEpoch(anyValidEpoch) + srv.checkRequestTrust(anyValidTrust) + srv.authenticateRequest(c.prm.signer) + err := c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestAnnounceIntermediateReputationServer, newTestReputationClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, opts) + }) + }) + t.Run("iteration", func(t *testing.T) { + srv := newTestAnnounceIntermediateReputationServer() + c := newTestReputationClient(t, srv) + + iter := rand.Uint32() + opts := anyValidOpts + opts.SetIteration(iter) + + srv.checkRequestIteration(iter) + err := c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoreputation.AnnounceIntermediateResultResponse_Body + }{ + {name: "min", body: validMinAnnounceIntermediateRepResponseBody}, + {name: "full", body: validFullAnnounceIntermediateRepResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestAnnounceIntermediateReputationServer() + c := newTestReputationClient(t, srv) + + srv.respondWithBody(tc.body) + err := c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + require.NoError(t, err) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestAnnounceIntermediateReputationServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "reputation.ReputationService", "AnnounceIntermediateResult", func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestAnnounceIntermediateReputationServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("zero epoch", func(t *testing.T) { + err := c.AnnounceIntermediateTrust(ctx, 0, anyValidTrust, anyValidOpts) + require.ErrorIs(t, err, ErrZeroEpoch) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestAnnounceIntermediateReputationServer, newTestReputationClient, func(ctx context.Context, c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestAnnounceIntermediateReputationServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestAnnounceIntermediateReputationServer, newDefaultReputationServiceDesc, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestAnnounceIntermediateReputationServer, newDefaultReputationServiceDesc, stat.MethodAnnounceIntermediateTrust, + nil, []testedClientOp{func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, 0, anyValidTrust, anyValidOpts) + }, + }, func(c *Client) error { + return c.AnnounceIntermediateTrust(ctx, anyValidEpoch, anyValidTrust, anyValidOpts) + }, + ) + }) +} + +func TestClient_AnnounceLocalTrust(t *testing.T) { + ctx := context.Background() + var anyValidOpts PrmAnnounceLocalTrust + const anyValidEpoch = 123 + anyValidTrusts := []reputation.Trust{reputationtest.Trust(), reputationtest.Trust()} + + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestAnnounceLocalTrustServer() + c := newTestReputationClient(t, srv) + + srv.checkRequestEpoch(anyValidEpoch) + srv.checkRequestTrusts(anyValidTrusts) + srv.authenticateRequest(c.prm.signer) + err := c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestAnnounceLocalTrustServer, newTestReputationClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, opts) + }) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protoreputation.AnnounceLocalTrustResponse_Body + }{ + {name: "min", body: validMinAnnounceLocalTrustResponseBody}, + {name: "full", body: validFullAnnounceLocalTrustRepResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestAnnounceLocalTrustServer() + c := newTestReputationClient(t, srv) + + srv.respondWithBody(tc.body) + err := c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + require.NoError(t, err) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestAnnounceLocalTrustServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "reputation.ReputationService", "AnnounceLocalTrust", func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestAnnounceLocalTrustServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("zero epoch", func(t *testing.T) { + err := c.AnnounceLocalTrust(ctx, 0, anyValidTrusts, anyValidOpts) + require.ErrorIs(t, err, ErrZeroEpoch) + }) + t.Run("empty trusts", func(t *testing.T) { + err := c.AnnounceLocalTrust(ctx, anyValidEpoch, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingTrusts) + err = c.AnnounceLocalTrust(ctx, anyValidEpoch, []reputation.Trust{}, anyValidOpts) + require.ErrorIs(t, err, ErrMissingTrusts) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestAnnounceLocalTrustServer, newTestReputationClient, func(ctx context.Context, c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + t.Run("sign request failure", func(t *testing.T) { + testSignRequestFailure(t, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestAnnounceLocalTrustServer, newTestReputationClient, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestAnnounceLocalTrustServer, newDefaultReputationServiceDesc, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestAnnounceLocalTrustServer, newDefaultReputationServiceDesc, stat.MethodAnnounceLocalTrust, + nil, []testedClientOp{func(c *Client) error { + return c.AnnounceLocalTrust(ctx, 0, anyValidTrusts, anyValidOpts) + }, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, nil, anyValidOpts) + }}, func(c *Client) error { + return c.AnnounceLocalTrust(ctx, anyValidEpoch, anyValidTrusts, anyValidOpts) + }, + ) + }) } diff --git a/client/session_test.go b/client/session_test.go index dd9dcc0e..49d276c6 100644 --- a/client/session_test.go +++ b/client/session_test.go @@ -2,80 +2,273 @@ package client import ( "context" + "errors" + "fmt" + "math/rand" "testing" - "github.com/nspcc-dev/neofs-api-go/v2/session" + apisession "github.com/nspcc-dev/neofs-api-go/v2/session" protosession "github.com/nspcc-dev/neofs-api-go/v2/session/grpc" - neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto" - neofscryptotest "github.com/nspcc-dev/neofs-sdk-go/crypto/test" + "github.com/nspcc-dev/neofs-sdk-go/stat" + "github.com/nspcc-dev/neofs-sdk-go/user" usertest "github.com/nspcc-dev/neofs-sdk-go/user/test" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -// returns Client of Session service provided by given server. -func newTestSessionClient(t testing.TB, srv protosession.SessionServiceServer) *Client { - return newClient(t, testService{desc: &protosession.SessionService_ServiceDesc, impl: srv}) +// returns Client-compatible Session service handled by given server. Provided +// server must implement [protosession.SessionServiceServer]: the parameter is +// not of this type to support generics. +func newDefaultSessionServiceDesc(t testing.TB, srv any) testService { + require.Implements(t, (*protosession.SessionServiceServer)(nil), srv) + return testService{desc: &protosession.SessionService_ServiceDesc, impl: srv} +} + +// returns Client of Session service provided by given server. Provided server +// must implement [protosession.SessionServiceServer]: the parameter is not of +// this type to support generics. +func newTestSessionClient(t testing.TB, srv any) *Client { + return newClient(t, newDefaultSessionServiceDesc(t, srv)) } type testCreateSessionServer struct { protosession.UnimplementedSessionServiceServer - signer neofscrypto.Signer - - unsetID bool - unsetKey bool + testCommonServerSettings[ + *protosession.CreateRequest_Body, + apisession.CreateRequestBody, + *apisession.CreateRequestBody, + *protosession.CreateRequest, + apisession.CreateRequest, + *apisession.CreateRequest, + *protosession.CreateResponse_Body, + protosession.CreateResponse, + apisession.CreateResponse, + *apisession.CreateResponse, + ] + reqUsr *user.ID + reqExp uint64 } -func (m *testCreateSessionServer) Create(context.Context, *protosession.CreateRequest) (*protosession.CreateResponse, error) { - resp := protosession.CreateResponse{ - Body: new(protosession.CreateResponse_Body), - } +// returns [protosession.SessionServiceServer] supporting Create method only. +// Default implementation performs common verification of any request, and +// responds with any valid message. Some methods allow to tune the behavior. +func newTestCreateSessionInfoServer() *testCreateSessionServer { return new(testCreateSessionServer) } + +// makes the server to assert that any request is for the given user. By +// default, any user is accepted. +func (x *testCreateSessionServer) checkRequestAccount(usr user.ID) { x.reqUsr = &usr } - if !m.unsetID { - resp.Body.Id = []byte{1} +// makes the server to assert that any request has given expiration epoch. By +// default, expiration must be unset. +func (x *testCreateSessionServer) checkRequestExpirationEpoch(epoch uint64) { x.reqExp = epoch } + +func (x *testCreateSessionServer) verifyRequest(req *protosession.CreateRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err } - if !m.unsetKey { - resp.Body.SessionKey = []byte{1} + // meta header + switch metaHdr := req.MetaHeader; { + case metaHdr.Ttl != 2: + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected 2", metaHdr.Ttl)) + case metaHdr.SessionToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("session token attached while should not be")) + case metaHdr.BearerToken != nil: + return newInvalidRequestMetaHeaderErr(errors.New("bearer token attached while should not be")) } - - var respV2 session.CreateResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) + // body + body := req.Body + if body == nil { + return newInvalidRequestBodyErr(errors.New("missing body")) + } + // 1. user + if body.OwnerId == nil { + return newErrMissingRequestBodyField("user") } - signer := m.signer - if signer == nil { - signer = neofscryptotest.Signer() + if x.reqUsr != nil { + if err := checkUserIDTransport(*x.reqUsr, body.OwnerId); err != nil { + return newErrInvalidRequestField("user", err) + } } - if err := signServiceMessage(signer, &respV2, nil); err != nil { + // 2. expiration epoch + if body.Expiration != x.reqExp { + return newErrInvalidRequestField("expiration epoch", errors.New("mismatches the test input")) + } + return nil +} + +func (x *testCreateSessionServer) Create(_ context.Context, req *protosession.CreateRequest) (*protosession.CreateResponse, error) { + if err := x.verifyRequest(req); err != nil { return nil, err } - return respV2.ToGRPCMessage().(*protosession.CreateResponse), nil + resp := protosession.CreateResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinCreateSessionResponseBody).(*protosession.CreateResponse_Body) + } + + return x.signResponse(&resp) } func TestClient_SessionCreate(t *testing.T) { ctx := context.Background() - usr := usertest.User() + var anyValidOpts PrmSessionCreate + anyUsr := usertest.User() - var prmSessionCreate PrmSessionCreate - prmSessionCreate.SetExp(1) + t.Run("messages", func(t *testing.T) { + /* + This test is dedicated for cases when user input results in sending a certain + request to the server and receiving a specific response to it. For user input + errors, transport, client internals, etc. see/add other tests. + */ + t.Run("requests", func(t *testing.T) { + t.Run("required data", func(t *testing.T) { + srv := newTestCreateSessionInfoServer() + c := newTestSessionClient(t, srv) - t.Run("missing session id", func(t *testing.T) { - srv := testCreateSessionServer{signer: usr, unsetID: true} - c := newTestSessionClient(t, &srv) + srv.checkRequestAccount(anyUsr.ID) + srv.authenticateRequest(anyUsr) + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + require.NoError(t, err) + }) + t.Run("options", func(t *testing.T) { + t.Run("X-headers", func(t *testing.T) { + testRequestXHeaders(t, newTestCreateSessionInfoServer, newTestSessionClient, func(c *Client, xhs []string) error { + opts := anyValidOpts + opts.WithXHeaders(xhs...) + _, err := c.SessionCreate(ctx, anyUsr, opts) + return err + }) + }) + t.Run("expiration epoch", func(t *testing.T) { + srv := newTestCreateSessionInfoServer() + c := newTestSessionClient(t, srv) - result, err := c.SessionCreate(ctx, usr, prmSessionCreate) - require.Nil(t, result) - require.ErrorIs(t, err, ErrMissingResponseField) - require.Equal(t, "missing session id field in the response", err.Error()) - }) + epoch := rand.Uint64() + var opts PrmSessionCreate + opts.SetExp(epoch) + + srv.checkRequestExpirationEpoch(epoch) + _, err := c.SessionCreate(ctx, anyUsr, opts) + require.NoError(t, err) + }) + }) + }) + t.Run("responses", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + t.Run("payloads", func(t *testing.T) { + for _, tc := range []struct { + name string + body *protosession.CreateResponse_Body + }{ + {name: "min", body: validMinCreateSessionResponseBody}, + {name: "full", body: validFullCreateSessionResponseBody}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newTestCreateSessionInfoServer() + c := newTestSessionClient(t, srv) - t.Run("missing session key", func(t *testing.T) { - srv := testCreateSessionServer{signer: usr, unsetKey: true} - c := newTestSessionClient(t, &srv) + srv.respondWithBody(tc.body) + res, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, validFullCreateSessionResponseBody.Id, res.ID()) + require.Equal(t, validFullCreateSessionResponseBody.SessionKey, res.PublicKey()) + }) + } + }) + t.Run("statuses", func(t *testing.T) { + testStatusResponses(t, newTestCreateSessionInfoServer, newTestSessionClient, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + }) + t.Run("invalid", func(t *testing.T) { + t.Run("format", func(t *testing.T) { + testIncorrectUnaryRPCResponseFormat(t, "session.SessionService", "Create", func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + t.Run("verification header", func(t *testing.T) { + testInvalidResponseVerificationHeader(t, newTestCreateSessionInfoServer, newTestSessionClient, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + t.Run("payloads", func(t *testing.T) { + type testcase = invalidResponseBodyTestcase[protosession.CreateResponse_Body] + tcs := []testcase{ + {name: "missing", body: nil, assertErr: func(t testing.TB, err error) { + require.EqualError(t, err, "missing session id field in the response") + // TODO: worth clarifying that body is completely missing + }}, + {name: "ID/missing", body: &protosession.CreateResponse_Body{ + SessionKey: validFullCreateSessionResponseBody.SessionKey, + }, assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing session id field in the response") + }}, + {name: "session public key/missing", body: &protosession.CreateResponse_Body{ + Id: validFullCreateSessionResponseBody.Id, + }, assertErr: func(t testing.TB, err error) { + require.ErrorIs(t, err, MissingResponseFieldErr{}) + require.EqualError(t, err, "missing session key field in the response") + }}, + } - result, err := c.SessionCreate(ctx, usr, prmSessionCreate) - require.Nil(t, result) - require.ErrorIs(t, err, ErrMissingResponseField) - require.Equal(t, "missing session key field in the response", err.Error()) + testInvalidResponseBodies(t, newTestCreateSessionInfoServer, newTestSessionClient, tcs, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + }) + }) + }) + t.Run("invalid user input", func(t *testing.T) { + c := newClient(t) + t.Run("missing signer", func(t *testing.T) { + _, err := c.SessionCreate(ctx, nil, anyValidOpts) + require.ErrorIs(t, err, ErrMissingSigner) + }) + }) + t.Run("context", func(t *testing.T) { + testContextErrors(t, newTestCreateSessionInfoServer, newTestSessionClient, func(ctx context.Context, c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + t.Run("sign request failure", func(t *testing.T) { + _, err := newClient(t).SessionCreate(ctx, usertest.FailSigner(anyUsr), anyValidOpts) + assertSignRequestErr(t, err) + }) + t.Run("transport failure", func(t *testing.T) { + testTransportFailure(t, newTestCreateSessionInfoServer, newTestSessionClient, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + t.Run("response callback", func(t *testing.T) { + testResponseCallback(t, newTestCreateSessionInfoServer, newDefaultSessionServiceDesc, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }) + }) + t.Run("exec statistics", func(t *testing.T) { + testStatistic(t, newTestCreateSessionInfoServer, newDefaultSessionServiceDesc, stat.MethodSessionCreate, + []testedClientOp{ + func(c *Client) error { + _, err := c.SessionCreate(ctx, nil, anyValidOpts) + return err + }, + }, nil, func(c *Client) error { + _, err := c.SessionCreate(ctx, anyUsr, anyValidOpts) + return err + }, + ) }) }