From a2b4e8a36559dc8043d52105bcec4137f7469edf Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Mon, 25 Nov 2024 21:26:50 +0300 Subject: [PATCH] client: Increase test coverage of container ops Continues TBD. Signed-off-by: Leonard Lyubich --- client/accounting_test.go | 592 +++------ client/client_test.go | 944 ++++++++++++++- client/container_statistic_test.go | 201 ---- client/container_test.go | 1802 ++++++++++++++++++++++++++-- client/messages_test.go | 1280 ++++++++++++++++++++ client/netmap_test.go | 792 +++++++++--- client/object_delete_test.go | 265 +++- client/object_hash_test.go | 367 +++++- client/object_test.go | 111 +- client/reputation_test.go | 455 ++++++- client/session_test.go | 279 ++++- 11 files changed, 6028 insertions(+), 1060 deletions(-) create mode 100644 client/messages_test.go diff --git a/client/accounting_test.go b/client/accounting_test.go index 570d724a..f1efe32c 100644 --- a/client/accounting_test.go +++ b/client/accounting_test.go @@ -1,55 +1,47 @@ 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, + v2accounting.BalanceRequest, + *v2accounting.BalanceRequest, + *protoaccounting.BalanceResponse_Body, + protoaccounting.BalanceResponse, + v2accounting.BalanceResponse, + *v2accounting.BalanceResponse, + ] + reqAcc *user.ID } // returns [protoaccounting.AccountingServiceServer] supporting Balance method @@ -57,128 +49,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 +76,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 +114,136 @@ 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) _, 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..cef0a3be 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,17 +2,37 @@ 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" + 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 +69,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 +90,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 +99,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 +168,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 +293,830 @@ 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"), + ), +}) + +// various sets of cross-service testcases. +var ( + 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: "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 + } + }}, + } +) + +// 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[ + REQUEST interface { + GetMetaHeader() *protosession.RequestMetaHeader + }, + REQUESTV2 any, + REQUESTV2PTR interface { + *REQUESTV2 + FromGRPCMessage(apigrpc.Message) error + }, + RESPBODY proto.Message, + RESP any, + RESPV2 any, + RESPV2PTR interface { + *RESPV2 + ToGRPCMessage() apigrpc.Message + FromGRPCMessage(apigrpc.Message) error + }, +] struct { + handlerErr error + + 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 +} + +func (x testCommonServerSettings[REQUEST, REQUESTV2, REQUESTV2PTR, _, _, _, _]) verifyRequest(req REQUEST) error { + time.Sleep(x.respSleep) + + // signatures + var reqV2 REQUESTV2 + if err := REQUESTV2PTR(&reqV2).FromGRPCMessage(req); err != nil { + panic(err) + } + if err := verifyServiceMessage(&reqV2); err != nil { + return newInvalidRequestVerificationHeaderErr(err) + } + // meta header + metaHdr := req.GetMetaHeader() + 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[_, _, _, _, 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) + 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.EqualError(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.EqualError(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 index 2412d494..27df4298 100644 --- a/client/container_statistic_test.go +++ b/client/container_statistic_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "io" - mathRand "math/rand/v2" "strconv" "testing" "time" @@ -12,13 +11,10 @@ import ( "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" @@ -91,126 +87,6 @@ func prepareContainer(accountID user.ID) container.Container { 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() @@ -227,48 +103,6 @@ func TestClientStatistic_ContainerSyncContainerWithNetwork(t *testing.T) { 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() @@ -434,38 +268,3 @@ func TestClientStatistic_ObjectSearch(t *testing.T) { 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..af8d23e9 100644 --- a/client/container_test.go +++ b/client/container_test.go @@ -2,203 +2,1807 @@ package client import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "errors" "fmt" + "math/big" "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" 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/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 struct { + reqPub *ecdsa.PublicKey + reqDataSignature *neofscrypto.Signature +} + +// makes the server to assert that any request carries signature of the +// particular data calculated using given private key. By default, any key can +// be used. +// +// Has no effect with checkRequestDataSignature. +func (x *testRFC6979DataSignatureServerSettings) checkRequestDataSignerKey(pk ecdsa.PrivateKey) { + x.reqPub = &pk.PublicKey +} + +// 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) 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 + } + + reqPubX, reqPubY := elliptic.UnmarshalCompressed(elliptic.P256(), m.Key) + if reqPubX == nil { + return newErrInvalidRequestField(field, fmt.Errorf("invalid EC point binary %x", m.Key)) + } + if x.reqPub != nil && (reqPubX.Cmp(x.reqPub.X) != 0 || reqPubY.Cmp(x.reqPub.Y) != 0) { + return newErrInvalidRequestField(field, fmt.Errorf("EC point != the parameterized one")) + } + sig := m.Sign + if len(sig) != 64 { + return newErrInvalidRequestField(field, fmt.Errorf("invalid signature length %d", len(sig))) + } + h := sha256.Sum256(data) + if !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: reqPubX, Y: reqPubY}, h[:], + new(big.Int).SetBytes(sig[0:32]), new(big.Int).SetBytes(sig[32:])) { + return newErrInvalidRequestField(field, fmt.Errorf("signature mismatches the %s", signedField)) + } + return nil +} + +// 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, + apicontainer.PutRequest, + *apicontainer.PutRequest, + *protocontainer.PutResponse_Body, + protocontainer.PutResponse, + apicontainer.PutResponse, + *apicontainer.PutResponse, + ] + testContainerSessionServerSettings + testRFC6979DataSignatureServerSettings + 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 +} - var respV2 apicontainer.PutResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { +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 + var cnrV2 apicontainer.Container + if err := cnrV2.FromGRPCMessage(body.Container); err != nil { panic(err) } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + return x.verifyDataSignature("container", cnrV2.StableMarshal(nil), body.Signature) +} + +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 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, + 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, + 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")) } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + // 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 +} - return respV2.ToGRPCMessage().(*protocontainer.ListResponse), nil +func (x *testListContainersServer) List(_ context.Context, req *protocontainer.ListRequest) (*protocontainer.ListResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err + } + + resp := protocontainer.ListResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinListContainersResponseBody).(*protocontainer.ListResponse_Body) + } + + return x.signResponse(&resp) } type testDeleteContainerServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *protocontainer.DeleteRequest, + apicontainer.DeleteRequest, + *apicontainer.DeleteRequest, + *protocontainer.DeleteResponse_Body, + protocontainer.DeleteResponse, + apicontainer.DeleteResponse, + *apicontainer.DeleteResponse, + ] + testContainerSessionServerSettings + testRequiredContainerIDServerSettings + testRFC6979DataSignatureServerSettings } -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")) } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + // 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 } - return respV2.ToGRPCMessage().(*protocontainer.DeleteResponse), nil + resp := protocontainer.DeleteResponse{ + MetaHeader: x.respMeta, + } + if x.respBodyForced { + resp.Body = x.respBody + } else { + resp.Body = proto.Clone(validMinDeleteContainerResponseBody).(*protocontainer.DeleteResponse_Body) + } + + return x.signResponse(&resp) } type testGetEACLServer struct { protocontainer.UnimplementedContainerServiceServer + testCommonServerSettings[ + *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) +} + +func (x *testGetEACLServer) GetExtendedACL(_ context.Context, req *protocontainer.GetExtendedACLRequest) (*protocontainer.GetExtendedACLResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - var respV2 apicontainer.GetExtendedACLResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(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, + apicontainer.SetExtendedACLRequest, + *apicontainer.SetExtendedACLRequest, + *protocontainer.SetExtendedACLResponse_Body, + protocontainer.SetExtendedACLResponse, + apicontainer.SetExtendedACLResponse, + *apicontainer.SetExtendedACLResponse, + ] + testContainerSessionServerSettings + testRFC6979DataSignatureServerSettings + 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 { +// 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")) + } + // 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 + var eACLV2 v2acl.Table + if err := eACLV2.FromGRPCMessage(body.Eacl); err != nil { panic(err) } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", err) + return x.verifyDataSignature("eACL", eACLV2.StableMarshal(nil), 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, + 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)) + } + } } - if err := signServiceMessage(neofscryptotest.Signer(), &respV2, nil); err != nil { - return nil, fmt.Errorf("sign response message: %w", 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 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) + + signer := neofscryptotest.Signer() + + srv.checkRequestContainer(anyValidContainer) + srv.checkRequestDataSignerKey(signer.ECDSAPrivateKey) + _, err := c.ContainerPut(ctx, anyValidContainer, signer.RFC6979, 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) + }}) + } - 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 - }, + 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) + _, 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 []struct { + name, msg string + corrupt func(valid []byte) []byte + }{ + {name: "undersize", msg: "invalid nonce: invalid UUID (got 15 bytes)", corrupt: func(valid []byte) []byte { + return valid[:15] + }}, + {name: "oversize", msg: "invalid nonce: invalid UUID (got 17 bytes)", corrupt: func(valid []byte) []byte { + return append(valid, 1) + }}, + {name: "wrong version", msg: "invalid nonce UUID version 3", corrupt: func(valid []byte) []byte { + valid[6] = 3 << 4 + return valid + }}, + } { + ctcs = append(ctcs, invalidContainerTestcase{ + name: "nonce/" + tc.name, msg: 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) + _, 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() - for _, test := range tt { - t.Run(test.name, func(t *testing.T) { - require.ErrorIs(t, test.methodCall(), ErrMissingSigner) + 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) + + signer := neofscryptotest.Signer() + + srv.checkRequestContainerID(anyID) + srv.checkRequestDataSignerKey(signer.ECDSAPrivateKey) + err := c.ContainerDelete(ctx, anyID, signer.RFC6979, 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") + }) + 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) + _, 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) + + signer := usertest.User() + + srv.checkRequestEACL(anyValidEACL) + srv.checkRequestDataSignerKey(signer.ECDSAPrivateKey) + err := c.ContainerSetEACL(ctx, anyValidEACL, signer.RFC6979, 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") + }) + 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) + 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/messages_test.go b/client/messages_test.go new file mode 100644 index 00000000..e3a19de8 --- /dev/null +++ b/client/messages_test.go @@ -0,0 +1,1280 @@ +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/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" + 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), + } +) + +// 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: &protorefs.Signature{ + Key: []byte("any_key"), + Sign: []byte("any_signature"), + Scheme: protorefs.SignatureScheme(rand.Int31()), + }, + }, + } + // 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 ( + // 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), + } +) + +// 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 +} diff --git a/client/netmap_test.go b/client/netmap_test.go index bf01100c..58ded7ce 100644 --- a/client/netmap_test.go +++ b/client/netmap_test.go @@ -8,128 +8,191 @@ 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, + 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, + 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, + v2netmap.LocalNodeInfoRequest, + *v2netmap.LocalNodeInfoRequest, + *protonetmap.LocalNodeInfoResponse_Body, + protonetmap.LocalNodeInfoResponse, + v2netmap.LocalNodeInfoResponse, + *v2netmap.LocalNodeInfoResponse, + ] } // returns [protonetmap.NetmapServiceServer] supporting LocalNodeInfo method @@ -137,127 +200,476 @@ 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) + + _, 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) + + _, 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) + + _, 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..dc61dbf4 100644 --- a/client/object_delete_test.go +++ b/client/object_delete_test.go @@ -2,49 +2,272 @@ 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, + 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) + _, 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: "container ID/invalid", 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_hash_test.go b/client/object_hash_test.go index 7b4d1188..64c1c1cb 100644 --- a/client/object_hash_test.go +++ b/client/object_hash_test.go @@ -1,51 +1,376 @@ package client import ( + "bytes" "context" + "errors" "fmt" + "math" + "math/rand" "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" ) type testHashObjectPayloadRangesServer struct { protoobject.UnimplementedObjectServiceServer + testCommonServerSettings[ + *protoobject.GetRangeHashRequest, + v2object.GetRangeHashRequest, + *v2object.GetRangeHashRequest, + *protoobject.GetRangeHashResponse_Body, + protoobject.GetRangeHashResponse, + v2object.GetRangeHashResponse, + *v2object.GetRangeHashResponse, + ] + testObjectSessionServerSettings + testBearerTokenServerSettings + testObjectAddressServerSettings + reqHomo bool + reqRanges []uint64 + reqSalt []byte + reqLocal bool } -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 } + +// makes the server to assert that any request has TTL = 1. By default, TTL must +// be 2. +func (x *testHashObjectPayloadRangesServer) checkRequestLocal() { x.reqLocal = true } + +func (x *testHashObjectPayloadRangesServer) verifyRequest(req *protoobject.GetRangeHashRequest) error { + if err := x.testCommonServerSettings.verifyRequest(req); err != nil { + return err + } + // meta header + metaHdr := req.MetaHeader + // TTL + var expTTL uint32 + if x.reqLocal { + expTTL = 1 + } else { + expTTL = 2 + } + if metaHdr.Ttl != expTTL { + return newInvalidRequestMetaHeaderErr(fmt.Errorf("wrong TTL %d, expected %d", metaHdr.Ttl, expTTL)) + } + // session token + if err := x.verifySessionToken(metaHdr.SessionToken); err != nil { + return err + } + // bearer token + if err := x.verifyBearerToken(metaHdr.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 + } + // 2. ranges + if len(body.Ranges) == 0 { + return newInvalidRequestBodyErr(errors.New("missing ranges")) + } + for i := range body.Ranges { + if body.Ranges[i] == nil { + return newErrInvalidRequestField("ranges", fmt.Errorf("nil element #%d", i)) + } + } + if x.reqRanges != nil { + if exp, act := len(x.reqRanges), 2*len(body.Ranges); exp != act { + return newErrInvalidRequestField("ranges", fmt.Errorf("number of elements %d != parameterized %d", act, exp)) + } + for i, r := range body.Ranges { + if r.Offset != x.reqRanges[2*i] || r.Length != x.reqRanges[2*i+1] { + return newErrInvalidRequestField("ranges", fmt.Errorf("element #%d != the parameterized one", i)) + } + } + } + // 3. salt + if x.reqSalt != nil && !bytes.Equal(body.Salt, x.reqSalt) { + return newErrInvalidRequestField("salt", fmt.Errorf("%x != parameterized %x", body.Salt, x.reqSalt)) + } + // 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("%d != parameterized %d", body.Type, expType)) + } + return nil +} + +func (x *testHashObjectPayloadRangesServer) GetRangeHash(_ context.Context, req *protoobject.GetRangeHashRequest) (*protoobject.GetRangeHashResponse, error) { + if err := x.verifyRequest(req); err != nil { + return nil, err } - var respV2 v2object.GetRangeHashResponse - if err := respV2.FromGRPCMessage(&resp); err != nil { - panic(err) + resp := protoobject.GetRangeHashResponse{ + 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 = &protoobject.GetRangeHashResponse_Body{ + Type: protorefs.ChecksumType(rand.Int31()), + HashList: [][]byte{[]byte("any")}, + } } - 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...) + + srv.checkRequestRanges(rs) + _, 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) + + 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) - t.Run("missing signer", func(t *testing.T) { - var reqBody v2object.GetRangeHashRequestBody - reqBody.SetRanges(make([]v2object.Range, 1)) + 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) - _, err := c.ObjectHash(context.Background(), cid.ID{}, oid.ID{}, nil, PrmObjectHash{ - body: reqBody, + 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) { + anyValidHashes := [][]byte{{1}, {2}, {3}} + for _, tc := range []struct { + name string + body *protoobject.GetRangeHashResponse_Body + }{ + // TODO: why is it even needed? + {name: "type/negative", body: &protoobject.GetRangeHashResponse_Body{ + Type: -1, HashList: anyValidHashes, + }}, + {name: "type/unset", body: &protoobject.GetRangeHashResponse_Body{ + Type: 0, HashList: anyValidHashes, + }}, + {name: "type/unsupported", body: &protoobject.GetRangeHashResponse_Body{ + Type: math.MaxInt32, HashList: anyValidHashes, + }}, + {name: "full", body: &protoobject.GetRangeHashResponse_Body{ + Type: protorefs.ChecksumType_TZ, HashList: anyValidHashes, + }}, + } { + 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, anyValidHashes, 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_test.go b/client/object_test.go index 86cd3650..003652d8 100644 --- a/client/object_test.go +++ b/client/object_test.go @@ -1,12 +1,117 @@ package client import ( + "errors" + "fmt" "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" ) -// 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}) +// 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 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..119d3117 100644 --- a/client/reputation_test.go +++ b/client/reputation_test.go @@ -2,53 +2,466 @@ 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, + 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, + 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) + 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) + 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..97ddb949 100644 --- a/client/session_test.go +++ b/client/session_test.go @@ -2,80 +2,269 @@ 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, + 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) + _, 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 + }, + ) }) }