//go:build test && dev package payments import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" ) // Product rule (security): cards may only be saved by verified accounts // (account_role in ('verified_email','admin')). Guests, unverified accounts, // and affiliates may still pay, but must never persist a card. These tests // cover both enforcement layers: the RequireVerified middleware on the // dedicated payment-methods routes, and the handler-level save_card gate on // the charge endpoints (CreateBookingPayment et al). // serveThroughVerifiedChain invokes a handler through the same middleware // stack the payment-methods routes are mounted under in main.go // (RequireAuth → RequireVerified), carrying a real JWT, so the test exercises // the actual route wiring rather than a bare handler call. func serveThroughVerifiedChain(t *testing.T, handler http.Handler, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != nil { bodyBytes, err := json.Marshal(body) require.NoError(t, err) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rctx := chi.NewRouteContext() if id, paramName := extractPaymentIDFromPath(path); id != "" { rctx.URLParams.Add(paramName, id) } req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // TestPaymentMethodsRoutes_RequireVerified verifies the mw.RequireVerified // middleware on all three payment-methods routes: GET/POST/DELETE are all 403 // for an unverified user (before any handler code runs), while a verified user // still reaches the handler. func TestPaymentMethodsRoutes_RequireVerified(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) unverifiedID, err := fixtures.CreateTestUnverifiedUser(tx) require.NoError(t, err) unverifiedToken := jwt.GenerateUnverifiedUserToken(unverifiedID) cardID, err := fixtures.CreateTestPaymentMethod(tx, unverifiedID, "ccof:unverified_saved", "VISA", "1111") require.NoError(t, err) verifiedID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) verifiedToken := jwt.GenerateUserToken(verifiedID) // POST /user/payment-methods is the primary save path — verify no card is // persisted when an unverified user's request is rejected. saveCardPath := "/api/user/payment-methods" cases := []struct { name string method string path string body interface{} token string want int }{ {"GET unverified", http.MethodGet, "/api/user/payment-methods", nil, unverifiedToken, http.StatusForbidden}, {"POST unverified save blocked", http.MethodPost, saveCardPath, CreatePaymentMethodRequest{CardToken: "cnon:unverified-save"}, unverifiedToken, http.StatusForbidden}, {"DELETE unverified", http.MethodDelete, "/api/user/payment-methods/" + cardID, nil, unverifiedToken, http.StatusForbidden}, {"GET verified passes through", http.MethodGet, "/api/user/payment-methods", nil, verifiedToken, http.StatusOK}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var route http.Handler switch tc.method { case http.MethodGet: route = mw.RequireAuth(mw.RequireVerified(http.HandlerFunc(GetUserPaymentMethods))) case http.MethodPost: route = mw.RequireAuth(mw.RequireVerified(http.HandlerFunc(CreatePaymentMethod))) case http.MethodDelete: route = mw.RequireAuth(mw.RequireVerified(http.HandlerFunc(DeletePaymentMethod))) } w := serveThroughVerifiedChain(t, route, tc.method, tc.path, tc.body, tc.token, ctx) require.Equal(t, tc.want, w.Code, "body: %s", w.Body.String()) }) } var savedCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, unverifiedID).Scan(&savedCount) require.NoError(t, err) require.Equal(t, 1, savedCount, "only the fixture card exists — the rejected save attempt must not create one") } // setupTestDataForUserAt mirrors setupTestDataAtTime but for a caller-supplied // user (so an unverified or otherwise non-default-role user owns the booking). func setupTestDataForUserAt(t *testing.T, ctx context.Context, q db.Querier, userID string, startTime time.Time) (string, string) { t.Helper() serviceID, err := fixtures.CreateTestService(q) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime) require.NoError(t, err) _, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID) require.NoError(t, err) return bookingID, serviceID } // TestCreateBookingPayment_SaveCard_UnverifiedForbidden verifies the // handler-level save-card gate: an unverified user sending save_card=true on a // booking payment gets 403 with NO side effects — no payment record and no // saved card, and no charge reaching Square. func TestCreateBookingPayment_SaveCard_UnverifiedForbidden(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) unverifiedID, err := fixtures.CreateTestUnverifiedUser(tx) require.NoError(t, err) bookingID, _ := setupTestDataForUserAt(t, ctx, tx, unverifiedID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) unverifiedToken := jwt.GenerateUnverifiedUserToken(unverifiedID) cardToken := "cnon:unverified-save-attempt" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "unverified-save-card-key-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, unverifiedToken, ctx) require.Equal(t, http.StatusForbidden, w.Code, "body: %s", w.Body.String()) var paymentCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount) require.NoError(t, err) require.Zero(t, paymentCount, "no payment record may be created for the rejected save-card request") var cardCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, unverifiedID).Scan(&cardCount) require.NoError(t, err) require.Zero(t, cardCount, "no card may be persisted for the rejected save-card request") } // TestCreateBookingPayment_SaveCard_VerifiedSucceeds verifies the gate does // not disturb verified users: the same save_card=true request from a // verified_email account charges AND persists the card, exactly as before. func TestCreateBookingPayment_SaveCard_VerifiedSucceeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:verified-save-card" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "verified-save-card-key-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp PaymentResponse require.NoError(t, parsePaymentResponseBody(w, &resp)) require.Equal(t, "completed", resp.Status) var cardCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount) require.NoError(t, err) require.Equal(t, 1, cardCount, "verified user's card must still be persisted") } // TestCreateBookingPayment_NoSaveCard_UnverifiedSucceeds verifies the gate // does not block payment for non-verified users: an unverified user paying // WITHOUT save_card still charges successfully and saves nothing. func TestCreateBookingPayment_NoSaveCard_UnverifiedSucceeds(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) unverifiedID, err := fixtures.CreateTestUnverifiedUser(tx) require.NoError(t, err) bookingID, _ := setupTestDataForUserAt(t, ctx, tx, unverifiedID, clock.Now().Add(-1*time.Hour)) unverifiedToken := jwt.GenerateUnverifiedUserToken(unverifiedID) cardToken := "cnon:unverified-one-off" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, SaveCard: false, IdempotencyKey: "unverified-one-off-key-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, unverifiedToken, ctx) require.Equal(t, http.StatusOK, w.Code, "payment must not be blocked for unverified users paying without save_card: %s", w.Body.String()) var paymentCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount) require.NoError(t, err) require.Equal(t, 1, paymentCount, "the unverified user's one-off payment must be recorded") var cardCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, unverifiedID).Scan(&cardCount) require.NoError(t, err) require.Zero(t, cardCount, "no card may be saved when save_card is false") }