Files
Crussell/backend/handlers/payments/payments_r6_review_test.go
T
popertots 2a78383a2d Expand payment remediation test coverage
Adds tests for chargeFailureStatus retryable-vs-definitive classification (429/408/425 -> 503, 4xx declines -> 402), legacy NULL-key refund resume, tip/discount split-record math, and the shared charge helpers adopted by till and gift-card paths.
2026-08-22 00:34:49 +01:00

414 lines
16 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
)
// =============================================================================
// R6: ccof charges must ALWAYS carry a CustomerID; one-off non-save charges
// use the cnon: nonce directly (no card-on-file, no customer).
// =============================================================================
// TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges
// covers the lazy Square-customer provisioning for saved-card rows that
// predate P14 (square_customer_id empty): the row must be provisioned AND the
// provisioned id persisted on the row BEFORE the ccof: charge goes out.
func TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_legacy", "VISA", "4242")
require.NoError(t, err)
// Row is seeded with square_customer_id empty (legacy).
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
IdempotencyKey: "legacy-cust-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.NotEmpty(t, got, "a legacy ccof: charge must be provisioned a Square customer before charging")
var persisted string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&persisted))
require.Equal(t, got, persisted, "the provisioned customer id must be persisted on the saved-card row")
}
// TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID covers the save
// path of the new-card flow: the ccof: charge MUST carry the same provisioned
// customer id that CreateCardOnFile used.
func TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
cardToken := "cnon:save-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "save-charge-cust-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
require.NotEmpty(t, cof.lastCustomerID(), "a save-card flow must provision a Square customer")
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) charge must carry the provisioned customer id")
require.NotEmpty(t, got)
}
// TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID is the BuyGiftCard
// counterpart of the above: the gift-card purchase's ccof: charge must carry
// the provisioned customer id.
func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:save-gift-card-nonce"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: true,
IdempotencyKey: "buy-gc-save-charge-cust",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) gift-card purchase must carry the provisioned customer id")
require.NotEmpty(t, got)
}
// =============================================================================
// R2: BuyGiftCard requires an idempotency key
// =============================================================================
func TestBuyGiftCard_MissingIdempotencyKey_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "an empty idempotency key must be rejected (R2)")
}
// =============================================================================
// R3: provisional (pre-Square) terminal checkout rows
// =============================================================================
// TestActiveTerminalCheckoutID_ResolvesProvisionalRow covers the crash-window
// guard: a PENDING row with a synthetic "tmp-" checkout_id never had a checkout
// created at Square, so it is provably not live — the guard must mark it failed
// and allow a fresh checkout instead of wedging the booking.
func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
provisionalID := "tmp-crash-window-1"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
VALUES ($1, $2, 'full', 'PENDING', 50.00)
`, provisionalID, bookingID); err != nil {
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
}
got := activeTerminalCheckoutID(ctx, bookingID)
require.Equal(t, "", got, "a provisional (pre-Square) row must not block a new checkout")
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
require.Equal(t, "failed", status, "the provisional row must be marked failed")
}
// failingGetCheckoutClient errors on every GetCheckout — if the sweep wrongly
// resolves a provisional row against Square, the row is left pending (the error
// is not a terminal checkout state) and the test fails.
type failingGetCheckoutClient struct {
square.SquareClient
}
func (c *failingGetCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
return nil, fmt.Errorf("GetCheckout was called for %s — provisional rows must resolve without a Square round-trip", checkoutID)
}
// TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare proves
// the sweep resolves a stale provisional row to failed without calling Square.
func TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
provisionalID := "tmp-sweep-provisional-1"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, provisionalID, bookingID); err != nil {
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
}
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
// Drop stale rows left by other sweep tests so the count is deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, provisionalID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
origClient := SquareClient
SquareClient = &failingGetCheckoutClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
n, err := SweepStaleTerminalCheckouts(freshCtx)
require.NoError(t, err)
require.Equal(t, 1, n, "the stale provisional terminal checkout must be resolved by the sweep")
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
require.Equal(t, "failed", status)
}
// =============================================================================
// R5: GetDiscountPreviewHandler ownership (IDOR)
// =============================================================================
// TestGetDiscountPreviewHandler_WrongOwner_Forbidden covers the IDOR fix: a
// non-admin user must not read another user's booking discount preview.
func TestGetDiscountPreviewHandler_WrongOwner_Forbidden(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
otherUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
otherToken := jwt.GenerateUserToken(otherUserID)
w := serveDiscountPreviewHandler(bookingID, otherUserID, otherToken, ctx)
require.Equal(t, http.StatusForbidden, w.Code, "a non-owner must not read another user's discount preview")
}
// TestGetDiscountPreviewHandler_NoUserContext_Unauthorized covers the fail-closed
// 401 for requests that carry no user id.
func TestGetDiscountPreviewHandler_NoUserContext_Unauthorized(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
GetDiscountPreviewHandler(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
// =============================================================================
// R8: DeletePaymentMethod disables the Square card before the local soft-delete
// =============================================================================
type deletingCardClient struct {
square.SquareClient
mu sync.Mutex
deleted []string
}
func (c *deletingCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deleted = append(c.deleted, cardID)
c.mu.Unlock()
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
}
func (c *deletingCardClient) deletedIDs() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deleted...)
}
func TestDeletePaymentMethod_DisablesSquareCard(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_disable", "VISA", "9999")
require.NoError(t, err)
origClient := SquareClient
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
require.Equal(t, []string{"ccof:sq_del_disable"}, rec.deletedIDs(), "the Square card must be disabled on local delete (R8)")
var deletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
require.NotEqual(t, "", deletedAt, "the card must still be soft-deleted locally")
}
// TestDeletePaymentMethod_SquareFailure_StillDeletesLocally covers the
// best-effort contract: a Square disable failure must never block the local
// soft-delete.
func TestDeletePaymentMethod_SquareFailure_StillDeletesLocally(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_fail", "VISA", "8888")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &failingDeleteClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
var deletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
require.NotEqual(t, "", deletedAt, "the local soft-delete must proceed even when the Square call fails")
}
type failingDeleteClient struct {
square.SquareClient
}
func (c *failingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return fmt.Errorf("square: network error disabling card %s", cardID)
}
// syncBuffer is a mutex-guarded slog writer so log records can be read safely
// under -race.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// tokenSafeFailingDeleteClient fails with a token-free error so the
// log-redaction test isolates redaction of the square_card_id attribute rather
// than the error string.
type tokenSafeFailingDeleteClient struct {
square.SquareClient
}
func (c *tokenSafeFailingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return fmt.Errorf("square: network error disabling card at Square")
}
// TestDeletePaymentMethod_LogsRedactCardToken verifies the local card-delete
// warning logs the redacted tokenPrefix form of the square_card_id (a ccof:
// token), never the full value (SECURITY: full ccof tokens must not reach
// server logs).
func TestDeletePaymentMethod_LogsRedactCardToken(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
fullToken := "ccof:secret_token_123456"
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, fullToken, "VISA", "9999")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &tokenSafeFailingDeleteClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
var sb syncBuffer
origLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&sb, nil)))
defer slog.SetDefault(origLogger)
svc := NewPaymentService()
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
logs := sb.String()
if strings.Contains(logs, fullToken) {
t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs)
}
if !strings.Contains(logs, "ccof:sec...") {
t.Errorf("expected redacted token prefix in logs, got %q", logs)
}
}