From cb9b3df0cff954464f96d01800d5ea267ddcfab0 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 13 May 2026 16:43:16 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20lock=20negotiated=20cart=20quantity=20?= =?UTF-8?q?=E2=80=94=20refuse=20/cart/add=20+=20quantity=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cart override returns offer.current_amount_in_cents regardless of line-item quantity. So a buyer who: 1. Got an offer accepted at $21 (list $42) 2. Hit /o/{id}/checkout → got a cart with 1 unit + cart_offer 3. Went back to the product page and clicked "Add to cart" …would end up with a cart showing 2 units of a $42 product but charged the single negotiated $21. Seller eats $84 of merch for $21. Same hole on /cart/{id}/quantity — bumping quantity directly skipped the override re-check. Plug both: - cart_add_product refuses when active_cart.is_negotiated. Flashes "This cart is locked to your accepted offer/auction — save or check out this cart before adding other items." - cart_quantity_product refuses when cart.is_negotiated. Flashes "Quantity is locked on an accepted offer/auction — the agreed price is for one unit only." Two regression tests cover the gap: one POSTs /cart/add of the same product, one POSTs /cart/{id}/quantity setting quantity=2. Both assert cart.get_product_quantity(product) stays at 1 and the response flashes "locked". UI tightening (hiding the add-to-cart button on the product page when the active cart is already negotiated to a different product) is a follow-up — this commit is the server-side defense. --- make_post_sell/tests/test_functional.py | 84 +++++++++++++++++++++++++ make_post_sell/views/cart.py | 26 ++++++++ 2 files changed, 110 insertions(+) diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 4b988b4..605fb08 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -7043,6 +7043,90 @@ class TestOfferCheckout(_AuthenticatedBase): self.dbsession.refresh(offer) self.assertEqual(offer.state, OFFER_STATE_ACCEPTED) + def test_negotiated_cart_refuses_add_product(self): + """A cart bound to an accepted offer must not accept additional + product adds. Otherwise the buyer can walk away with N units at + the single-unit negotiated price (the override total is constant + regardless of line-item quantity). + """ + from ..models.cart import Cart + from ..models.cart_offer import MpsCartOffer + + offer_id = self._accepted_offer() + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + + # First the buyer pays for their offer — this creates a cart + # with cart_offer linked. + self.testapp.post(f"/o/{offer_id}/checkout", status=302) + + # The cart has 1 unit of the offer's product. + from ..models.user import get_user_by_email + buyer = get_user_by_email(self.dbsession, self.user2_creds[0]) + match = [ + c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all() + if c.cart_offers + ] + self.assertEqual(len(match), 1) + cart = match[0] + product = cart.cart_offers[0].offer.product + self.assertEqual(cart.get_product_quantity(product), 1) + + # Now try to add the SAME product via /cart/add. + csrf_token = self.get_csrf_token(product.shop.uuid_str) + res = self.testapp.post( + "/cart/add", + { + "product_id": product.id, + "shop_id": product.shop.id, + "csrf_token": csrf_token, + }, + status=302, + ) + flash_body = res.follow().body.decode() + self.assertIn("locked", flash_body) + + # Quantity unchanged in DB. + self.dbsession.refresh(cart) + self.assertEqual(cart.get_product_quantity(product), 1) + + def test_negotiated_cart_refuses_quantity_bump(self): + """Same defense for /cart/{id}/quantity — the buyer can't bump + quantity directly on the cart page either. + """ + from ..models.cart import Cart + + offer_id = self._accepted_offer() + self.testapp.get("/log-out") + self.log_in_user(self.user2_creds) + self.testapp.post(f"/o/{offer_id}/checkout", status=302) + + from ..models.user import get_user_by_email + buyer = get_user_by_email(self.dbsession, self.user2_creds[0]) + cart = [ + c for c in self.dbsession.query(Cart).filter(Cart.user_id == buyer.id).all() + if c.cart_offers + ][0] + product = cart.cart_offers[0].offer.product + + csrf_token = self.get_csrf_token(product.shop.uuid_str) + res = self.testapp.post( + f"/cart/{cart.uuid_str}/quantity", + { + "product_id": product.id, + "quantity": "2", + "csrf_token": csrf_token, + }, + status=302, + ) + # Follow the full redirect chain until we land on a rendered page. + while 300 <= res.status_int < 400: + res = res.follow() + flash_body = res.body.decode() + self.assertIn("locked", flash_body) + self.dbsession.refresh(cart) + self.assertEqual(cart.get_product_quantity(product), 1) + def test_offer_checkout_is_single_redemption(self): """Repeated POSTs to /o/{id}/checkout must reuse the same cart — one offer, one cart, one chance to redeem. Otherwise a buyer diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 8ce97c5..a35c4b5 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -384,6 +384,18 @@ def cart_add_product(request): elif product.is_not_sellable: msg = ("that content is not for sale or purchase.", "error") + elif request.active_cart.is_negotiated: + # A negotiated cart is single-purpose: one product, one unit, + # one negotiated price. Bumping quantity or adding other items + # would dilute the override (paying the agreed amount but + # walking with extra units / extra products). + kind = request.active_cart.negotiation_kind # "offer" | "auction" + msg = ( + f"This cart is locked to your accepted {kind}. " + f"Save or check out this cart before adding other items.", + "error", + ) + else: msg = (f'You added "{product.title}" to your cart.', "success") request.active_cart.add_product(product) @@ -486,6 +498,20 @@ def cart_quantity_product(request): ("Refusing to add a product from another shop to cart.", "error") ) return HTTPFound("/cart") + elif cart.is_negotiated: + # Negotiated carts (cart_offer / cart_auction) are locked to + # one unit of the negotiated product. Bumping quantity would + # let the buyer walk with N units at the single-unit negotiated + # price. + request.session.flash( + ( + "Quantity is locked on an accepted " + f"{cart.negotiation_kind} — the agreed price is for one " + "unit only.", + "error", + ) + ) + return HTTPFound("/cart") else: msg = ( f'You updated the quantity of "{product.title}" in the cart.',