diff --git a/handlers/bounty.go b/handlers/bounty.go index 650661369..178bb576e 100644 --- a/handlers/bounty.go +++ b/handlers/bounty.go @@ -408,7 +408,7 @@ func (h *bountyHandler) GenerateBountyResponse(bounties []db.Bounty) []db.Bounty return bountyResponse } -func MakeBountyPayment(w http.ResponseWriter, r *http.Request) { +func (h *bountyHandler) MakeBountyPayment(w http.ResponseWriter, r *http.Request) { var m sync.Mutex m.Lock() @@ -429,7 +429,7 @@ func MakeBountyPayment(w http.ResponseWriter, r *http.Request) { return } - bounty := db.DB.GetBounty(id) + bounty := h.db.GetBounty(id) amount := bounty.Price if bounty.ID != id { @@ -445,7 +445,7 @@ func MakeBountyPayment(w http.ResponseWriter, r *http.Request) { // check if user is the admin of the organization // or has a pay bounty role - hasRole := db.UserHasAccess(pubKeyFromAuth, bounty.OrgUuid, db.PayBounty) + hasRole := h.db.UserHasAccess(pubKeyFromAuth, bounty.OrgUuid, db.PayBounty) if !hasRole { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode("You don't have appropriate permissions to pay bounties") @@ -454,7 +454,7 @@ func MakeBountyPayment(w http.ResponseWriter, r *http.Request) { // check if the organization bounty balance // is greater than the amount - orgBudget := db.DB.GetOrganizationBudget(bounty.OrgUuid) + orgBudget := h.db.GetOrganizationBudget(bounty.OrgUuid) if orgBudget.TotalBudget < amount { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode("organization budget is not enough to pay the amount") @@ -474,7 +474,7 @@ func MakeBountyPayment(w http.ResponseWriter, r *http.Request) { url := fmt.Sprintf("%s/payment", config.RelayUrl) - assignee := db.DB.GetPersonByPubkey(bounty.Assignee) + assignee := h.db.GetPersonByPubkey(bounty.Assignee) bodyData := utils.BuildKeysendBodyData(amount, assignee.OwnerPubKey, assignee.OwnerRouteHint) jsonBody := []byte(bodyData) diff --git a/handlers/bounty_test.go b/handlers/bounty_test.go index c50d72cd0..0ad1a846d 100644 --- a/handlers/bounty_test.go +++ b/handlers/bounty_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "strconv" "strings" + "sync" "testing" "time" @@ -1117,3 +1118,153 @@ func TestGetAllBounties(t *testing.T) { }) } + +func TestMakeBountyPayment(t *testing.T) { + ctx := context.Background() + mockDb := dbMocks.NewDatabase(t) + mockHttpClient := mocks.NewHttpClient(t) + bHandler := NewBountyHandler(mockHttpClient, mockDb) + + unauthorizedCtx := context.WithValue(ctx, auth.ContextKey, "") + authorizedCtx := context.WithValue(ctx, auth.ContextKey, "valid-key") + + var mutex sync.Mutex + var processingTimes []time.Time + + t.Run("mutex lock ensures sequential access", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mutex.Lock() + processingTimes = append(processingTimes, time.Now()) + time.Sleep(10 * time.Millisecond) + mutex.Unlock() + + bHandler.MakeBountyPayment(w, r) + })) + defer server.Close() + + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := http.Get(server.URL) + if err != nil { + t.Errorf("Failed to send request: %v", err) + } + }() + } + wg.Wait() + + for i := 1; i < len(processingTimes); i++ { + assert.True(t, processingTimes[i].After(processingTimes[i-1]), + "Expected processing times to be sequential, indicating mutex is locking effectively.") + } + }) + + t.Run("401 unauthorized error when unauthorized user hits endpoint", func(t *testing.T) { + + r := chi.NewRouter() + r.Post("/gobounties/pay/{id}", bHandler.MakeBountyPayment) + + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(unauthorizedCtx, http.MethodPost, "/gobounties/pay/1", nil) + + if err != nil { + t.Fatal(err) + } + + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code, "Expected 401 Unauthorized for unauthorized access") + mockDb.AssertExpectations(t) + }) + + t.Run("405 when trying to pay an already-paid bounty", func(t *testing.T) { + mockDb.On("GetBounty", mock.AnythingOfType("uint")).Return(db.Bounty{ + ID: 1, + Price: 1000, + OrgUuid: "org-1", + Assignee: "assignee-1", + Paid: true, + }, nil) + + mockDb.On("UserHasAccess", "valid-key", "org-1", db.PayBounty).Return(true) + mockDb.On("GetOrganizationBudget", "org-1").Return(db.BountyBudget{TotalBudget: 1000}, nil) + mockDb.On("GetPersonByPubkey", "assignee-1").Return(db.Person{ + OwnerPubKey: "assignee-1", + }, nil) + + r := chi.NewRouter() + r.Post("/gobounties/pay/{id}", bHandler.MakeBountyPayment) + + requestBody := bytes.NewBuffer([]byte("{}")) + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(authorizedCtx, http.MethodPost, "/gobounties/pay/1", requestBody) + if err != nil { + t.Fatal(err) + } + + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code, "Expected 405 Method Not Allowed for an already-paid bounty") + mockDb.AssertExpectations(t) + }) + + t.Run("401 error if user not organization admin or does not have PAY BOUNTY role", func(t *testing.T) { + mockDb.On("GetBounty", mock.AnythingOfType("uint")).Return(db.Bounty{ + ID: 1, + Price: 1000, + OrgUuid: "org-1", + Assignee: "assignee-1", + Paid: false, + }, nil) + mockDb.On("UserHasAccess", "valid-key", "org-1", db.PayBounty).Return(false) + + r := chi.NewRouter() + r.Post("/gobounties/pay/{id}", bHandler.MakeBountyPayment) + + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(unauthorizedCtx, http.MethodPost, "/gobounties/pay/1", bytes.NewBufferString(`{}`)) + if err != nil { + t.Fatal(err) + } + + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code, "Expected 401 Unauthorized when the user lacks the PAY BOUNTY role") + + }) + + t.Run("403 error when amount exceeds organization's budget balance", func(t *testing.T) { + ctx := context.WithValue(context.Background(), auth.ContextKey, "valid-key") + + mockDb := dbMocks.NewDatabase(t) + mockHttpClient := mocks.NewHttpClient(t) + bHandler := NewBountyHandler(mockHttpClient, mockDb) + mockDb.On("GetBounty", mock.AnythingOfType("uint")).Return(db.Bounty{ + ID: 1, + Price: 1000, + OrgUuid: "org-1", + Assignee: "assignee-1", + Paid: false, + }, nil) + mockDb.On("UserHasAccess", "valid-key", "org-1", db.PayBounty).Return(true) + mockDb.On("GetOrganizationBudget", "org-1").Return(db.BountyBudget{ + TotalBudget: 500, + }, nil) + + r := chi.NewRouter() + r.Post("/gobounties/pay/{id}", bHandler.MakeBountyPayment) + + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/gobounties/pay/1", nil) + if err != nil { + t.Fatal(err) + } + + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code, "Expected 403 Forbidden when the payment exceeds the organization's budget") + + }) + +} diff --git a/routes/bounty.go b/routes/bounty.go index 5101515d6..f25e6050e 100644 --- a/routes/bounty.go +++ b/routes/bounty.go @@ -31,7 +31,7 @@ func BountyRoutes() chi.Router { }) r.Group(func(r chi.Router) { r.Use(auth.PubKeyContext) - r.Post("/pay/{id}", handlers.MakeBountyPayment) + r.Post("/pay/{id}", bountyHandler.MakeBountyPayment) r.Post("/budget/withdraw", bountyHandler.BountyBudgetWithdraw) r.Post("/", bountyHandler.CreateOrEditBounty)