Skip to content

Commit 5a4be88

Browse files
authored
Merge pull request #5909 from learningequality/hotfixes
Release 2026.05.14
2 parents 720e6e6 + 89a2796 commit 5a4be88

6 files changed

Lines changed: 130 additions & 5 deletions

File tree

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ repos:
8181
contentcuration/kolibri_public/migrations/0004_auto_20240612_1847.py|
8282
contentcuration/kolibri_public/migrations/0006_auto_20250417_1516.py|
8383
)$
84+
# Only checks the root Makefile. Extend if nested Makefiles get added.
85+
- repo: local
86+
hooks:
87+
- id: makefile-syntax
88+
name: Makefile syntax check
89+
entry: make -n
90+
language: system
91+
files: ^Makefile$
92+
pass_filenames: false
8493
# Always keep black as the final hook so it reformats any other reformatting.
8594
- repo: https://github.com/python/black
8695
rev: 20.8b1

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ migrate:
3939
# 4) Remove the management command from this `deploy-migrate` recipe
4040
# 5) Repeat!
4141
deploy-migrate:
42-
python contentcuration/manage.py ensure_versioned_databases_exist & python contentcuration/manage.py create_channel_versions & wait
42+
echo "Nothing to do here!"
4343

4444
contentnodegc:
4545
python contentcuration/manage.py garbage_collect
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from django.contrib.sites.models import Site
2+
from django.test import override_settings
3+
4+
from contentcuration.tests.base import StudioTestCase
5+
from contentcuration.utils.urls import canonical_url
6+
7+
8+
class CanonicalUrlTestCase(StudioTestCase):
9+
@override_settings(SITE_ID=1)
10+
def test_production_domain_uses_https(self):
11+
self.assertEqual(
12+
canonical_url("/settings/"),
13+
"https://studio.learningequality.org/settings/",
14+
)
15+
16+
@override_settings(SITE_ID=3)
17+
def test_branch_subdomain_uses_https(self):
18+
self.assertEqual(
19+
canonical_url("/settings/"),
20+
"https://unstable.studio.learningequality.org/settings/",
21+
)
22+
23+
@override_settings(SITE_ID=2)
24+
def test_local_dev_loopback_uses_http(self):
25+
self.assertEqual(canonical_url("/settings/"), "http://127.0.0.1:8080/settings/")
26+
27+
def test_localhost_uses_http(self):
28+
site = Site.objects.create(
29+
pk=9999, domain="localhost:9000", name="Localhost test"
30+
)
31+
with override_settings(SITE_ID=site.pk):
32+
self.assertEqual(
33+
canonical_url("/settings/"), "http://localhost:9000/settings/"
34+
)
35+
36+
@override_settings(SITE_ID=1)
37+
def test_empty_path_returns_bare_url(self):
38+
self.assertEqual(canonical_url(), "https://studio.learningequality.org")

contentcuration/contentcuration/tests/views/test_subscription.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import mock
22

33
import stripe
4+
from django.contrib.sites.models import Site
45
from django.test import override_settings
56
from django.urls import reverse
67

@@ -57,6 +58,33 @@ def test_rejects_user_with_active_subscription(self, mock_create):
5758
self.assertEqual(response.status_code, 400)
5859
mock_create.assert_not_called()
5960

61+
@override_settings(SITE_ID=1)
62+
@mock.patch("contentcuration.views.subscription.stripe.checkout.Session.create")
63+
def test_checkout_urls_use_canonical_site_domain(self, mock_create):
64+
Site.objects.update_or_create(
65+
pk=1, defaults={"domain": "studio.learningequality.org", "name": "Studio"}
66+
)
67+
mock_create.return_value = mock.Mock(url="https://checkout.stripe.com/test")
68+
69+
self.client.force_authenticate(self.user)
70+
response = self.client.post(
71+
self.url,
72+
data={"storage_gb": 10},
73+
format="json",
74+
HTTP_HOST="master.studio.learningequality.org",
75+
)
76+
77+
self.assertEqual(response.status_code, 200)
78+
call_kwargs = mock_create.call_args[1]
79+
for key in ("success_url", "cancel_url"):
80+
url = call_kwargs[key]
81+
self.assertNotIn(
82+
"master.studio.learningequality.org",
83+
url,
84+
f"{key} leaked internal hostname: {url}",
85+
)
86+
self.assertIn("studio.learningequality.org", url)
87+
6088
@mock.patch("contentcuration.views.subscription.stripe.checkout.Session.create")
6189
def test_user_with_canceled_subscription_can_checkout_again(self, mock_create):
6290
"""User whose subscription was canceled can create a new checkout session."""
@@ -124,6 +152,35 @@ def test_creates_portal_session(self, mock_create):
124152
data = response.json()
125153
self.assertEqual(data["portal_url"], "https://billing.stripe.com/test")
126154

