diff --git a/.gitignore b/.gitignore index 4ff1110..6dca61d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ vars.sh coverage.xml build/ dist/ +data/ src/ .tox/ nosetests.xml diff --git a/development.ini b/development.ini index aa0f99b..be3383f 100644 --- a/development.ini +++ b/development.ini @@ -31,7 +31,9 @@ session.secret = test-secret session.timeout = 31104000 session.max_age = 31104000 session.reissue_time = 15552000 -session.samesite = None +session.secure = false +session.domain = localhost.localhost +session.samesite = Lax ### # app "business logic" settings diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index a3aa0de..ef4f2a9 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -44,21 +44,22 @@ def includeme(config): return get_product_by_id(request.dbsession, product_id) def add_shop(request): - """return Shop object or None.""" - # get shop_id first from query, then route, or None. - shop_id = request.params.get("shop_id", request.matchdict.get("shop_id")) - + # print(f"add_shop: shop_id={shop_id}, params={request.params}, matchdict={request.matchdict}") + shop = None if shop_id: - return get_shop_by_id(request.dbsession, shop_id) + shop = get_shop_by_id(request.dbsession, shop_id) + # print(f"get_shop_by_id returned: {shop}") elif request.product: - return request.product.shop + shop = request.product.shop else: if request.is_saas_domain: if request.user and request.user.active_shop: - return request.user.active_shop + shop = request.user.active_shop else: - return get_shop_by_domain_name(request.dbsession, request.domain) + shop = get_shop_by_domain_name(request.dbsession, request.domain) + # print(f"add_shop returning: {shop}") + return shop def add_active_cart(request): """ diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 469fa19..300cc80 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -283,11 +283,13 @@ img.product-main { cursor: pointer; } -a.mps-button-blue { +a.mps-button-blue, +button.mps-button-blue { background-color: #98b6fa; } -a.mps-button-green { +a.mps-button-green, +button.mps-button-green { background-color: #a3c765; } @@ -319,35 +321,6 @@ a.log-out-button { background-color: #5871ad; } -a.cart-checkout-button { - background-color: #a3c765; -} - -a.cart-save-button { - background-color: #98b6fa; -} - -a.cart-activate-button { - background-color: #5871ad; -} - -a.cart-public-button span.lock { - font-size: 24px; -} - -a.cart-public-button { - color: #666666; - border-color: #666666; - border-style: solid; - border-width: 1px; - padding-top: 10px; - padding-bottom: 10px; -} - -a.cart-delete-button { - background-color: #CC6958; -} - a.shop-switch-button { color: #666666; border-color: #666666; @@ -368,6 +341,64 @@ a.cart-and-count { } +/* ------------------------------------------------------------------- + Form-buttons: copy your -class styles to +
@@ -203,7 +206,10 @@ {% if not cart.is_empty and request.user == cart.user %} - Checkout +
+ {% include "snippets/csrf.j2" %} + +


@@ -218,16 +224,25 @@ {% if request.active_cart == cart and cart.count > 0 %} - Save Cart +
+ {% include "snippets/csrf.j2" %} + +

{% elif request.active_cart != cart %} {% if request.user and request.user == cart.user %} - Make Cart Active +
+ {% include "snippets/csrf.j2" %} + +

{% else %} - Copy & Make Cart Active +
+ {% include "snippets/csrf.j2" %} + +

{% endif %} @@ -236,10 +251,16 @@ {% if request.user and request.user.owns_cart(cart) %} {% if cart.is_not_public and cart.count > 0 %} - 🔓 Make Cart Public +
+ {% include "snippets/csrf.j2" %} + +

{% elif cart.public %} - 🔒 Make Cart Private +
+ {% include "snippets/csrf.j2" %} + +


@@ -249,7 +270,10 @@ {% endif %} {% if request.active_cart != cart %} - Delete Cart +
+ {% include "snippets/csrf.j2" %} + +

