Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions openapi/Swarm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,37 @@ paths:
$ref: "SwarmCommon.yaml#/components/responses/400"
default:
description: Default response
patch:
summary: Update the label of an existing postage batch
tags:
- Postage Stamps
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
label:
type: string
description: New label for the postage batch
required:
- label
responses:
"200":
description: Label updated successfully
content:
application/json:
schema:
$ref: "SwarmCommon.yaml#/components/schemas/Response"
"400":
$ref: "SwarmCommon.yaml#/components/responses/400"
"404":
$ref: "SwarmCommon.yaml#/components/responses/404"
"500":
$ref: "SwarmCommon.yaml#/components/responses/500"
default:
description: Default response

"/stamps/{batch_id}/buckets":
parameters:
Expand Down
39 changes: 39 additions & 0 deletions pkg/api/postage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
118 changes: 118 additions & 0 deletions pkg/api/postage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
})
}
}
3 changes: 2 additions & 1 deletion pkg/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})),
)

Expand Down
6 changes: 3 additions & 3 deletions pkg/api/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down
13 changes: 13 additions & 0 deletions pkg/postage/mock/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
17 changes: 17 additions & 0 deletions pkg/postage/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -247,6 +248,22 @@ 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.mtx.Lock()
st.data.Label = label

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is data race on data.Label when we have concurent GET + PATCH on the same batch.

st.mtx.Unlock()
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()
Expand Down
60 changes: 60 additions & 0 deletions pkg/postage/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,63 @@ func TestCrashRecovery(t *testing.T) {
t.Errorf("post-recovery clean restart: bucket %d: want 1, got %d", bIdx1, buckets3[bIdx1])
}
}

func TestUpdateIssuerLabel(t *testing.T) {
t.Parallel()

store := inmemstore.New()
defer store.Close()
pstore := pstoremock.New()

ps, err := postage.NewService(log.Noop, store, pstore, 1, true)
if err != nil {
t.Fatal(err)
}
defer ps.Close()

issuer := newTestStampIssuer(t, 1000)
batchID := issuer.ID()

if err := ps.Add(issuer); err != nil {
t.Fatal(err)
}

t.Run("not found", func(t *testing.T) {
unknown := make([]byte, 32)
if err := ps.UpdateIssuerLabel(unknown, "x"); !errors.Is(err, postage.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
})

t.Run("updates in-memory and persists", func(t *testing.T) {
const newLabel = "updated-label"

if err := ps.UpdateIssuerLabel(batchID, newLabel); err != nil {
t.Fatal(err)
}

// in-memory label must reflect the update immediately
issuers := ps.StampIssuers()
var found bool
for _, si := range issuers {
if bytes.Equal(si.ID(), batchID) {
found = true
if si.Label() != newLabel {
t.Fatalf("in-memory label: got %q, want %q", si.Label(), newLabel)
}
}
}
if !found {
t.Fatal("issuer not found after update")
}

// persisted label must also reflect the update
item := postage.NewStampIssuerItem(batchID)
if err := store.Get(item); err != nil {
t.Fatal(err)
}
if item.Issuer.Label() != newLabel {
t.Fatalf("persisted label: got %q, want %q", item.Issuer.Label(), newLabel)
}
})
}
Loading