155+
@override_settings(SITE_ID=1)
156+
@mock.patch(
157+
"contentcuration.views.subscription.stripe.billing_portal.Session.create"
158+
)
159+
def test_portal_return_url_uses_canonical_site_domain(self, mock_create):
160+
Site.objects.update_or_create(
161+
pk=1, defaults={"domain": "studio.learningequality.org", "name": "Studio"}
162+
)
163+
UserSubscription.objects.create(
164+
user=self.user,
165+
stripe_customer_id="cus_test123",
166+
stripe_subscription_status="active",
167+
)
168+
mock_create.return_value = mock.Mock(url="https://billing.stripe.com/test")
169+
170+
self.client.force_authenticate(self.user)
171+
response = self.client.post(
172+
self.url, HTTP_HOST="master.studio.learningequality.org"
173+
)
174+
175+
self.assertEqual(response.status_code, 200)
176+
return_url = mock_create.call_args[1]["return_url"]
177+
self.assertNotIn(
178+
"master.studio.learningequality.org",
179+
return_url,
180+
f"return_url leaked internal hostname: {return_url}",
181+
)
182+
self.assertIn("studio.learningequality.org", return_url)
183+
127184

128185
@override_settings(
129186
STRIPE_SECRET_KEY="sk_test_fake",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django.contrib.sites.models import Site
2+
from django.contrib.sites.shortcuts import get_current_site
3+
4+
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
5+
6+
7+
def canonical_url(path="", request=None):
8+
"""Absolute URL on the Site framework's canonical domain.
9+
10+
Prefer this over ``request.build_absolute_uri`` for URLs handed to
11+
external systems: the latter reflects the Host header Django actually
12+
received, which on production is the internal pod hostname rather
13+
than the public canonical.
14+
"""
15+
if request is not None:
16+
domain = get_current_site(request).domain
17+
else:
18+
domain = Site.objects.get_current().domain
19+
scheme = "http" if domain.startswith(_LOCAL_HOSTS) else "https"
20+
return f"{scheme}://{domain}{path}"

contentcuration/contentcuration/views/subscription.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from contentcuration.models import User
1616
from contentcuration.models import UserSubscription
17+
from contentcuration.utils.urls import canonical_url
1718

1819
BYTES_PER_GB = 10 ** 9
1920
MIN_STORAGE_GB = 1
@@ -52,10 +53,10 @@ def post(self, request):
5253
storage_gb = serializer.validated_data["storage_gb"]
5354

5455
try:
55-
success_url = request.build_absolute_uri(
56-
"/settings/#/storage?upgrade=success"
56+
success_url = canonical_url(
57+
"/settings/#/storage?upgrade=success", request=request
5758
)
58-
cancel_url = request.build_absolute_uri("/settings/#/storage")
59+
cancel_url = canonical_url("/settings/#/storage", request=request)
5960

6061
checkout_session_params = {
6162
"mode": "subscription",
@@ -101,7 +102,7 @@ def post(self, request):
101102
try:
102103
session = stripe.billing_portal.Session.create(
103104
customer=subscription.stripe_customer_id,
104-
return_url=request.build_absolute_uri("/settings/#/storage"),
105+
return_url=canonical_url("/settings/#/storage", request=request),
105106
)
106107
return Response({"portal_url": session.url})
107108

0 commit comments

Comments
 (0)