feat(sca): add customer-set spend limit (#55)

This commit is contained in:
2026-06-17 13:25:04 +02:00
committed by GitHub
parent 22eeeeeac1
commit 7148ff1d4d
11 changed files with 613 additions and 12 deletions

111
api/handlers/account.go Normal file
View File

@@ -0,0 +1,111 @@
package handlers
import (
"encoding/json"
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AccountHandler serves JWT-authed, customer-facing account-settings endpoints.
// Unlike AdminUserHandler (X-Admin-Key gated), every route here edits the
// logged-in user's own row, identified by the JWT user id in context.
type AccountHandler struct {
// maxOrderCents is the global spend cap, needed to bound a self-limit: a
// customer may only TIGHTEN their effective ceiling, never raise it.
maxOrderCents int
}
// NewAccountHandler constructs an AccountHandler. maxOrderCents is the global
// MAX_ORDER_CENTS, wired from cfg in routes.SetupRoutes (same value the cart
// handler receives).
func NewAccountHandler(maxOrderCents int) *AccountHandler {
return &AccountHandler{maxOrderCents: maxOrderCents}
}
// SetSpendLimit sets or clears the logged-in user's self-service spend limit
// (User.SelfLimitCents). Body: {"self_limit_cents": <int|null>}.
// - null -> clears the self-limit; the user reverts to the admin/global cap
// - 0 -> a real "block everything" ceiling (NOT an off-switch; that is the
// whole point of a customer-set limit — it can only tighten)
// - >0 -> custom ceiling, must be <= the effective admin/global cap
//
// Security: this endpoint is JWT-authed and edits the caller's OWN row, so a
// self-limit is LOWER-ONLY. A value above the user's effective admin/global cap
// is rejected here (HTTP 400) so a compromised session cannot lift its own
// ceiling; the min() fold in cart.go:ConvertCart is the authoritative second
// guard. Negative values are rejected at the write boundary (the ConvertCart
// read path also ignores a stray negative from a direct DB write).
//
// Mirrors AdminUserHandler.SetUserOrderCap but reads the user id from the JWT
// context instead of a {id} URL param, uses a distinct body key + audit event,
// and gates the value against the cap.
func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawLimit, ok := raw["self_limit_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "self_limit_cents is required (use null to clear the limit)", nil)
return
}
var limitCents *int
if string(rawLimit) != "null" {
var n int
if err := json.Unmarshal(rawLimit, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "self_limit_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_LIMIT", "self_limit_cents must be >= 0 (use null to clear the limit)", nil)
return
}
limitCents = &n
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// A self-limit may only tighten the effective cap, never raise it. Compute
// the user's effective admin/global ceiling exactly as cart.go:ConvertCart
// does (per-user override wins when present and non-negative; an active cap
// is > 0). Reject any self-limit above an ACTIVE admin ceiling. When no admin
// ceiling is active (global disabled and no override) there is nothing to
// raise, so any non-negative self-limit is allowed — it can only ADD a cap.
if limitCents != nil {
adminCap := h.maxOrderCents
if user.MaxOrderCents != nil && *user.MaxOrderCents >= 0 {
adminCap = *user.MaxOrderCents
}
if adminCap > 0 && *limitCents > adminCap {
utils.RespondError(w, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", "self_limit_cents may not exceed your account spend cap", map[string]any{"self_limit_cents": *limitCents, "cap_cents": adminCap})
return
}
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"self_limit_cents": limitCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update spend limit", nil)
return
}
audit.UserSelfLimitSet(user.ID, limitCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"self_limit_cents": limitCents,
})
}

View File

@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func TestSetSpendLimit(t *testing.T) {
const globalCap = 5000
cases := []struct {
name string
seedSelf *int // starting SelfLimitCents on the row
seedAdmin *int // per-user MaxOrderCents override (effective admin cap)
body string
wantStatus int
wantCode string // error code; "" means success
wantLimit *int // expected SelfLimitCents after a successful update
}{
{"set positive under cap", nil, nil, `{"self_limit_cents":2000}`, http.StatusOK, "", intPtr(2000)},
{"set zero blocks all", nil, nil, `{"self_limit_cents":0}`, http.StatusOK, "", intPtr(0)},
{"set equal to cap", nil, nil, `{"self_limit_cents":5000}`, http.StatusOK, "", intPtr(5000)},
{"clear with null", intPtr(2000), nil, `{"self_limit_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing", intPtr(2000), nil, `{"self_limit_cents":1000}`, http.StatusOK, "", intPtr(1000)},
{"above global cap rejected", nil, nil, `{"self_limit_cents":6000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"under per-user override accepted", nil, intPtr(10000), `{"self_limit_cents":8000}`, http.StatusOK, "", intPtr(8000)},
{"above per-user override rejected", nil, intPtr(10000), `{"self_limit_cents":12000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"negative rejected", nil, nil, `{"self_limit_cents":-1}`, http.StatusBadRequest, "INVALID_LIMIT", nil},
{"missing field rejected", nil, nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, nil, `{"self_limit_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seedSelf != nil {
if err := db.Model(&user).Update("self_limit_cents", *tc.seedSelf).Error; err != nil {
t.Fatalf("seed self limit: %v", err)
}
}
if tc.seedAdmin != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seedAdmin).Error; err != nil {
t.Fatalf("seed admin cap: %v", err)
}
}
handler := NewAccountHandler(globalCap)
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(tc.body), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
assertErrCode(t, w, tc.wantCode)
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.SelfLimitCents, tc.wantLimit) {
t.Errorf("db SelfLimitCents: want %s got %s", fmtIntPtr(tc.wantLimit), fmtIntPtr(got.SelfLimitCents))
}
} else if !eqIntPtr(got.SelfLimitCents, tc.seedSelf) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db limit: want %s got %s", fmtIntPtr(tc.seedSelf), fmtIntPtr(got.SelfLimitCents))
}
})
}
}
// TestSetSpendLimit_CapDisabledAllowsAnyLimit: with no active admin ceiling
// (global 0, no override) any non-negative self-limit is accepted — a self-limit
// can only ADD a ceiling, so there is nothing to raise.
func TestSetSpendLimit_CapDisabledAllowsAnyLimit(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewAccountHandler(0) // global cap disabled
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(`{"self_limit_cents":999999}`), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.SelfLimitCents, intPtr(999999)) {
t.Errorf("db SelfLimitCents: want 999999 got %s", fmtIntPtr(got.SelfLimitCents))
}
}
// TestSetSpendLimit_RequireAuthGate exercises the JWT gate through a real
// router: no user context => 401, the handler is never reached.
func TestSetSpendLimit_RequireAuthGate(t *testing.T) {
setupOrderTestDB(t)
router := chi.NewRouter()
router.With(middleware.RequireAuth).
Put("/api/v1/account/spend-limit", NewAccountHandler(5000).SetSpendLimit)
req := httptest.NewRequest("PUT", "/api/v1/account/spend-limit", strings.NewReader(`{"self_limit_cents":1000}`))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
}

View File

@@ -54,6 +54,7 @@ func SetupRoutes(
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
adminUserHandler := handlers.NewAdminUserHandler()
accountHandler := handlers.NewAccountHandler(maxOrderCents)
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
@@ -105,6 +106,11 @@ func SetupRoutes(
r.Get("/view/init", viewHandler.GetViewInit)
// Account self-service settings, JWT-authed, edits the callers
// own row (user id from context, never a URL param). Distinct from
// the admin /users/{id}/order-cap setter in the RequireAdmin group.
r.Put("/account/spend-limit", accountHandler.SetSpendLimit)
// Cart
r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart)