From fc02f331d26ecc84db8931337e0425632f3bd9c7 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Sun, 7 Jun 2026 23:25:30 +0200 Subject: [PATCH] feat(audit): emit cart_rejected event when per-order cap blocks checkout (#38) * feat(audit): emit cart_rejected event when per-order cap blocks checkout * refactor(audit): drop dead persist call + harden cart_rejected test capture --- api/handlers/cart.go | 1 + api/handlers/cart_test.go | 35 ++++++++++++++++++++++++ pkg/audit/audit.go | 57 ++++++++++++++++++++++++++++++--------- pkg/audit/audit_test.go | 41 ++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 pkg/audit/audit_test.go diff --git a/api/handlers/cart.go b/api/handlers/cart.go index f87b70d..a4b5d76 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -382,6 +382,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { if h.maxOrderCents > 0 && total > h.maxOrderCents { middleware.RecordCartConversion("validation_over_limit") + audit.CartRejected(userID, total, h.maxOrderCents) utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), map[string]any{ diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index e87fa12..3402d0b 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "log/slog" "net/http" "net/http/httptest" "os" @@ -746,6 +747,19 @@ func TestConvertCartLimit(t *testing.T) { t.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) } + // Capture audit slog ONLY for ConvertCart's emit window. Placed + // here (not at top of t.Run) so setup INFO logs from + // database.Connect/Migrate/Seed don't pollute the buffer with + // non-audit records - json.Unmarshal below requires a single + // JSON object, not multi-document input. slog.Default is + // process-global - do NOT add t.Parallel() to subtests here. + var auditBuf bytes.Buffer + if tc.expectOverCap { + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&auditBuf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + } + // Convert req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) w = httptest.NewRecorder() @@ -772,6 +786,27 @@ func TestConvertCartLimit(t *testing.T) { 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"]) } + + if auditBuf.Len() == 0 { + t.Fatalf("audit: expected cart_rejected slog line, got nothing") + } + + 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()) + } + if got := rec["event"]; got != "cart_rejected" { + t.Errorf("audit event: want cart_rejected, got %v", got) + } + if n, _ := rec["user_id"].(float64); uint(n) != user.ID { + t.Errorf("audit user_id: want %d, got %v", user.ID, rec["user_id"]) + } + 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"]) + } } }) } diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 08ee91f..247d62e 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -24,6 +24,12 @@ const EventOrderRequiresAction = "order_requires_action" // stays decoupled from sweep activity const EventOrder3DSAbandoned = "order_3ds_abandoned" +// EventCartRejected is the audit event-type string emitted when the per-order +// spend cap blocks a checkout BEFORE an order row is created. The event lives +// in slog only, persist() no-ops on zero order id, which is the correct shape +// here (cap rejection is upstream of order state). See caveat #13. +const EventCartRejected = "cart_rejected" + type eventType string const ( @@ -36,6 +42,7 @@ const ( eventOrderFailed eventType = "order_failed" eventOrderRequiresAction eventType = EventOrderRequiresAction eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned + eventCartRejected eventType = EventCartRejected eventOrderRefunded eventType = "order_refunded" eventPaymentCritical eventType = "payment_critical" eventLabelPurchased eventType = "label_purchased" @@ -46,19 +53,21 @@ const ( ) type event struct { - Event eventType - UserID uint - OrderID uint - CardID uint - Amount int - StripeID string - CardLast4 string - CardBrand string - Carrier string - Tracking string - Status string - Error string - Actor string + Event eventType + UserID uint + OrderID uint + CardID uint + Amount int + StripeID string + CardLast4 string + CardBrand string + Carrier string + Tracking string + Status string + Error string + Actor string + TotalCents int + CapCents int } func (e event) attrs() []any { @@ -99,6 +108,12 @@ func (e event) attrs() []any { if e.Actor != "" { a = append(a, "actor", e.Actor) } + if e.TotalCents != 0 { + a = append(a, "total_cents", e.TotalCents) + } + if e.CapCents != 0 { + a = append(a, "cap_cents", e.CapCents) + } return a } @@ -349,3 +364,19 @@ func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) { slog.Info("audit", e.attrs()...) persist(orderID, eventOrder3DSAbandoned, e) } + +// CartRejected records the per-order cap blocking a checkout BEFORE an +// order row is created. Slog-only by design: cap rejection runs upstream +// of order creation so there is no order id to scope a persistent +// OrderEvent row. persist() is intentionally not called - future refactors +// of the persist() zero-id guard must not silently start writing rows here. +// Counterpart to the validation_over_limit Prometheus counter. +func CartRejected(userID uint, totalCents, capCents int) { + e := event{ + Event: eventCartRejected, + UserID: userID, + TotalCents: totalCents, + CapCents: capCents, + } + slog.Info("audit", e.attrs()...) +} diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 0000000..0ee3af0 --- /dev/null +++ b/pkg/audit/audit_test.go @@ -0,0 +1,41 @@ +package audit + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestCartRejected(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + CartRejected(42, 25000, 20000) + + raw := strings.TrimSpace(buf.String()) + if raw == "" { + t.Fatalf("CartRejected: no slog output captured") + } + + var rec map[string]any + if err := json.Unmarshal([]byte(raw), &rec); err != nil { + t.Fatalf("decode slog record: %v\nraw: %s", err, raw) + } + + if got, want := rec["event"], "cart_rejected"; got != want { + t.Errorf("event: want %q, got %v", want, got) + } + if n, _ := rec["user_id"].(float64); int(n) != 42 { + t.Errorf("user_id want 42, got %v", rec["user_id"]) + } + if n, _ := rec["total_cents"].(float64); int(n) != 25000 { + t.Errorf("total_cents: want 25000, got %v", rec["total_cents"]) + } + if n, _ := rec["cap_cents"].(float64); int(n) != 20000 { + t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"]) + } +}