fix: lock negotiated cart quantity — refuse /cart/add + quantity bump

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.
This commit is contained in:
russell@unturf.com 2026-05-13 16:43:16 -04:00
parent 527298874e
commit cb9b3df0cf
No known key found for this signature in database
2 changed files with 110 additions and 0 deletions

View file

@ -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

View file

@ -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.',