diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 469e1f5c45e4..b5532cd2c061 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -50,7 +50,8 @@ OrganizationRoleType, OrganizationType, ) -from warehouse.packaging import Project +from warehouse.packaging import IProjectService, Project +from warehouse.packaging.interfaces import TooManyProjectsCreated from warehouse.utils.paginate import paginate_url_factory @@ -2135,6 +2136,62 @@ def test_add_organization_project_new_project_name_conflict( ] assert len(organization.projects) == 1 + @pytest.mark.usefixtures("_enable_organizations") + @pytest.mark.parametrize( + ("resets_in", "expected_error"), + [ + ( + datetime.timedelta(seconds=42), + "Too many new projects created. Try again in 42 seconds.", + ), + (None, "Too many new projects created. Try again later."), + ], + ) + def test_add_organization_project_new_project_ratelimited( + self, + db_request, + pyramid_user, + organization_service, + monkeypatch, + resets_in, + expected_error, + ): + organization = OrganizationFactory.create() + OrganizationRoleFactory.create( + organization=organization, user=db_request.user, role_name="Owner" + ) + + add_organization_project_obj = pretend.stub( + add_existing_project=pretend.stub(data=False), + new_project_name=pretend.stub(data="some-new-project", errors=[]), + validate=lambda *a, **kw: True, + ) + monkeypatch.setattr( + org_views, + "AddOrganizationProjectForm", + lambda *a, **kw: add_organization_project_obj, + ) + + project_service = pretend.stub( + create_project=pretend.raiser(TooManyProjectsCreated(resets_in=resets_in)), + ) + db_request.find_service = lambda svc, **kw: ( + project_service if svc is IProjectService else organization_service + ) + + view = org_views.ManageOrganizationProjectsViews(organization, db_request) + result = view.add_organization_project() + + assert result == { + "organization": organization, + "active_projects": view.active_projects, + "projects_owned": set(), + "projects_sole_owned": set(), + "add_organization_project_form": add_organization_project_obj, + } + assert add_organization_project_obj.new_project_name.errors == [expected_error] + assert len(organization.projects) == 0 + class TestManageOrganizationRoles: @pytest.mark.usefixtures("_enable_organizations") diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index cb9b1fb0970d..07f28880abfd 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -2,7 +2,7 @@ import json -from datetime import datetime +from datetime import datetime, timedelta import pretend import pytest @@ -30,6 +30,7 @@ ) from warehouse.organizations.models import OrganizationProject from warehouse.packaging import services +from warehouse.packaging.interfaces import IProjectService, TooManyProjectsCreated from warehouse.packaging.models import Project from warehouse.rate_limiting.interfaces import IRateLimiter @@ -456,6 +457,56 @@ def test_mint_token_pending_publisher_project_already_exists(db_request): assert oidc_service.find_publisher.calls == [pretend.call(claims, pending=True)] +@pytest.mark.parametrize( + ("resets_in", "expected_description"), + [ + ( + timedelta(seconds=42), + "Too many new projects created. Try again in 42 seconds.", + ), + (None, "Too many new projects created. Try again later."), + ], +) +def test_mint_token_pending_publisher_project_create_ratelimited( + db_request, resets_in, expected_description +): + pending_publisher = PendingGitHubPublisherFactory.create( + project_name="does-not-exist", + ) + + db_request.flags.disallow_oidc = lambda f=None: False + + claims = {"iss": "https://none"} + oidc_service = pretend.stub( + verify_jwt_signature=pretend.call_recorder( + lambda token, issuer_url=None: claims + ), + find_publisher=pretend.call_recorder( + lambda claims, pending=False: pending_publisher + ), + ) + project_service = pretend.stub( + create_project=pretend.raiser(TooManyProjectsCreated(resets_in=resets_in)), + ) + db_request.find_service = lambda svc, **kw: ( + project_service if svc is IProjectService else oidc_service + ) + + resp = views.mint_token( + oidc_service, DUMMY_GITHUB_OIDC_JWT, claims["iss"], db_request + ) + assert db_request.response.status_code == 422 + assert resp == { + "message": "Token request failed", + "errors": [ + { + "code": "too-many-projects", + "description": expected_description, + } + ], + } + + def test_mint_token_from_oidc_pending_publisher_ok(monkeypatch, db_request): user = UserFactory.create() diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py index 9da1ee769e48..c1d632d956af 100644 --- a/tests/unit/packaging/test_services.py +++ b/tests/unit/packaging/test_services.py @@ -1030,7 +1030,7 @@ def test_check_project_test_new_disallowed(self, db_request): service = ProjectService(session=db_request.db) with pytest.raises(HTTPForbidden) as exc: - service.create_project("foo", pretend.stub(), db_request, ratelimited=False) + service.create_project("foo", pretend.stub(id=pretend.stub()), db_request) resp = exc.value assert resp.status_code == 403 diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 8efdfe806146..710ec8df0aa0 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -339,14 +339,14 @@ msgstr "" #: warehouse/accounts/views.py:1794 warehouse/accounts/views.py:2057 #: warehouse/manage/views/oidc_publishers.py:126 -#: warehouse/manage/views/organizations.py:1820 +#: warehouse/manage/views/organizations.py:1830 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" #: warehouse/accounts/views.py:1815 -#: warehouse/manage/views/organizations.py:1843 +#: warehouse/manage/views/organizations.py:1853 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" @@ -375,7 +375,7 @@ msgstr "" #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 #: warehouse/manage/views/oidc_publishers.py:664 -#: warehouse/manage/views/organizations.py:1859 +#: warehouse/manage/views/organizations.py:1869 msgid "The trusted publisher could not be registered" msgstr "" @@ -393,7 +393,7 @@ msgid "" msgstr "" #: warehouse/accounts/views.py:1927 -#: warehouse/manage/views/organizations.py:1908 +#: warehouse/manage/views/organizations.py:1918 msgid "Registered a new pending publisher to create " msgstr "" @@ -661,13 +661,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2259 -#: warehouse/manage/views/organizations.py:980 +#: warehouse/manage/views/organizations.py:990 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2324 -#: warehouse/manage/views/organizations.py:1055 +#: warehouse/manage/views/organizations.py:1065 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -681,7 +681,7 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2400 -#: warehouse/manage/views/organizations.py:1255 +#: warehouse/manage/views/organizations.py:1265 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" @@ -774,37 +774,37 @@ msgid "" "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/organizations.py:956 +#: warehouse/manage/views/organizations.py:966 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:967 +#: warehouse/manage/views/organizations.py:977 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:991 +#: warehouse/manage/views/organizations.py:1001 msgid "Cannot invite new member. Organization is not in good standing." msgstr "" -#: warehouse/manage/views/organizations.py:1150 -#: warehouse/manage/views/organizations.py:1192 +#: warehouse/manage/views/organizations.py:1160 +#: warehouse/manage/views/organizations.py:1202 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1160 +#: warehouse/manage/views/organizations.py:1170 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1208 +#: warehouse/manage/views/organizations.py:1218 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" -#: warehouse/manage/views/organizations.py:1875 +#: warehouse/manage/views/organizations.py:1885 msgid "" "This publisher has already been registered in your organization. See your" " existing pending publishers below." diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index 3903b401fdb9..9a3f11655be0 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -80,6 +80,7 @@ TermsOfServiceEngagement, ) from warehouse.packaging import IProjectService, Project, Role +from warehouse.packaging.interfaces import TooManyProjectsCreated from warehouse.packaging.models import JournalEntry, ProjectFactory from warehouse.subscriptions import IBillingService, ISubscriptionService from warehouse.subscriptions.services import MockStripeBillingService @@ -881,11 +882,20 @@ def add_organization_project(self): self.request.user, request=self.request, creator_is_owner=False, - ratelimited=False, ) except HTTPException as exc: form.new_project_name.errors.append(exc.detail) return default_response + except TooManyProjectsCreated as exc: + retry = ( + f"Try again in {int(exc.resets_in.total_seconds())} seconds." + if exc.resets_in is not None + else "Try again later." + ) + form.new_project_name.errors.append( + f"Too many new projects created. {retry}" + ) + return default_response # Add project to organization. self.organization_service.add_organization_project( diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index 00d20899d44d..7a426b965c4d 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -32,7 +32,7 @@ OIDC_ISSUER_SERVICE_NAMES, lookup_custom_issuer_type, ) -from warehouse.packaging.interfaces import IProjectService +from warehouse.packaging.interfaces import IProjectService, TooManyProjectsCreated from warehouse.packaging.models import ProjectFactory from warehouse.rate_limiting.interfaces import IRateLimiter @@ -225,7 +225,6 @@ def mint_token( pending_publisher.added_by, request, creator_is_owner=pending_publisher.organization_id is None, - ratelimited=False, organization_id=pending_publisher.organization_id, ) except HTTPException as exc: @@ -233,6 +232,21 @@ def mint_token( errors=[{"code": "invalid-payload", "description": str(exc)}], request=request, ) + except TooManyProjectsCreated as exc: + retry = ( + f"Try again in {int(exc.resets_in.total_seconds())} seconds." + if exc.resets_in is not None + else "Try again later." + ) + return _invalid( + errors=[ + { + "code": "too-many-projects", + "description": f"Too many new projects created. {retry}", + } + ], + request=request, + ) # Reify the pending publisher against the newly created project reified_publisher = oidc_service.reify_pending_publisher( diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py index 7626885379f4..d3a0f00c033d 100644 --- a/warehouse/packaging/services.py +++ b/warehouse/packaging/services.py @@ -437,9 +437,7 @@ def _check_ratelimits(self, request, creator): tags=["ratelimiter:user"], ) raise TooManyProjectsCreated( - resets_in=self.ratelimiters["project.create.user"].resets_in( - request.remote_addr - ) + resets_in=self.ratelimiters["project.create.user"].resets_in(creator.id) ) def _hit_ratelimits(self, request, creator): @@ -500,11 +498,9 @@ def create_project( request, *, creator_is_owner=True, - ratelimited=True, organization_id=None, ): - if ratelimited: - self._check_ratelimits(request, creator) + self._check_ratelimits(request, creator) # Check for AdminFlag set by a PyPI Administrator disabling new project # registration, reasons for this include Spammers, security @@ -716,8 +712,7 @@ def create_project( ) request.db.delete(stale_publisher) - if ratelimited: - self._hit_ratelimits(request, creator) + self._hit_ratelimits(request, creator) return project