feat(sca): add per-user spend cap override (#53)

* feat(sca): add per-user spend cap override

* feat(sca): add admin endpoint to set per-user order cap

- PATCH /api/v1/users/{id}/order-cap (RequireAdmin / X-Admin-Key) sets or
  clears User.MaxOrderCents; null clears (revert to global), >=0 sets,
  negative rejected 400 INVALID_CAP at the write boundary
- harden ConvertCart: a negative override falls back to the global cap with
  a slog.Warn (mirrors loadMaxOrderCents) so a bad DB write cannot silently
  void the fraud control
- emit user_order_cap_set audit event (set/cleared)
- tests: admin handler table + RequireAdmin gate router test + negative
  read-path fallback case

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(api): drop orphan comment referencing nonexistent request struct

The paragraph above SetUserOrderCap documented a setUserOrderCapRequest
struct that does not exist; the handler decodes into a raw map instead.
The raw-map rationale already lives inline at the decode site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:56:38 +02:00
committed by GitHub
parent 8df1058932
commit aa446cdfa5
7 changed files with 400 additions and 26 deletions

View File

@@ -0,0 +1,97 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AdminUserHandler serves admin-gated user-administration endpoints. Routes that
// use it are mounted behind middleware.RequireAdmin (X-Admin-Key), not the JWT
// auth used by customer-facing endpoints.
type AdminUserHandler struct{}
// NewAdminUserHandler constructs an AdminUserHandler.
func NewAdminUserHandler() *AdminUserHandler {
return &AdminUserHandler{}
}
// SetUserOrderCap sets or clears a user's per-user spend-cap override
// (User.MaxOrderCents). Body: {"max_order_cents": <int|null>}.
// - null -> clears the override; the user reverts to the global MAX_ORDER_CENTS
// - 0 -> per-user off-switch (disables the cap for this user)
// - >0 -> custom ceiling
//
// Negative values are rejected here at the write boundary (HTTP 400) to keep bad
// data out of the column. The read path in cart.go:ConvertCart independently
// defends against a negative that arrives via a direct DB write (falls back to
// the global cap), so the two layers cover each other.
//
// Adapted from the admin OrderHandler.UpdateTracking pattern in
// api/handlers/orders.go (URL id param -> body decode -> row lookup -> update ->
// audit -> respond).
func (h *AdminUserHandler) SetUserOrderCap(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid user id", nil)
return
}
// Decode raw so an explicit null (clear) is distinguishable from an absent key.
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
}
rawCap, ok := raw["max_order_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD",
"max_order_cents is required (use null to clear the override)", nil)
return
}
var capCents *int
if string(rawCap) != "null" {
var n int
if err := json.Unmarshal(rawCap, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY",
"max_order_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CAP",
"max_order_cents must be >= 0 (use null to clear the override)", nil)
return
}
capCents = &n
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
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{"max_order_cents": capCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order cap", nil)
return
}
audit.UserOrderCapSet("admin", user.ID, capCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"max_order_cents": capCents,
})
}

View File

