feat(sca): enforce user self-limit as lower-only cap in convert-cart

This commit is contained in:
2026-06-17 10:07:30 +02:00
parent aa446cdfa5
commit 33a6e31aaa
3 changed files with 51 additions and 18 deletions

View File

@@ -399,7 +399,21 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
}
if capCents > 0 && total > capCents {
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
// a compromised account must not be able to lift its own ceiling to drain a
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
// present self-limit always activates a ceiling, a self-limit of 0 means
// "block everything", not "disable", so it folds in via min() and can only
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
// guard above); this min() is the authoritative lower-only guard.
capActive := capCents > 0
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
if self := *user.SelfLimitCents; !capActive || self < capCents {
capCents = self
}
capActive = true
}
if capActive && total > capCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",

View File

@@ -680,31 +680,48 @@ func TestConvertCartLimit(t *testing.T) {
capCents int
quantity int
expectOverCap bool
userCap *int // per-user override; nil = inherit the global capCents
userCap *int // per-user admin override; nil = inherit the global capCents
selfLimit *int // user self-limit; nil = none. Lower-only: tightens, never raises.
}{
{"over cap rejects", 20000, 41, true, nil}, // $205 vs global $200
{"at cap accepts", 20000, 40, false, nil}, // $200 boundary
{"under cap accepts", 20000, 39, false, nil}, // $195
{"zero cap disables", 0, 1000, false, nil}, // explicit global off-switch
// Per-user override (User.MaxOrderCents) takes precedence over the global.
{"user override below global rejects", 20000, 12, true, intPtr(5000)}, // $60 > $50 user cap (global $200 would allow)
{"user override above global accepts", 20000, 41, false, intPtr(50000)}, // $205 < $500 user cap (global $200 would reject)
{"user override zero disables per user", 20000, 1000, false, intPtr(0)}, // per-user off-switch over a live global
{"nil override falls back to global", 20000, 41, true, nil}, // explicit nil == inherit global $200
{"over cap rejects", 20000, 41, true, nil, nil}, // $205 vs global $200
{"at cap accepts", 20000, 40, false, nil, nil}, // $200 boundary
{"under cap accepts", 20000, 39, false, nil, nil}, // $195
{"zero cap disables", 0, 1000, false, nil, nil}, // explicit global off-switch
// Per-user admin override (User.MaxOrderCents) takes precedence over the global.
{"user override below global rejects", 20000, 12, true, intPtr(5000), nil}, // $60 > $50 user cap (global $200 would allow)
{"user override above global accepts", 20000, 41, false, intPtr(50000), nil}, // $205 < $500 user cap (global $200 would reject)
{"user override zero disables per user", 20000, 1000, false, intPtr(0), nil}, // per-user off-switch over a live global
{"nil override falls back to global", 20000, 41, true, nil, nil}, // explicit nil == inherit global $200
// A negative override is nonsense and must NOT disable the cap; it falls
// back to the global so a bad DB write can't void a fraud control. Here
// $205 > global $200 still rejects, at the GLOBAL cap (not the -1).
{"user override negative falls back to global", 20000, 41, true, intPtr(-1)},
{"user override negative falls back to global", 20000, 41, true, intPtr(-1), nil},
// Self-limit (User.SelfLimitCents) is LOWER-ONLY: it can tighten the effective
// cap below the admin/global value but must never raise it.
{"self limit below global rejects", 20000, 12, true, nil, intPtr(5000)}, // $60 > $50 self-limit (global $200 would allow)
{"self limit under accepts", 20000, 8, false, nil, intPtr(5000)}, // $40 < $50 self-limit
{"self limit cannot raise above global", 20000, 50, true, nil, intPtr(30000)}, // $250: min($200 global,$300 self)=$200 still rejects
{"self limit tightens disabled global", 0, 12, true, nil, intPtr(5000)}, // global off-switch, self-limit $50 still caps; $60 rejects
{"self limit zero blocks everything", 20000, 1, true, nil, intPtr(0)}, // $5 > $0: self-limit 0 is "block all", NOT an off-switch
{"self limit negative ignored", 20000, 41, true, nil, intPtr(-1)}, // self ignored; rejects at global $200
{"self limit tightens below user override", 10000, 12, true, intPtr(10000), intPtr(5000)}, // $60 > $50 self (under the $100 admin override)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Effective cap the handler should enforce: a non-negative override
// wins; nil or a negative value both fall back to the global cap.
// Effective cap the handler should enforce, mirroring ConvertCart:
// a non-negative admin override wins over the global; then a present,
// non-negative self-limit folds in via min() (lower-only — 0 means
// "block all", a negative value is ignored).
effectiveCap := tc.capCents
if tc.userCap != nil && *tc.userCap >= 0 {
effectiveCap = *tc.userCap
}
if tc.selfLimit != nil && *tc.selfLimit >= 0 {
if effectiveCap <= 0 || *tc.selfLimit < effectiveCap {
effectiveCap = *tc.selfLimit
}
}
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
@@ -713,6 +730,7 @@ func TestConvertCartLimit(t *testing.T) {
db := database.GetDB()
user.StripeCustomerID = "cus_test_overlimit"
user.MaxOrderCents = tc.userCap
user.SelfLimitCents = tc.selfLimit
if err := db.Save(&user).Error; err != nil {
t.Fatalf("saved user: %v", err)
}

View File

@@ -23,10 +23,11 @@ type User struct {
// per-user off-switch (same semantics as the global); >0 = custom ceiling. A
// negative value is treated as "no override" (falls back to global) with a
// warning — see ConvertCart. Operator-set out-of-band; kept out of API payloads.
MaxOrderCents *int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MaxOrderCents *int `json:"-"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for the User model