Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions fxsharing/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@

LOGIN_REDIRECT_URL = env("LOGIN_REDIRECT_URL", default="/auth-complete")

# Max active (non-deleted) top-level shares a user may hold within the trailing
# SHARE_EXPIRY_DAYS window before create_share rejects with 429.
MAX_ACTIVE_SHARES = env.int("MAX_ACTIVE_SHARES", default=15)


# Application definition

Expand Down
72 changes: 72 additions & 0 deletions fxsharing/shares/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,78 @@ def test_rejects_bookmark_folder_type(self):
assert response.status_code == 400


@override_settings(MAX_ACTIVE_SHARES=3)
class TestCreateShareActiveLimit(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(fxa_id="a1b2c3d4e5f6limit")
cls.other = User.objects.create_user(fxa_id="a1b2c3d4e5f6other")

def setUp(self):
self.client.force_login(self.user)

def _post(self):
payload = {
"type": "tabs",
"title": "My Links",
"links": [{"url": "https://example.com", "title": "Example"}],
}
return self.client.post(
reverse("create_share"),
data=json.dumps(payload),
content_type="application/json",
)

def test_under_limit_succeeds(self):
Share.objects.create(title="A", user=self.user)
Share.objects.create(title="B", user=self.user)
response = self._post()
assert response.status_code == 201

def test_at_limit_returns_429(self):
for i in range(3):
Share.objects.create(title=f"S{i}", user=self.user)
response = self._post()
assert response.status_code == 429
assert "error" in response.json()
# The rejected request created no share.
assert Share.objects.filter(user=self.user).count() == 3

def test_soft_deleted_shares_do_not_count(self):
# Three shares created then soft-deleted no longer count against the cap.
for i in range(3):
Share.objects.create(title=f"S{i}", user=self.user).delete()
response = self._post()
assert response.status_code == 201

def test_nested_shares_do_not_count(self):
# Two top-level shares (one with two nested sub-shares) = 4 rows but only
# 2 count, so the user is still under the cap of 3.
Share.objects.create(title="Top1", user=self.user)
parent = Share.objects.create(title="Top2", user=self.user)
Share.objects.create(title="Nested1", user=self.user, parent_share=parent)
Share.objects.create(title="Nested2", user=self.user, parent_share=parent)
response = self._post()
assert response.status_code == 201

def test_expired_shares_outside_window_do_not_count(self):
# Shares created more than SHARE_EXPIRY_DAYS ago have expired and no
# longer count toward the active cap.
old = timezone.now() - timedelta(days=8)
for i in range(3):
share = Share.objects.create(title=f"Old{i}", user=self.user)
Share.objects.filter(pk=share.pk).update(created_at=old)
response = self._post()
assert response.status_code == 201

def test_limit_is_per_user(self):
for i in range(3):
Share.objects.create(title=f"Other{i}", user=self.other)
# self.user is at zero; the other user's shares don't count.
response = self._post()
assert response.status_code == 201


class TestCreateShareRequiresAuth(TestCase):
def test_anonymous_post_is_rejected(self):
payload = {
Expand Down
24 changes: 24 additions & 0 deletions fxsharing/shares/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ def view_share(request, shortcode):
SHARE_EXPIRY_DAYS = 7


def active_share_count(user):
"""Count a user's active (non-deleted, non-expired) top-level shares.

Uses the default manager (which excludes soft-deleted shares) and restricts
to top-level shares created within the trailing SHARE_EXPIRY_DAYS window —
equivalently, those that have not yet expired.
"""
window_start = timezone.now() - timedelta(days=SHARE_EXPIRY_DAYS)
return Share.objects.filter(
user=user,
parent_share__isnull=True,
created_at__gte=window_start,
).count()


@transaction.atomic
def create_share_from_data(data, user, parent_share=None):
share = Share.objects.create(
Expand Down Expand Up @@ -188,6 +203,15 @@ def create_share(request):
except ValidationError as e:
return HttpResponseBadRequest(f"JSON validation error: {e.message}")

# Cap how many active (non-deleted, non-expired) shares a user may hold.
if active_share_count(request.user) >= settings.MAX_ACTIVE_SHARES:
with tracer.start_as_current_span("share.create") as span:
span.set_attribute("share.outcome", "limit_reached")
return JsonResponse(
{"error": "You have reached the maximum number of active shares."},
status=429,
)

# Always create a fresh share page so a user can generate a new link
# from the same tab group each time they share.
with tracer.start_as_current_span("share.create") as span:
Expand Down
Loading