{% endif %} diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2 index 6ca1b9f..b6f76cf 100644 --- a/make_post_sell/templates/cart_checkout.j2 +++ b/make_post_sell/templates/cart_checkout.j2 @@ -38,7 +38,10 @@

- Yes, Complete Checkout +
+ {% include "snippets/csrf.j2" %} + +
diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 921948e..1a67c0d 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -80,7 +80,11 @@ {% if product.is_physical %} {% set inventory = product.inventories | selectattr('shop_location_id', 'equalto', request.shop_location.id) | first %} {% if inventory and inventory.quantity > 0 %} - Add To Cart +
+ {% include "snippets/csrf.j2" %} + + +
{% else %} {% if request.shop.shop_locations.all()|length > 1 %} @@ -89,7 +93,11 @@ {% endif %} {% endif %} {% else %} - Add To Cart +
+ {% include "snippets/csrf.j2" %} + + +
{% endif %}
diff --git a/make_post_sell/templates/snippets/csrf.j2 b/make_post_sell/templates/snippets/csrf.j2 index f28926c..2f1ecc9 100644 --- a/make_post_sell/templates/snippets/csrf.j2 +++ b/make_post_sell/templates/snippets/csrf.j2 @@ -1,3 +1,3 @@ -{% if request.csrf_token %} - -{% endif %} +{# Always include the session’s CSRF token #} + + diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 014a1f1..be4f0bb 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -7,6 +7,8 @@ import unittest import webtest import stripe +import re + from ..models import get_tm_session from ..models.meta import Base @@ -17,7 +19,7 @@ from ..models.user import get_or_create_user_by_email from ..models.cart import get_cart_by_id, get_all_carts -from ..models.stripe_user_shop import get_all_stripe_customer_objects +from ..models.stripe_user_shop import get_all_stripe_customer_objects, StripeUserShop from ..models.product import get_product_by_id, get_all_products @@ -28,7 +30,7 @@ from ..lib.currency import dollars_to_cents, cents_to_dollars from pyramid.paster import get_appsettings import mock -from mock import patch +from mock import patch, MagicMock # todo we should pick a new file to put test helpers. @@ -36,7 +38,7 @@ mock_always_true = mock.Mock(return_value=True) # todo we should pick a new file to put test helpers. -class FunctionalTests(unittest.TestCase, object): +class FunctionalTests(unittest.TestCase): def setUp(self): from make_post_sell import main @@ -59,6 +61,27 @@ class FunctionalTests(unittest.TestCase, object): transaction.abort() Base.metadata.drop_all(bind=self.engine) + def get_csrf_token(self, shop_id, keywords="test"): + """ + GET the search page scoped to the given shop_id (and a dummy keywords param + so the form actually renders) and extract the CSRF hidden input via regex. + """ + url = f"/search?shop_id={shop_id}&keywords={keywords}" + res = self.testapp.get(url, status=200) + html = res.body.decode("utf-8") + + m = re.search( + r'name=["\']csrf_token["\'].*?value=["\']([^"\']+)["\']', + html, + flags=re.IGNORECASE | re.DOTALL, + ) + if not m: + self.fail( + f"CSRF token not found on {url} page.\n\n" + f"HTML snippet:\n{html[:500]}…" + ) + return m.group(1) + class UnauthenticatedFunctionalTests(FunctionalTests): def test_root_home_page(self): @@ -419,15 +442,10 @@ class AuthenticatedFunctionalTests(FunctionalTests): self.assertEqual(product.price_history.count(), 1) self.assertEqual(product.price_in_cents, 350) - # this patch requires an argument added to test method `mock_smtp`. @patch("smtplib.SMTP") @patch("make_post_sell.models.Product.is_ready", mock_always_true) def test_cart_checkout_for_shop(self, mock_smtp): - # 1. log in as user1. - # 2. create new shop. - # 3. make new shop ready for checkout. - # 4. create new product. - # 5. log out user1. + # 1. Log in as user1, create shop, make shop ready, create product, log out. self.test_new_product( user_creds=self.user1_creds, shop_params=self.shop1_params, @@ -435,20 +453,35 @@ class AuthenticatedFunctionalTests(FunctionalTests): log_out_user=True, ) - # get the Product object from database. + # Get the Product and Shop objects from the database. all_products = get_all_products(self.dbsession) product = all_products.one() - shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) - # print("{},{},{}".format(product.id, product.title, product.price)) - - # log in user2. + # Log in user2. self.log_in_user(self.user2_creds) - # add product to cart. - redirect_res1 = self.testapp.get( - f"/cart/add?product_id={product.id}&shop_id={shop.id}" + # Step 1: Get the product page and follow the redirect to the slug version to get raw HTML. + res_csrf_redirect = self.testapp.get(f"/p/{product.uuid_str}") + res_csrf = ( + res_csrf_redirect.follow() + ) # Follow the single redirect to the slug version. + + # Step 2: Extract the CSRF token from the raw HTML. + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail( + "CSRF token not found in product page HTML or session cookie (_csrft_)" + ) + + # Step 3: Add product to cart using a POST request with CSRF token. + redirect_res1 = self.testapp.post( + "/cart/add", + { + "product_id": product.id, + "shop_id": shop.id, + "csrf_token": csrf_token, # Include CSRF token as a form value. + }, ) redirect_res2 = redirect_res1.follow() res = redirect_res2.follow() @@ -458,7 +491,7 @@ class AuthenticatedFunctionalTests(FunctionalTests): carts = get_all_carts(self.dbsession) - # make sure we have 4 carts. request.active_cart & request.session_cart for each user. + # Make sure we have 4 carts (request.active_cart & request.session_cart for each user). self.assertEqual(4, carts.count()) self.dbsession.refresh(shop) @@ -468,64 +501,25 @@ class AuthenticatedFunctionalTests(FunctionalTests): user_1_cart = shop.get_active_cart_for_user(self.user1) user_2_cart = shop.get_active_cart_for_user(self.user2) - # make sure user1 has 0 items in active Cart. + # Make sure user1 has 0 items in active Cart. self.assertEqual(0, user_1_cart.count) - # make sure user2 has 1 item in active Cart. + # Make sure user2 has 1 item in active Cart. self.assertEqual(1, user_2_cart.count) - redirect_res = self.testapp.get( - f"/u/cart/{user_2_cart.id}/checkout?shop_id={shop.id}" - ) - redirect_res2 = redirect_res1.follow() - res = redirect_res2.follow() - self.assertIn("Please enter your payment information.", res.body.decode()) - - # the stripe migration to SetupIntents broke this end-to-end test routine. - """ - res = self.testapp.post( - "/billing/add-card?shop_id={}".format(shop.id), + # Proceed to checkout (extract CSRF token again if needed). + csrf_token = self.get_csrf_token(shop.uuid_str) + res_csrf_checkout = self.testapp.post( + f"/u/cart/{user_2_cart.uuid_str}/checkout", { - "email": self.user2.email, - "stripeToken": "tok_visa", + "shop_id": shop.id, + "csrf_token": csrf_token, # Include CSRF token as a form value. }, ) - - res = self.testapp.get( - "/u/cart/{}/checkout?shop_id={}".format( - user_2_cart.id, - shop.id - ) + res_csrf_checkout = res_csrf_checkout.follow().follow() + self.assertIn( + "Please enter your payment information.", res_csrf_checkout.body.decode() ) - res_body = res.body.decode() - - self.assertIn("Please confirm your order.", res_body) - self.assertIn("Visa", res_body) - self.assertIn("Are you sure you want to charge", res_body) - - redirect_res1 = self.testapp.get( - "/u/cart/{}/complete/checkout?shop_id={}".format( - user_2_cart.id, - shop.id - ) - ) - redirect_res2 = redirect_res1.follow() - res = redirect_res2.follow() - res_body = res.body.decode() - self.assertIn("Success, you have completed the purchase!", res_body) - - # refresh shop attributes from database. - self.dbsession.refresh(shop) - - stripe_customer = shop.stripe_customer(self.user2) - - stripe_charge = shop.list_stripe_charges(stripe_customer).data[0] - - self.assertEqual( - stripe_charge.amount, - dollars_to_cents(self.product1_params["price"]), - ) - """ @patch("make_post_sell.models.Product.is_ready", mock_always_true) def make_new_coupon_for_shop(self, coupon_params=None): @@ -555,6 +549,10 @@ class AuthenticatedFunctionalTests(FunctionalTests): f"/s/{shop.id}/coupon/new", coupon_params, ) + + # Commit the transaction to persist shop, product, and coupon + transaction.manager.commit() + return res def test_new_coupon_for_shop(self): @@ -569,78 +567,307 @@ class AuthenticatedFunctionalTests(FunctionalTests): self.assertIn("Please submit all required fields.", res.body.decode()) # this patch requires an argument added to test method `mock_smtp`. + @patch("make_post_sell.models.Product.is_ready") @patch("smtplib.SMTP") - @patch("make_post_sell.models.Product.is_ready", mock_always_true) - def test_checkout_with_coupon_code(self, mock_smtp): + @patch("stripe.SetupIntent.retrieve") + @patch("stripe.Customer.list_payment_methods") + def test_checkout_with_coupon_code( + self, + mock_list_payment_methods, + mock_setup_intent, + mock_smtp, + mock_product_ready, + ): + # Debug: Confirm mock objects + print(f"Mock SetupIntent: {mock_setup_intent}") + print(f"Mock SMTP: {mock_smtp}") + print(f"Mock Product.is_ready: {mock_product_ready}") + print(f"Mock list_payment_methods: {mock_list_payment_methods}") + + # Configure mocks + mock_product_ready.return_value = True + mock_setup_intent.return_value = { + "id": "seti_123", + "status": "succeeded", + "payment_method": "pm_card_visa", + "customer": "cus_123", + "client_secret": "seti_123_secret", + "payment_method_types": ["card"], + "payment_method_options": {"card": {}}, + "metadata": {"card_id": "pm_card_visa"}, + } + # Mock PaymentMethod object + payment_method = MagicMock() + payment_method.id = "pm_card_visa" + payment_method.type = "card" + payment_method.card.brand = "visa" + payment_method.card.last4 = "4242" + mock_list_payment_methods.return_value = {"data": [payment_method]} + + # Step 1: Create a coupon as user1 res = self.make_new_coupon_for_shop() res = res.follow() self.assertIn("You created a new coupon!", res.body.decode()) - # log out user1. - self.testapp.get("/log-out") - - # log in user2. - self.log_in_user(self.user2_creds) - - # query the new shop from database. + # Re-query users and shop + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + # Log out user1 + self.testapp.get("/log-out") + + # Log in user2 + self.log_in_user(self.user2_creds) + + # Refresh the shop to ensure it's committed + self.dbsession.refresh(shop) + print( + f"Shop UUID: {shop.uuid_str}, Ready: {shop.is_ready}, " + f"Public key: {shop.stripe_public_api_key}, " + f"Secret key: {shop.stripe_secret_api_key}" + ) + + # Verify shop is ready + self.assertTrue( + shop.is_ready, + f"Shop {shop.name} is not ready. " + f"stripe_public_api_key: {shop.stripe_public_api_key}, " + f"stripe_secret_api_key: {shop.stripe_secret_api_key}", + ) + + # Step 2: Visit /billing to create StripeUserShop record for user2 + shop_id = str(shop.uuid_str) + res_billing_init = self.testapp.get(f"/billing?shop_id={shop_id}", status=200) + print(f"Billing init response: {res_billing_init.body.decode()}") # Debug + self.assertIn( + "The active card will be charged during checkout.", + res_billing_init.body.decode(), + ) # Verify billing page + + # Commit transaction to persist StripeUserShop + transaction.manager.commit() + + # Re-query users and shop + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Step 3: Add billing details for user2 + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") + + print(f"Using shop_id for billing: {shop_id}") + res_billing_redirect = self.testapp.post( + f"/billing/add-card?shop_id={shop_id}", + { + "email": self.user2.email, + "setup_intent": "seti_123", + "setup_intent_client_secret": "seti_123_secret", + "csrf_token": csrf_token, + }, + status=302, + ) + res_billing = res_billing_redirect.follow() + res_billing = res_billing.follow() + print(f"Billing response: {res_billing.body.decode()}") # Debug + self.assertIn( + "You saved a new card.", res_billing.body.decode() + ) # Match view's flash message + + # Commit transaction to persist card + transaction.manager.commit() + + # Re-query users and shop + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Verify StripeUserShop and set active card + stripe_user_shop = ( + self.dbsession.query(StripeUserShop) + .filter_by(user_id=self.user2.id, shop_id=shop.id) + .first() + ) + print(f"StripeUserShop for {self.user2.email}: {stripe_user_shop}") + print( + f"Card IDs before: {stripe_user_shop.stripe_card_ids if stripe_user_shop else None}" + ) + if stripe_user_shop and not stripe_user_shop.active_card_id: + stripe_user_shop.active_card_id = "pm_card_visa" + self.dbsession.add(stripe_user_shop) + self.dbsession.flush() + print(f"Manually set active card ID: {stripe_user_shop.active_card_id}") + print( + f"Active card ID: {stripe_user_shop.active_card_id if stripe_user_shop else None}" + ) + + # Commit transaction to persist StripeUserShop changes + transaction.manager.commit() + + # Re-query users and shop + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Re-query StripeUserShop to verify persistence + stripe_user_shop = ( + self.dbsession.query(StripeUserShop) + .filter_by(user_id=self.user2.id, shop_id=shop.id) + .first() + ) + print(f"StripeUserShop after commit: {stripe_user_shop}") + print( + f"Active card ID after commit: {stripe_user_shop.active_card_id if stripe_user_shop else None}" + ) + print( + f"Card IDs after commit: {stripe_user_shop.stripe_card_ids if stripe_user_shop else None}" + ) + + # Query the coupon from the database coupons = get_coupons_by_code(self.dbsession, self.coupon1_params["code"]) coupon = coupons[0] - # apply coupon to user2's active cart. - self.testapp.post(f"/coupon/apply?&coupon_id={coupon.id}&shop_id={shop.id}") + # Step 4: Extract CSRF token for applying the coupon + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") - # verify coupon was applied. - res_redirect1 = self.testapp.get(f"/cart?shop_id={shop.id}") - res = res_redirect1.follow() - res_body = res.body.decode() + # Step 5: Apply coupon to user2's active cart + res_coupon_apply_redirect = self.testapp.post( + f"/coupon/apply?shop_id={shop_id}", + { + "coupon_id": coupon.id, + "shop_id": shop_id, + "csrf_token": csrf_token, + }, + status=302, + ) + res_coupon_apply = res_coupon_apply_redirect.follow() + res_body = res_coupon_apply.body.decode() + + # Verify coupon was applied self.assertIn("Slippery Halloween Party", res_body) self.assertIn("3.50", res_body) self.assertIn("camp31", res_body) - # get the Product object from database. + # Commit transaction to persist coupon application + transaction.manager.commit() + + # Re-query users and shop + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + + # Step 6: Query the product from the database all_products = get_all_products(self.dbsession) product = all_products.one() + product_id = product.uuid_str - # add product to user2's active cart. - self.testapp.get(f"/cart/add?product_id={product.uuid_str}&shop_id={shop.id}") + # Step 7: Extract CSRF token for adding product to cart + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") - # the stripe migration to SetupIntents broke this end-to-end test routine. - """ - # add billing details for user2 for this shop. - res = self.testapp.post( - "/billing/add-card?shop_id={}".format(shop.id), + # Step 8: Add product to user2's active cart + res_add_product_redirect = self.testapp.post( + f"/cart/add?shop_id={shop_id}", { - "email": self.user2.email, - "stripeToken": "tok_visa", + "product_id": product_id, + "shop_id": shop_id, + "csrf_token": csrf_token, }, + status=302, ) + res_add_product = res_add_product_redirect.follow() + res_add_product = res_add_product.follow() + res_body = res_add_product.body.decode() + self.assertIn('You added "russell\'s product" to your cart.', res_body) - # why is this None? + # Commit transaction to persist cart + transaction.manager.commit() + + # Re-query users, shop, and cart + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) user_2_cart = shop.get_active_cart_for_user(self.user2) - # attempt to checkout before coupon is properly qualified. - # shop total must be over cart_qualifier (test defaults to $10). - res_redirect1 = self.testapp.get( - "/u/cart/{}/checkout?shop_id={}".format( - user_2_cart.id, - shop.id - ) - ) - res = res_redirect1.follow() - self.assertIn("Please review coupon terms: shop total not met.", res.body.decode()) + # Step 9: Get user2's active cart + self.assertIsNotNone(user_2_cart, "User2's active cart should not be None") - # add more product to user2's active cart, to pass cart_qualifier. - self.testapp.get("/cart/add?product_id={}&shop_id={}".format(product.uuid_str, shop.id)) - self.testapp.get("/cart/add?product_id={}&shop_id={}".format(product.uuid_str, shop.id)) + # Step 10: Attempt checkout with insufficient cart total + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") - # checkout coupon confirmation unblocked. - res = self.testapp.get( - "/u/cart/{}/checkout?shop_id={}".format( - user_2_cart.id, - shop.id - ) + res_checkout = self.testapp.post( + f"/u/cart/{user_2_cart.uuid_str}/checkout?shop_id={shop_id}", + { + "shop_id": shop_id, + "csrf_token": csrf_token, + }, + status=302, ) - self.assertIn("Please confirm your order.", res.body.decode()) - """ + res_body = res_checkout.follow().body.decode() + print(f"First checkout response: {res_body}") # Debug + self.assertIn("Please review the terms for coupon", res_body) + self.assertIn("shop total not met.", res_body) + + # Commit transaction to persist checkout attempt + transaction.manager.commit() + + # Re-query users, shop, and cart + self.user1 = get_or_create_user_by_email(self.dbsession, self.user1_creds[0]) + self.user2 = get_or_create_user_by_email(self.dbsession, self.user2_creds[0]) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + user_2_cart = shop.get_active_cart_for_user(self.user2) + + # Step 11: Add more products to meet coupon qualifier + for _ in range(2): + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") + + res_add_product_redirect = self.testapp.post( + f"/cart/add?shop_id={shop_id}", + { + "product_id": product_id, + "shop_id": shop_id, + "csrf_token": csrf_token, + }, + status=302, + ) + res_add_product = res_add_product_redirect.follow() + res_add_product = res_add_product.follow() + print(f"Add product response: {res_add_product.body.decode()}") # Debug + transaction.manager.commit() + + # Re-query users, shop, and cart + self.user1 = get_or_create_user_by_email( + self.dbsession, self.user1_creds[0] + ) + self.user2 = get_or_create_user_by_email( + self.dbsession, self.user2_creds[0] + ) + shop = get_shop_by_name(self.dbsession, self.shop1_params["name"]) + user_2_cart = shop.get_active_cart_for_user(self.user2) + + # Step 12: Attempt checkout again + csrf_token = self.get_csrf_token(shop.uuid_str) + if not csrf_token: + self.fail("CSRF token not found in session cookie (_csrft_)") + + print(f"Shop before checkout: UUID: {shop.uuid_str}, Ready: {shop.is_ready}") + res_checkout_final = self.testapp.post( + f"/u/cart/{user_2_cart.uuid_str}/checkout?shop_id={shop_id}", + { + "shop_id": shop_id, + "csrf_token": csrf_token, + }, + status=200, + ) + res_body = res_checkout_final.body.decode() + print(f"Checkout final response: {res_body}") # Debug + self.assertIn("Please confirm your order.", res_body) diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index b2f2725..d89a551 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -1,7 +1,5 @@ from pyramid.view import view_config -from transaction import TransactionManager - from . import ( user_required, get_referer_or_home, @@ -19,6 +17,8 @@ from ..lib.mail import ( send_sale_email, ) +import stripe + def get_cart_from_matchdict(request): """ @@ -212,7 +212,7 @@ def cart_by_id(request): return HTTPFound(get_referer_or_home(request)) -@view_config(route_name="cart_add_product") +@view_config(route_name="cart_add_product", request_method="POST", require_csrf=True) def cart_add_product(request): product_id = request.params.get("product_id", None) @@ -247,7 +247,7 @@ def cart_add_product(request): return HTTPFound(get_referer_or_home(request)) -@view_config(route_name="cart_remove_product") +@view_config(route_name="cart_remove_product", request_method="POST", require_csrf=True) def cart_remove_product(request): product_id = request.params.get("product_id", None) @@ -285,7 +285,9 @@ def cart_remove_product(request): return HTTPFound(get_referer_or_home(request)) -@view_config(route_name="cart_quantity_product") +@view_config( + route_name="cart_quantity_product", request_method="POST", require_csrf=True +) def cart_quantity_product(request): product_id = request.params.get("product_id", None) quantity = request.params.get("quantity", None) @@ -380,8 +382,18 @@ def cart_handling_option(request): return HTTPFound(f"/cart/{cart.id}") -@view_config(route_name="cart_checkout", renderer="cart_checkout.j2") -@view_config(route_name="user_cart_checkout", renderer="cart_checkout.j2") +@view_config( + route_name="cart_checkout", + renderer="cart_checkout.j2", + request_method="POST", + require_csrf=True, +) +@view_config( + route_name="user_cart_checkout", + renderer="cart_checkout.j2", + request_method="POST", + require_csrf=True, +) @user_required( flash_msg="To checkout your cart, please verify your email address below.", flash_level="info", @@ -459,7 +471,10 @@ def cart_checkout(request): request.session.flash(msg) return HTTPFound(get_referer_or_home(request)) -@view_config(route_name="user_cart_complete_checkout") + +@view_config( + route_name="user_cart_complete_checkout", request_method="POST", require_csrf=True +) @user_required() @shop_is_ready_required() def cart_complete_checkout(request): @@ -467,102 +482,111 @@ def cart_complete_checkout(request): if cart is None: msg = ("That cart_id does not exist.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) elif cart.is_not_public and request.user.does_not_own_cart(cart): msg = ("That cart is not public and you do not own that cart.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) elif cart.is_empty: msg = ("That cart is empty, you cannot checkout.", "error") + request.session.flash(msg) + return HTTPFound(get_referer_or_home(request)) elif cart.requires_payment and request.shop.stripe_customer(request.user) is None: msg = ("Please enter your payment information.", "info") request.session.flash(msg) return HTTPFound("/billing") - else: - # make sure all coupon terms are met before proceeding. - error_messages = cart.validate_attached_coupons() - if error_messages: - for error_message in error_messages: - request.session.flash((error_message, "error")) - return HTTPFound(get_referer_or_home(request)) + error_messages = cart.validate_attached_coupons() + if error_messages: + for error_message in error_messages: + request.session.flash((error_message, "error")) + return HTTPFound(get_referer_or_home(request)) - tm = TransactionManager() + tm = request.tm - try: - tm.begin() - invoices = [] - successful_checkout = True + try: + tm.begin() - for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): - shop = cart.shops[shop_id] - invoice = Invoice(request.user) - invoice.shop = shop - invoice.shop_id = shop.id - invoice.handling_option = cart.handling_option - invoice.handling_cost_in_cents = cart.handling_cost_in_cents + invoices = [] - if cart.physical_products: - invoice.delivery_address = request.user.active_address.data + # First, prepare invoices without persisting + for shop_id, product_quantity_tuple in cart.shop_product_dict.items(): + shop = cart.shops[shop_id] + invoice = Invoice(request.user) + invoice.shop = shop + invoice.shop_id = shop.id + invoice.handling_option = cart.handling_option + invoice.handling_cost_in_cents = cart.handling_cost_in_cents - for product, quantity in product_quantity_tuple: - product.unlock_for_user(request.user) - request.dbsession.add(product) - invoice.new_line_item( - product=product, - quantity=quantity, - ) + if cart.physical_products: + invoice.delivery_address = request.user.active_address.data - for coupon in cart.coupons: - invoice.new_coupon_redemption(coupon) + for product, quantity in product_quantity_tuple: + invoice.new_line_item(product=product, quantity=quantity) - request.dbsession.add(invoice) - invoices.append(invoice) + for coupon in cart.coupons: + invoice.new_coupon_redemption(coupon) - for invoice in invoices: - shop = invoice.shop + invoices.append(invoice) - if invoice.requires_payment: - stripe_user_shop = shop.stripe_user_shop(request.user) - try: - shop.stripe.PaymentIntent.create( - amount=invoice.total_in_cents, - currency="usd", - customer=stripe_user_shop.cus_id, - payment_method=stripe_user_shop.active_card_id, - off_session=True, - confirm=True, - ) - except shop.stripe.error.CardError as e: - successful_checkout = False - raise + # Attempt payment BEFORE adding invoices to session + for invoice in invoices: + shop = invoice.shop - if successful_checkout: - tm.commit() - cart.update_inventory(request.shop_location) - msg = ("Success, you have completed the purchase!", "success") - request.session.flash(msg) - for invoice in invoices: - send_purchase_email( - request, - request.user.email, - [item.product for item in invoice.line_items], - invoice.total, - ) - send_sale_email( - request, - invoice.shop, - [item.product for item in invoice.line_items], - invoice.total, - ) - save_cart(request) - return HTTPFound("/u/purchases") - else: - tm.abort() - msg = ("Payment failed. Please check your card details.", "error") - request.session.flash(msg) - return HTTPFound("/billing") + if invoice.requires_payment: + stripe_user_shop = shop.stripe_user_shop(request.user) + shop.stripe.PaymentIntent.create( + amount=invoice.total_in_cents, + currency="usd", + customer=stripe_user_shop.cus_id, + payment_method=stripe_user_shop.active_card_id, + off_session=True, + confirm=True, + ) - except Exception as e: - request.session.flash(("Payment failed: Please check your card details. {}".format(str(e)), "error")) - return HTTPFound("/billing") + # Only persist data after successful payment + for invoice in invoices: + for line_item in invoice.line_items: + line_item.product.unlock_for_user(request.user) + request.dbsession.add(line_item.product) + + request.dbsession.add(invoice) + + tm.commit() + + cart.update_inventory(request.shop_location) + msg = ("Success, you have completed the purchase!", "success") + request.session.flash(msg) + + for invoice in invoices: + send_purchase_email( + request, + request.user.email, + [item.product for item in invoice.line_items], + invoice.total, + ) + send_sale_email( + request, + invoice.shop, + [item.product for item in invoice.line_items], + invoice.total, + ) + + save_cart(request) + return HTTPFound("/u/purchases") + + except stripe.error.CardError as e: + tm.abort() + msg = ("Payment failed. Please check your card details.", "error") + request.session.flash(msg) + return HTTPFound("/billing") + + except Exception as e: + tm.abort() + msg = (f"Payment failed: {str(e)}", "error") + request.session.flash(msg) + return HTTPFound("/billing")