diff --git a/fxsharing/settings.py b/fxsharing/settings.py index a183994..1893c2e 100644 --- a/fxsharing/settings.py +++ b/fxsharing/settings.py @@ -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 diff --git a/fxsharing/shares/tests.py b/fxsharing/shares/tests.py index 92e0c55..adc1e43 100644 --- a/fxsharing/shares/tests.py +++ b/fxsharing/shares/tests.py @@ -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 = { diff --git a/fxsharing/shares/views.py b/fxsharing/shares/views.py index 1102b25..f8f7d59 100644 --- a/fxsharing/shares/views.py +++ b/fxsharing/shares/views.py @@ -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( @@ -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: