diff --git a/pkg/api/postage.go b/pkg/api/postage.go index 7cd74a23e87..a1f4f85eac6 100644 --- a/pkg/api/postage.go +++ b/pkg/api/postage.go @@ -496,6 +496,45 @@ func (s *Service) estimateBatchTTL(batch *postage.Batch) (int64, error) { return ttl.Int64(), nil } +type postageLabelUpdateRequest struct { + Label string `json:"label"` +} + +func (s *Service) postageUpdateLabelHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("patch_stamp").Build() + + paths := struct { + BatchID []byte `map:"batch_id" validate:"required,len=32"` + }{} + if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + response("invalid path params", logger, w) + return + } + hexBatchID := hex.EncodeToString(paths.BatchID) + + body := postageLabelUpdateRequest{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + logger.Debug("patch stamp: decode body failed", "batch_id", hexBatchID, "error", err) + logger.Error(nil, "patch stamp: decode body failed") + jsonhttp.BadRequest(w, "invalid request body") + return + } + + if err := s.post.UpdateIssuerLabel(paths.BatchID, body.Label); err != nil { + logger.Debug("patch stamp: update label failed", "batch_id", hexBatchID, "error", err) + logger.Error(nil, "patch stamp: update label failed") + switch { + case errors.Is(err, postage.ErrNotFound): + jsonhttp.NotFound(w, "issuer does not exist") + default: + jsonhttp.InternalServerError(w, "cannot update label") + } + return + } + + jsonhttp.OK(w, nil) +} + func (s *Service) postageTopUpHandler(w http.ResponseWriter, r *http.Request) { logger := s.logger.WithName("patch_stamp_topup").Build() diff --git a/pkg/api/postage_test.go b/pkg/api/postage_test.go index c83aceefe6a..1cf9b8e03ca 100644 --- a/pkg/api/postage_test.go +++ b/pkg/api/postage_test.go @@ -1198,3 +1198,121 @@ func Test_postageDiluteHandler_invalidInputs(t *testing.T) { }) } } + +func TestPostageUpdateLabelStamp(t *testing.T) { + t.Parallel() + + batchID := batchOk + batchIDStr := batchOkStr + updatePath := "/stamps/" + batchIDStr + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + si := postage.NewStampIssuer("original", "test identity", batchID, big.NewInt(3), 24, 6, 1000, false) + mp := mockpost.New(mockpost.WithIssuer(si)) + ts, _, _, _ := newTestServer(t, testServerOptions{ + Post: mp, + }) + + jsonhttptest.Request(t, ts, http.MethodPatch, updatePath, http.StatusOK, + jsonhttptest.WithRequestHeader("Content-Type", "application/json"), + jsonhttptest.WithRequestBody(bytes.NewBufferString(`{"label":"updated"}`)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{Code: http.StatusOK, Message: "OK"}), + ) + }) + + t.Run("not-found", func(t *testing.T) { + t.Parallel() + + mp := mockpost.New() + ts, _, _, _ := newTestServer(t, testServerOptions{ + Post: mp, + }) + + jsonhttptest.Request(t, ts, http.MethodPatch, updatePath, http.StatusNotFound, + jsonhttptest.WithRequestHeader("Content-Type", "application/json"), + jsonhttptest.WithRequestBody(bytes.NewBufferString(`{"label":"updated"}`)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{Code: http.StatusNotFound, Message: "issuer does not exist"}), + ) + }) + + t.Run("invalid-body", func(t *testing.T) { + t.Parallel() + + si := postage.NewStampIssuer("original", "test identity", batchID, big.NewInt(3), 24, 6, 1000, false) + mp := mockpost.New(mockpost.WithIssuer(si)) + ts, _, _, _ := newTestServer(t, testServerOptions{ + Post: mp, + }) + + jsonhttptest.Request(t, ts, http.MethodPatch, updatePath, http.StatusBadRequest, + jsonhttptest.WithRequestHeader("Content-Type", "application/json"), + jsonhttptest.WithRequestBody(bytes.NewBufferString(`not-json`)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{Code: http.StatusBadRequest, Message: "invalid request body"}), + ) + }) +} + +//nolint:tparallel +func Test_postageUpdateLabelHandler_invalidInputs(t *testing.T) { + t.Parallel() + + client, _, _, _ := newTestServer(t, testServerOptions{}) + + tests := []struct { + name string + batchID string + want jsonhttp.StatusResponse + }{{ + name: "batch_id - odd hex string", + batchID: "123", + want: jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid path params", + Reasons: []jsonhttp.Reason{ + { + Field: "batch_id", + Error: api.ErrHexLength.Error(), + }, + }, + }, + }, { + name: "batch_id - invalid hex character", + batchID: "123G", + want: jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid path params", + Reasons: []jsonhttp.Reason{ + { + Field: "batch_id", + Error: api.HexInvalidByteError('G').Error(), + }, + }, + }, + }, { + name: "batch_id - invalid length", + batchID: "1234", + want: jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid path params", + Reasons: []jsonhttp.Reason{ + { + Field: "batch_id", + Error: "want len:32", + }, + }, + }, + }} + + //nolint:paralleltest + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodPatch, "/stamps/"+tc.batchID, tc.want.Code, + jsonhttptest.WithRequestHeader("Content-Type", "application/json"), + jsonhttptest.WithRequestBody(bytes.NewBufferString(`{"label":"x"}`)), + jsonhttptest.WithExpectedJSONResponse(tc.want), + ) + }) + } +} diff --git a/pkg/api/router.go b/pkg/api/router.go index 3d61fc98c00..0c5827615fc 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -593,7 +593,8 @@ func (s *Service) mountBusinessDebug() { s.checkChainAvailability, s.postageSyncStatusCheckHandler, web.FinalHandler(jsonhttp.MethodHandler{ - "GET": http.HandlerFunc(s.postageGetStampHandler), + "GET": http.HandlerFunc(s.postageGetStampHandler), + "PATCH": http.HandlerFunc(s.postageUpdateLabelHandler), })), ) diff --git a/pkg/api/router_test.go b/pkg/api/router_test.go index 8c13f459375..457dcb6e31b 100644 --- a/pkg/api/router_test.go +++ b/pkg/api/router_test.go @@ -103,7 +103,7 @@ func TestEndpointOptions(t *testing.T) { {"/wallet", []string{"GET"}, http.StatusNoContent}, {"/wallet/withdraw/{coin}", []string{"POST"}, http.StatusNoContent}, {"/stamps", []string{"GET"}, http.StatusNoContent}, - {"/stamps/{batch_id}", []string{"GET"}, http.StatusNoContent}, + {"/stamps/{batch_id}", []string{"GET", "PATCH"}, http.StatusNoContent}, {"/stamps/{batch_id}/buckets", []string{"GET"}, http.StatusNoContent}, {"/stamps/{amount}/{depth}", []string{"POST"}, http.StatusNoContent}, {"/stamps/topup/{batch_id}/{amount}", []string{"PATCH"}, http.StatusNoContent}, @@ -293,7 +293,7 @@ func TestEndpointOptions(t *testing.T) { {"/wallet", nil, http.StatusForbidden}, {"/wallet/withdraw/{coin}", nil, http.StatusForbidden}, {"/stamps", []string{"GET"}, http.StatusNoContent}, - {"/stamps/{batch_id}", []string{"GET"}, http.StatusNoContent}, + {"/stamps/{batch_id}", []string{"GET", "PATCH"}, http.StatusNoContent}, {"/stamps/{batch_id}/buckets", []string{"GET"}, http.StatusNoContent}, {"/stamps/{amount}/{depth}", []string{"POST"}, http.StatusNoContent}, {"/stamps/topup/{batch_id}/{amount}", []string{"PATCH"}, http.StatusNoContent}, @@ -388,7 +388,7 @@ func TestEndpointOptions(t *testing.T) { {"/wallet", nil, http.StatusForbidden}, {"/wallet/withdraw/{coin}", nil, http.StatusForbidden}, {"/stamps", []string{"GET"}, http.StatusNoContent}, - {"/stamps/{batch_id}", []string{"GET"}, http.StatusNoContent}, + {"/stamps/{batch_id}", []string{"GET", "PATCH"}, http.StatusNoContent}, {"/stamps/{batch_id}/buckets", []string{"GET"}, http.StatusNoContent}, {"/stamps/{amount}/{depth}", []string{"POST"}, http.StatusNoContent}, {"/stamps/topup/{batch_id}/{amount}", []string{"PATCH"}, http.StatusNoContent}, diff --git a/pkg/postage/mock/service.go b/pkg/postage/mock/service.go index 9584ffeeb1c..3d8c1b04e8d 100644 --- a/pkg/postage/mock/service.go +++ b/pkg/postage/mock/service.go @@ -99,6 +99,19 @@ func (m *mockPostage) IssuerUsable(_ *postage.StampIssuer) bool { return true } +func (m *mockPostage) UpdateIssuerLabel(id []byte, label string) error { + m.issuerLock.Lock() + defer m.issuerLock.Unlock() + + _, exists := m.issuersMap[string(id)] + if !exists { + return postage.ErrNotFound + } + // label is local metadata only, real persistence is handled by the service implementation. + _ = label + return nil +} + func (m *mockPostage) HandleCreate(_ *postage.Batch, _ *big.Int) error { return nil } func (m *mockPostage) HandleTopUp(_ []byte, _ *big.Int) {} diff --git a/pkg/postage/service.go b/pkg/postage/service.go index e6991bdcfa3..f4cd00a7133 100644 --- a/pkg/postage/service.go +++ b/pkg/postage/service.go @@ -46,6 +46,7 @@ type Service interface { StampIssuers() []*StampIssuer GetStampIssuer([]byte) (*StampIssuer, func() error, error) IssuerUsable(*StampIssuer) bool + UpdateIssuerLabel([]byte, string) error BatchEventListener BatchExpiryHandler io.Closer @@ -247,6 +248,20 @@ func (ps *service) GetStampIssuer(batchID []byte) (*StampIssuer, func() error, e return nil, nil, ErrNotFound } +// UpdateIssuerLabel updates the label of the stamp issuer with the given batch ID and persists the change. +func (ps *service) UpdateIssuerLabel(batchID []byte, label string) error { + ps.mtx.Lock() + defer ps.mtx.Unlock() + + for _, st := range ps.issuers { + if bytes.Equal(batchID, st.data.BatchID) { + st.data.Label = label + return ps.save(st) + } + } + return ErrNotFound +} + // save persists the specified stamp issuer to the stamperstore. func (ps *service) save(st *StampIssuer) error { st.mtx.Lock()