@@ -0,0 +1,194 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// adminCapRequest builds a PATCH request carrying the {id} chi URL param, as the
// admin order-cap route would. No JWT — the route is gated by RequireAdmin.
func adminCapRequest(idParam, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/v1/users/"+idParam+"/order-cap", strings.NewReader(body))
rc := chi.NewRouteContext()
rc.URLParams.Add("id", idParam)
return req.WithContext(contextWithRoute(req.Context(), rc))
}
func eqIntPtr(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func fmtIntPtr(p *int) string {
if p == nil {
return "<nil>"
}
return strconv.Itoa(*p)
}
func TestSetUserOrderCap(t *testing.T) {
cases := []struct {
name string
seed *int // starting MaxOrderCents on the user row
body string
wantStatus int
wantCode string // error code; "" means success
wantCap *int // expected DB value after a successful update
}{
{"set positive", nil, `{"max_order_cents":7500}`, http.StatusOK, "", intPtr(7500)},
{"set zero off-switch", nil, `{"max_order_cents":0}`, http.StatusOK, "", intPtr(0)},
{"clear with null reverts to global", intPtr(500), `{"max_order_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing value", intPtr(500), `{"max_order_cents":12000}`, http.StatusOK, "", intPtr(12000)},
{"negative rejected", nil, `{"max_order_cents":-1}`, http.StatusBadRequest, "INVALID_CAP", nil},
{"missing field rejected", nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, `{"max_order_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", 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.seed != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seed).Error; err != nil {
t.Fatalf("seed cap: %v", err)
}
}
handler := NewAdminUserHandler()
req := adminCapRequest(strconv.FormatUint(uint64(user.ID), 10), tc.body)
w := httptest.NewRecorder()
handler.SetUserOrderCap(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != tc.wantCode {
t.Errorf("error code: want %q got %q", tc.wantCode, resp.Error.Code)
}
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.MaxOrderCents, tc.wantCap) {
t.Errorf("db MaxOrderCents: want %s got %s", fmtIntPtr(tc.wantCap), fmtIntPtr(got.MaxOrderCents))
}
} else if !eqIntPtr(got.MaxOrderCents, tc.seed) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db cap: want %s got %s", fmtIntPtr(tc.seed), fmtIntPtr(got.MaxOrderCents))
}
})
}
}
func TestSetUserOrderCap_InvalidID(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("abc", `{"max_order_cents":100}`))
if w.Code != http.StatusBadRequest {
t.Fatalf("status: want 400 got %d", w.Code)
}
assertErrCode(t, w, "INVALID_ID")
}
func TestSetUserOrderCap_UserNotFound(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("99999", `{"max_order_cents":100}`))
if w.Code != http.StatusNotFound {
t.Fatalf("status: want 404 got %d", w.Code)
}
assertErrCode(t, w, "USER_NOT_FOUND")
}
// TestSetUserOrderCap_AdminGate exercises the RequireAdmin middleware through a
// real router (the handler-direct tests above bypass middleware).
func TestSetUserOrderCap_AdminGate(t *testing.T) {
user := setupOrderTestDB(t)
idStr := strconv.FormatUint(uint64(user.ID), 10)
router := func() http.Handler {
r := chi.NewRouter()
r.With(middleware.RequireAdmin).
Patch("/api/v1/users/{id}/order-cap", NewAdminUserHandler().SetUserOrderCap)
return r
}
newReq := func() *http.Request {
return httptest.NewRequest("PATCH", "/api/v1/users/"+idStr+"/order-cap",
strings.NewReader(`{"max_order_cents":100}`))
}
t.Run("disabled when ADMIN_API_KEY unset", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "")
w := httptest.NewRecorder()
router().ServeHTTP(w, newReq())
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status: want 503 got %d", w.Code)
}
assertErrCode(t, w, "ADMIN_DISABLED")
})
t.Run("rejects wrong key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "nope")
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")
})
t.Run("accepts correct key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "secret")
w := httptest.NewRecorder()
router().ServeHTTP(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.MaxOrderCents, intPtr(100)) {
t.Errorf("db MaxOrderCents: want 100 got %s", fmtIntPtr(got.MaxOrderCents))
}
})
}
func assertErrCode(t *testing.T, w *httptest.ResponseRecorder, want string) {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != want {
t.Errorf("error code: want %q got %q", want, resp.Error.Code)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
@@ -380,14 +381,32 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost
if h.maxOrderCents > 0 && total > h.maxOrderCents {
// Per-user overridable spend cap. The override may raise OR lower the global
// cap — operators set it in both directions deliberately. A negative override
// is nonsense: ignore it and fall back to the global so an erroneous DB write
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
// 0 is a valid per-user off-switch (same semantics as the global).
capCents := h.maxOrderCents
if user.MaxOrderCents != nil {
if *user.MaxOrderCents < 0 {
slog.Warn("ignoring negative per-user order cap, using global",
"user_id", user.ID,
"user_cap_cents", *user.MaxOrderCents,
"global_cap_cents", h.maxOrderCents)
} else {
capCents = *user.MaxOrderCents
}
}
if capCents > 0 && total > capCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, h.maxOrderCents)
audit.CartRejected(userID, total, capCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": h.maxOrderCents,
"limit_cents": capCents,
})
return
}

