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
118 changes: 116 additions & 2 deletions backend/handlers/remittances.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package handlers

import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -34,7 +36,44 @@ func NewRemittanceHandler(db *gorm.DB, cfg *config.Config) *RemittanceHandler {
}
}

// Paginate is a GORM scope for pagination
const (
// MaxPage prevents integer overflow in offset calculation (#198)
MaxPage = 10000
)

// PaginationCursor represents cursor-based pagination state
type PaginationCursor struct {
CreatedAt time.Time `json:"created_at"`
ID uint `json:"id"`
}

// EncodeCursor encodes pagination cursor to base64 string
func EncodeCursor(cursor PaginationCursor) string {
data, _ := json.Marshal(cursor)
return base64.StdEncoding.EncodeToString(data)
}

// DecodeCursor decodes base64 cursor string to PaginationCursor
func DecodeCursor(encoded string) (PaginationCursor, error) {
var cursor PaginationCursor
if encoded == "" {
return cursor, nil
}

data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return cursor, fmt.Errorf("invalid cursor encoding: %w", err)
}

err = json.Unmarshal(data, &cursor)
if err != nil {
return cursor, fmt.Errorf("invalid cursor format: %w", err)
}

return cursor, nil
}

// Paginate is a GORM scope for pagination with overflow protection
func Paginate(c *gin.Context) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
page := 1
Expand All @@ -50,6 +89,11 @@ func Paginate(c *gin.Context) func(db *gorm.DB) *gorm.DB {
if page <= 0 {
page = 1
}
if page > MaxPage {
// Return error context to be handled by calling function
c.Set("pagination_error", errors.NewValidationError(fmt.Sprintf("Page number cannot exceed %d", MaxPage), nil))
return db
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
Expand Down Expand Up @@ -212,9 +256,73 @@ func (h *RemittanceHandler) GetRemittance(c *gin.Context) {
c.JSON(http.StatusOK, payment)
}

type ListRemittancesResponse struct {
Data []models.Payment `json:"data"`
Page int `json:"page,omitempty"` // Deprecated: use cursor instead
PageSize int `json:"page_size,omitempty"` // Deprecated: use limit instead
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}

func (h *RemittanceHandler) ListRemittances(c *gin.Context) {
var payments []models.Payment

// Support both cursor-based and legacy offset-based pagination
cursor := c.Query("cursor")
limitStr := c.Query("limit")

// Cursor-based pagination (preferred)
if cursor != "" || limitStr != "" {
// Cursor-based pagination eliminates overflow risk (#198)
limit := 20
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}

decodedCursor, err := DecodeCursor(cursor)
if err != nil {
c.Error(errors.NewValidationError("Invalid cursor format", err.Error()))
return
}

query := h.db.Model(&models.Payment{}).Order("created_at DESC, id DESC")

// Apply cursor filtering: WHERE created_at < cursor.CreatedAt OR (created_at = cursor.CreatedAt AND id < cursor.ID)
if !decodedCursor.CreatedAt.IsZero() {
query = query.Where("created_at < ? OR (created_at = ? AND id < ?)",
decodedCursor.CreatedAt, decodedCursor.CreatedAt, decodedCursor.ID)
}

if err := query.Limit(limit + 1).Find(&payments).Error; err != nil {
c.Error(errors.NewInternalError("Failed to fetch payments", err))
return
}

var nextCursor string
hasMore := len(payments) > limit
if hasMore {
// Remove the extra item used for has_more detection
lastItem := payments[limit-1]
payments = payments[:limit]
nextCursor = EncodeCursor(PaginationCursor{
CreatedAt: lastItem.CreatedAt,
ID: lastItem.ID,
})
}

response := ListRemittancesResponse{
Data: payments,
NextCursor: nextCursor,
HasMore: hasMore,
}

c.JSON(http.StatusOK, response)
return
}

// Legacy offset-based pagination for backward compatibility
// Cache key based on query params
cacheKey := fmt.Sprintf("payments:list:%s:%s", c.Query("page"), c.Query("page_size"))

Expand All @@ -225,12 +333,18 @@ func (h *RemittanceHandler) ListRemittances(c *gin.Context) {
return
}

// DB query with pagination
// DB query with pagination - check for pagination error from MaxPage validation
if err := h.db.Scopes(Paginate(c)).Order("created_at DESC").Find(&payments).Error; err != nil {
c.Error(errors.NewInternalError("Failed to fetch payments", err))
return
}

// Check if MaxPage validation failed
if paginationErr, exists := c.Get("pagination_error"); exists {
c.Error(paginationErr.(error))
return
}

// Set cache for 30 seconds
utils.SetCached(cacheKey, payments, 30*time.Second)

Expand Down
139 changes: 139 additions & 0 deletions backend/handlers/remittances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -189,3 +190,141 @@ func TestCreateRemittance(t *testing.T) {
assert.Equal(t, http.StatusCreated, w.Code)
})
}

// Test pagination overflow fix (#198)
func TestPaginationOverflow(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()

// Create some test payments
for i := 0; i < 5; i++ {
payment := models.Payment{
SenderID: 1,
RecipientID: 2,
Amount: float64(100 + i),
Currency: "USD",
Status: "pending",
}
db.Create(&payment)
}

mockStellar := &MockStellarClient{}
testCfg := &config.Config{}
handler := &RemittanceHandler{
db: db,
config: testCfg,
stellarClient: mockStellar,
fees: services.NewFeeService(testCfg),
}

router := gin.Default()
router.GET("/remittances", handler.ListRemittances)

t.Run("Valid MaxPage", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?page=10000&page_size=20", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
})

t.Run("Exceeds MaxPage", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?page=10001&page_size=20", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "cannot exceed 10000")
})

t.Run("Zero Page", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?page=0&page_size=20", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code) // Should default to page 1
})

t.Run("Negative Page", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?page=-1&page_size=20", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code) // Should default to page 1
})
}

// Test cursor-based pagination (#198)
func TestCursorPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()

// Create test payments with known timestamps
now := time.Now()
for i := 0; i < 5; i++ {
payment := models.Payment{
SenderID: 1,
RecipientID: 2,
Amount: float64(100 + i),
Currency: "USD",
Status: "pending",
CreatedAt: now.Add(time.Duration(i) * time.Hour), // Different timestamps
}
db.Create(&payment)
}

mockStellar := &MockStellarClient{}
testCfg := &config.Config{}
handler := &RemittanceHandler{
db: db,
config: testCfg,
stellarClient: mockStellar,
fees: services.NewFeeService(testCfg),
}

router := gin.Default()
router.GET("/remittances", handler.ListRemittances)

t.Run("Valid Cursor", func(t *testing.T) {
// First request with limit
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?limit=2", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var response ListRemittancesResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, 2, len(response.Data))
assert.True(t, response.HasMore)
assert.NotEmpty(t, response.NextCursor)
})

t.Run("Invalid Cursor", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?cursor=invalid_base64&limit=2", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Invalid cursor format")
})

t.Run("Empty Result Set", func(t *testing.T) {
// Clear all payments
db.Where("1 = 1").Delete(&models.Payment{})

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/remittances?limit=2", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var response ListRemittancesResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, 0, len(response.Data))
assert.False(t, response.HasMore)
assert.Empty(t, response.NextCursor)
})
}
Loading
Loading