Skip to content

Commit

Permalink
Use capture context in cart checkout (#671)
Browse files Browse the repository at this point in the history
* Use capture context to verify transient token

* Add migration

* Minor changes to documentation

* Add tests

* Add comment explaining max char length
  • Loading branch information
aviupadhyayula authored Apr 21, 2024
1 parent 20f68ab commit 78f02fd
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 13 deletions.
18 changes: 18 additions & 0 deletions backend/clubs/migrations/0104_cart_checkout_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-04-21 00:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("clubs", "0103_ticket_group_discount_ticket_group_size"),
]

operations = [
migrations.AddField(
model_name="cart",
name="checkout_context",
field=models.CharField(blank=True, max_length=8297, null=True),
),
]
2 changes: 2 additions & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1789,6 +1789,8 @@ class Cart(models.Model):
owner = models.OneToOneField(
get_user_model(), related_name="cart", on_delete=models.CASCADE
)
# Capture context from Cybersource should be 8297 chars
checkout_context = models.CharField(max_length=8297, blank=True, null=True)


class TicketManager(models.Manager):
Expand Down
39 changes: 28 additions & 11 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,19 +332,15 @@ def hour_to_string_helper(hour):
return hour_string


def validate_transient_token(tt: str) -> Tuple[bool, str]:
def validate_transient_token(cc: str, tt: str) -> Tuple[bool, str]:
"""Validate the integrity of the transient token using
the public key (JWK) obtained from the public key endpoint"""
the public key (JWK) obtained from the capture context"""

cybersource_url = "https://" + settings.CYBERSOURCE_CONFIG["run_environment"]
try:
header, payload, signature = tt.split(".")
decoded_header = json.loads(base64.b64decode(header + "=="))
kid = decoded_header["kid"]
resp = requests.get(f"{cybersource_url}/flex/v2/public-keys/{kid}")
if resp.status_code >= 400:
raise ApiException(reason=f"Public key retrieval failed {resp.json()}")
jwk = resp.json()
_, body, _ = cc.split(".")
decoded_body = json.loads(base64.b64decode(body + "==="))
jwk = decoded_body["flx"]["jwk"]

Check warning on line 342 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L339-L342

Added lines #L339 - L342 were not covered by tests

public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))

Check warning on line 344 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L344

Added line #L344 was not covered by tests
# This will throw if the key is invalid
jwt.decode(tt, key=public_key, algorithms=["RS256"])
Expand Down Expand Up @@ -4964,6 +4960,11 @@ def initiate_checkout(self, request, *args, **kwargs):
reason=f"Received {context} with HTTP status {http_status}",
)

# Tie generated capture context to user cart
if cart.checkout_context != context:
cart.checkout_context = context
cart.save()

# Place hold on tickets for 10 mins
holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
tickets.update(
Expand Down Expand Up @@ -5016,10 +5017,19 @@ def complete_checkout(self, request, *args, **kwargs):
Cart.objects.prefetch_related("tickets"), owner=self.request.user
)

ok, message = validate_transient_token(tt)
cc = cart.checkout_context
if cc is None:
return Response(

Check warning on line 5022 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5022

Added line #L5022 was not covered by tests
{"success": False, "detail": "Associated capture context not found"},
status=status.HTTP_500_BAD_REQUEST,
)

ok, message = validate_transient_token(cc, tt)
if not ok:
# Cleanup state since the purchase failed
cart.tickets.update(holder=None, owner=None)
cart.checkout_context = None
cart.save()
return Response(
{"success": False, "detail": message},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
Expand Down Expand Up @@ -5049,6 +5059,8 @@ def complete_checkout(self, request, *args, **kwargs):
except ApiException as e:
# Cleanup state since the purchase failed
cart.tickets.update(holder=None, owner=None)
cart.checkout_context = None
cart.save()

return Response(
{
Expand Down Expand Up @@ -5076,6 +5088,8 @@ def complete_checkout(self, request, *args, **kwargs):
except ApiException as e:

Check warning on line 5088 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5088

Added line #L5088 was not covered by tests
# Cleanup state since the purchase failed
cart.tickets.update(holder=None, owner=None)
cart.checkout_context = None
cart.save()

Check warning on line 5092 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5090-L5092

Added lines #L5090 - L5092 were not covered by tests

return Response(

Check warning on line 5094 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5094

Added line #L5094 was not covered by tests
{
Expand Down Expand Up @@ -5113,6 +5127,9 @@ def complete_checkout(self, request, *args, **kwargs):

Ticket.objects.update_holds()

cart.checkout_context = None
cart.save()

return Response(
{
"success": True,
Expand Down
66 changes: 64 additions & 2 deletions backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,16 +614,78 @@ def test_initiate_checkout(self):
]
)
) as fake_cap_context:
fake_cap_context.return_value = "abcde", 200, None
cap_context_data = "abcde"
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)

# Tickets are held
# Capture context should be tied to cart
cart = Cart.objects.filter(owner=self.user1).first()
self.assertIsNotNone(cart.checkout_context)
self.assertEqual(cart.checkout_context, cap_context_data)

# Tickets should be held
held_tickets = Ticket.objects.filter(holder=self.user1)
self.assertEqual(held_tickets.count(), 2, held_tickets)
self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets)
self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets)

def test_initiate_concurrent_checkouts(self):
self.client.login(username=self.user1.username, password="test")

# Add tickets to cart
tickets_to_add = {
"quantities": [
{"type": "normal", "count": 1},
{"type": "premium", "count": 1},
]
}
resp = self.client.post(
reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
tickets_to_add,
format="json",
)
self.assertIn(resp.status_code, [200, 201], resp.content)

# Initiate first checkout
cap_context_data = "abc"
with patch(
".".join(
[
"CyberSource",
"UnifiedCheckoutCaptureContextApi",
"generate_unified_checkout_capture_context_with_http_info",
]
)
) as fake_cap_context:
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)

cart = Cart.objects.filter(owner=self.user1).first()
cap_context_1 = cart.checkout_context

# Initiate second checkout
cap_context_data = "def" # simulate capture context changing between checkouts
with patch(
".".join(
[
"CyberSource",
"UnifiedCheckoutCaptureContextApi",
"generate_unified_checkout_capture_context_with_http_info",
]
)
) as fake_cap_context:
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)

cart = Cart.objects.filter(owner=self.user1).first()
cap_context_2 = cart.checkout_context

# Stored capture context should change between checkouts
self.assertNotEqual(cap_context_1, cap_context_2)

def test_initiate_checkout_fails_with_empty_cart(self):
self.client.login(username=self.user1.username, password="test")

Expand Down

0 comments on commit 78f02fd

Please sign in to comment.