mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
* 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>
195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|