View File

@@ -670,27 +670,49 @@ func TestCartIsolation(t *testing.T) {
}
}
// intPtr returns a pointer to i, for seeding the nullable User.MaxOrderCents
// per-user cap override in table tests.
func intPtr(i int) *int { return &i }
func TestConvertCartLimit(t *testing.T) {
cases := []struct {
name string
capCents int
quantity int
expectOverCap bool
userCap *int // per-user override; nil = inherit the global capCents
}{
{"over cap rejects", 20000, 41, true}, // $205
{"at cap accepts", 20000, 40, false}, // $200 boundary
{"under cap accepts", 20000, 39, false}, // $195
{"zero cap disables", 0, 1000, false}, // explicit off-switch
{"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
// 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)},
}
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.
effectiveCap := tc.capCents
if tc.userCap != nil && *tc.userCap >= 0 {
effectiveCap = *tc.userCap
}
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
user.StripeCustomerID = "cus_test_overlimit"
user.MaxOrderCents = tc.userCap
if err := db.Save(&user).Error; err != nil {
t.Fatalf("saved user: %v", err)
}
@@ -780,8 +802,8 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("expectOverCap=%v got code=%d errCode=%q msg=%q", tc.expectOverCap, w.Code, resp.Error.Code, resp.Error.Message)
}
if tc.expectOverCap {
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != effectiveCap {
t.Errorf("limit_cents in details: want %d, got %v", effectiveCap, resp.Error.Details["limit_cents"])
}
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
@@ -791,12 +813,22 @@ func TestConvertCartLimit(t *testing.T) {
t.Fatalf("audit: expected cart_rejected slog line, got nothing")
}
// The buffer may hold more than one JSON line (e.g. the negative-
// override warning precedes the audit record), so scan for the
// cart_rejected event rather than assuming it's the only line.
var rec map[string]any
if err := json.Unmarshal(auditBuf.Bytes(), &rec); err != nil {
t.Fatalf("audit: decode slog record: %v\nraw: %s", err, auditBuf.String())
for line := range bytes.SplitSeq(bytes.TrimSpace(auditBuf.Bytes()), []byte("\n")) {
var m map[string]any
if err := json.Unmarshal(line, &m); err != nil {
continue
}
if m["event"] == "cart_rejected" {
rec = m
break
}
}
if got := rec["event"]; got != "cart_rejected" {
t.Errorf("audit event: want cart_rejected, got %v", got)
if rec == nil {
t.Fatalf("audit: no cart_rejected slog record found\nraw: %s", auditBuf.String())
}
if n, _ := rec["user_id"].(float64); uint(n) != user.ID {
t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"])
@@ -804,8 +836,8 @@ func TestConvertCartLimit(t *testing.T) {
if n, _ := rec["total_cents"].(float64); int(n) != tc.quantity*pinnedPrice {
t.Errorf("audit total_cents: want %d, got %v", tc.quantity*pinnedPrice, rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != tc.capCents {
t.Errorf("audit cap_cents: want %d, got %v", tc.capCents, rec["cap_cents"])
if n, _ := rec["cap_cents"].(float64); int(n) != effectiveCap {
t.Errorf("audit cap_cents: want %d, got %v", effectiveCap, rec["cap_cents"])
}
}
})

View File

@@ -53,6 +53,7 @@ func SetupRoutes(
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
adminUserHandler := handlers.NewAdminUserHandler()
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
@@ -149,6 +150,8 @@ func SetupRoutes(
r.Use(middleware.RequireAdmin)
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
// Set or clear a user's per-user order spend-cap override.
r.Patch("/users/{id}/order-cap", adminUserHandler.SetUserOrderCap)
})
})