diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index c926bdd5842d..0c799490e40a 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -57,7 +57,11 @@ from warehouse.events.tags import EventTag from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.interfaces import TooManyOIDCRegistrations -from warehouse.oidc.models import PendingGitHubPublisher, PendingGooglePublisher +from warehouse.oidc.models import ( + PendingActiveStatePublisher, + PendingGitHubPublisher, + PendingGooglePublisher, +) from warehouse.organizations.models import ( OrganizationInvitation, OrganizationRole, @@ -3347,6 +3351,15 @@ def test_manage_publishing(self, metrics, monkeypatch): monkeypatch.setattr( views, "PendingGooglePublisherForm", pending_google_publisher_form_cls ) + pending_activestate_publisher_form_obj = pretend.stub() + pending_activestate_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_activestate_publisher_form_obj + ) + monkeypatch.setattr( + views, + "PendingActiveStatePublisherForm", + pending_activestate_publisher_form_cls, + ) view = views.ManageAccountPublishingViews(request) @@ -3354,15 +3367,18 @@ def test_manage_publishing(self, metrics, monkeypatch): "disabled": { "GitHub": False, "Google": False, + "ActiveState": False, }, "pending_github_publisher_form": pending_github_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, + "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert project_factory_cls.calls == [pretend.call(request)] assert pending_github_publisher_form_cls.calls == [ @@ -3405,6 +3421,15 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): monkeypatch.setattr( views, "PendingGooglePublisherForm", pending_google_publisher_form_cls ) + pending_activestate_publisher_form_obj = pretend.stub() + pending_activestate_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_activestate_publisher_form_obj + ) + monkeypatch.setattr( + views, + "PendingActiveStatePublisherForm", + pending_activestate_publisher_form_cls, + ) view = views.ManageAccountPublishingViews(pyramid_request) @@ -3412,15 +3437,18 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): "disabled": { "GitHub": True, "Google": True, + "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, + "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert pyramid_request.session.flash.calls == [ pretend.call( @@ -3452,6 +3480,11 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): AdminFlagValue.DISALLOW_GOOGLE_OIDC, "Google", ), + ( + "add_pending_activestate_oidc_publisher", + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, + "ActiveState", + ), ], ) def test_add_pending_oidc_publisher_admin_disabled( @@ -3479,7 +3512,18 @@ def test_add_pending_oidc_publisher_admin_disabled( lambda *a, **kw: pending_github_publisher_form_obj ) monkeypatch.setattr( - views, "PendingGitHubPublisherForm", pending_github_publisher_form_cls + views, + "PendingGitHubPublisherForm", + pending_github_publisher_form_cls, + ) + pending_activestate_publisher_form_obj = pretend.stub() + pending_activestate_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_activestate_publisher_form_obj + ) + monkeypatch.setattr( + views, + "PendingActiveStatePublisherForm", + pending_activestate_publisher_form_cls, ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( @@ -3495,17 +3539,21 @@ def test_add_pending_oidc_publisher_admin_disabled( "disabled": { "GitHub": True, "Google": True, + "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, + "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert pyramid_request.session.flash.calls == [ pretend.call( @@ -3538,6 +3586,11 @@ def test_add_pending_oidc_publisher_admin_disabled( AdminFlagValue.DISALLOW_GOOGLE_OIDC, "Google", ), + ( + "add_pending_activestate_oidc_publisher", + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, + "ActiveState", + ), ], ) def test_add_pending_oidc_publisher_user_cannot_register( @@ -3581,6 +3634,15 @@ def test_add_pending_oidc_publisher_user_cannot_register( monkeypatch.setattr( views, "PendingGooglePublisherForm", pending_google_publisher_form_cls ) + pending_activestate_publisher_form_obj = pretend.stub() + pending_activestate_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_activestate_publisher_form_obj + ) + monkeypatch.setattr( + views, + "PendingActiveStatePublisherForm", + pending_activestate_publisher_form_cls, + ) view = views.ManageAccountPublishingViews(pyramid_request) @@ -3588,17 +3650,21 @@ def test_add_pending_oidc_publisher_user_cannot_register( "disabled": { "GitHub": False, "Google": False, + "ActiveState": False, }, "pending_github_publisher_form": pending_github_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, + "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert view.metrics.increment.calls == [ pretend.call( @@ -3654,6 +3720,20 @@ def test_add_pending_oidc_publisher_user_cannot_register( ), PendingGooglePublisher, ), + ( + "add_pending_activestate_oidc_publisher", + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, + "ActiveState", + lambda i, user_id: PendingActiveStatePublisher( + project_name="some-project-name-" + str(i), + added_by_id=user_id, + organization="some-org-" + str(i), + activestate_project_name="some-project-" + str(i), + actor="some-user-" + str(i), + actor_id="some-user-id-" + str(i), + ), + PendingActiveStatePublisher, + ), ], ) def test_add_pending_github_oidc_publisher_too_many_already( @@ -3699,11 +3779,14 @@ def test_add_pending_github_oidc_publisher_too_many_already( assert db_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert view.metrics.increment.calls == [ pretend.call( @@ -3733,6 +3816,10 @@ def test_add_pending_github_oidc_publisher_too_many_already( "add_pending_google_oidc_publisher", "Google", ), + ( + "add_pending_activestate_oidc_publisher", + "ActiveState", + ), ], ) def test_add_pending_oidc_publisher_ratelimited( @@ -3799,6 +3886,10 @@ def test_add_pending_oidc_publisher_ratelimited( "add_pending_google_oidc_publisher", "Google", ), + ( + "add_pending_activestate_oidc_publisher", + "ActiveState", + ), ], ) def test_add_pending_oidc_publisher_invalid_form( @@ -3846,6 +3937,19 @@ def test_add_pending_oidc_publisher_invalid_form( "validate_project_name", lambda *a: True, ) + + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_organization", + lambda *a: None, + ) + + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_actor", + lambda *a: {"user_id": "some-user-id"}, + ) + monkeypatch.setattr( view, "_check_ratelimits", pretend.call_recorder(lambda: None) ) @@ -3905,6 +4009,26 @@ def test_add_pending_oidc_publisher_invalid_form( } ), ), + ( + "add_pending_activestate_oidc_publisher", + "ActiveState", + lambda user_id: PendingActiveStatePublisher( + project_name="some-project-name", + added_by_id=user_id, + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), + MultiDict( + { + "organization": "some-org", + "project": "some-project", + "actor": "some-user", + "project_name": "some-other-project-name", + } + ), + ), ], ) def test_add_pending_oidc_publisher_already_exists( @@ -3947,6 +4071,19 @@ def test_add_pending_oidc_publisher_already_exists( "_lookup_owner", lambda *a: {"login": "some-owner", "id": "some-owner-id"}, ) + + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_organization", + lambda *a: None, + ) + + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_actor", + lambda *a: {"user_id": "some-user-id"}, + ) + monkeypatch.setattr( view, "_check_ratelimits", pretend.call_recorder(lambda: None) ) @@ -4003,6 +4140,19 @@ def test_add_pending_oidc_publisher_already_exists( ), PendingGooglePublisher, ), + ( + "add_pending_activestate_oidc_publisher", + "ActiveState", + MultiDict( + { + "organization": "some-org", + "project": "some-project", + "actor": "some-user", + "project_name": "some-project-name", + } + ), + PendingActiveStatePublisher, + ), ], ) def test_add_pending_oidc_publisher( @@ -4035,6 +4185,18 @@ def test_add_pending_oidc_publisher( lambda *a: {"login": "some-owner", "id": "some-owner-id"}, ) + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_organization", + lambda *a: None, + ) + + monkeypatch.setattr( + views.PendingActiveStatePublisherForm, + "_lookup_actor", + lambda *a: {"user_id": "some-user-id"}, + ) + view = views.ManageAccountPublishingViews(db_request) monkeypatch.setattr( @@ -4123,6 +4285,15 @@ def test_delete_pending_oidc_publisher_admin_disabled( monkeypatch.setattr( views, "PendingGooglePublisherForm", pending_google_publisher_form_cls ) + pending_activestate_publisher_form_obj = pretend.stub() + pending_activestate_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_activestate_publisher_form_obj + ) + monkeypatch.setattr( + views, + "PendingActiveStatePublisherForm", + pending_activestate_publisher_form_cls, + ) view = views.ManageAccountPublishingViews(pyramid_request) @@ -4130,15 +4301,18 @@ def test_delete_pending_oidc_publisher_admin_disabled( "disabled": { "GitHub": True, "Google": True, + "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, + "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert pyramid_request.session.flash.calls == [ pretend.call( @@ -4211,6 +4385,17 @@ def test_delete_pending_oidc_publisher_invalid_form( ), PendingGooglePublisher, ), + ( + lambda user_id: PendingActiveStatePublisher( + project_name="some-project-name", + added_by_id=user_id, + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), + PendingActiveStatePublisher, + ), ], ) def test_delete_pending_oidc_publisher_not_found( diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index ef8a43c3497e..706b193842e3 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -47,7 +47,12 @@ from warehouse.manage.views import organizations as org_views from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.interfaces import TooManyOIDCRegistrations -from warehouse.oidc.models import GitHubPublisher, GooglePublisher, OIDCPublisher +from warehouse.oidc.models import ( + ActiveStatePublisher, + GitHubPublisher, + GooglePublisher, + OIDCPublisher, +) from warehouse.organizations.interfaces import IOrganizationService from warehouse.organizations.models import ( OrganizationRoleType, @@ -5835,16 +5840,18 @@ def test_manage_project_oidc_publishers(self, monkeypatch): view = views.ManageOIDCPublisherViews(project, request) assert view.manage_project_oidc_publishers() == { - "disabled": {"GitHub": False, "Google": False}, + "disabled": {"GitHub": False, "Google": False, "ActiveState": False}, "project": project, "github_publisher_form": view.github_publisher_form, "google_publisher_form": view.google_publisher_form, + "activestate_publisher_form": view.activestate_publisher_form, } assert request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] def test_manage_project_oidc_publishers_admin_disabled( @@ -5869,16 +5876,18 @@ def test_manage_project_oidc_publishers_admin_disabled( view = views.ManageOIDCPublisherViews(project, pyramid_request) assert view.manage_project_oidc_publishers() == { - "disabled": {"GitHub": True, "Google": True}, + "disabled": {"GitHub": True, "Google": True, "ActiveState": True}, "project": project, "github_publisher_form": view.github_publisher_form, "google_publisher_form": view.google_publisher_form, + "activestate_publisher_form": view.activestate_publisher_form, } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), + pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] assert pyramid_request.session.flash.calls == [ pretend.call( @@ -5930,6 +5939,27 @@ def test_manage_project_oidc_publishers_admin_disabled( sub=pretend.stub(data=publisher.sub), ), ), + ( + "add_activestate_oidc_publisher", + pretend.stub( + id="fakeid", + publisher_name="ActiveState", + publisher_url=( + lambda x=None: "https://platform.activestate.com/some-org/some-project" # noqa + ), + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), + lambda publisher: pretend.stub( + validate=pretend.call_recorder(lambda: True), + organization=pretend.stub(data=publisher.organization), + project=pretend.stub(data=publisher.activestate_project_name), + actor=pretend.stub(data=publisher.actor), + actor_id="some-user-id", + ), + ), ], ) def test_add_oidc_publisher_preexisting( @@ -5973,6 +6003,7 @@ def test_add_oidc_publisher_preexisting( publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) view = views.ManageOIDCPublisherViews(project, request) monkeypatch.setattr( @@ -6048,6 +6079,20 @@ def test_add_oidc_publisher_preexisting( ), "Google", ), + ( + "add_activestate_oidc_publisher", + pretend.stub( + validate=pretend.call_recorder(lambda: True), + id="fakeid", + publisher_name="ActiveState", + publisher_url=lambda x=None: None, + organization=pretend.stub(data="fake-org"), + project=pretend.stub(data="fake-project"), + actor=pretend.stub(data="fake-actor"), + actor_id="some-user-id", + ), + "ActiveState", + ), ], ) def test_add_oidc_publisher_created( @@ -6088,6 +6133,7 @@ def test_add_oidc_publisher_created( publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) monkeypatch.setattr( views, "send_trusted_publisher_added_email", @@ -6191,6 +6237,23 @@ def test_add_oidc_publisher_created( } ), ), + ( + "add_activestate_oidc_publisher", + "ActiveState", + ActiveStatePublisher( + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), + MultiDict( + { + "organization": "some-org", + "project": "some-project", + "actor": "some-user", + } + ), + ), ], ) def test_add_oidc_publisher_already_registered_with_project( @@ -6227,6 +6290,18 @@ def test_add_oidc_publisher_already_registered_with_project( lambda *a: {"login": "some-owner", "id": "some-owner-id"}, ) + monkeypatch.setattr( + views.ActiveStatePublisherForm, + "_lookup_organization", + lambda *a: None, + ) + + monkeypatch.setattr( + views.ActiveStatePublisherForm, + "_lookup_actor", + lambda *a: {"user_id": "some-user-id"}, + ) + monkeypatch.setattr( view, "_hit_ratelimits", pretend.call_recorder(lambda: None) ) @@ -6235,10 +6310,11 @@ def test_add_oidc_publisher_already_registered_with_project( ) assert getattr(view, view_name)() == { - "disabled": {"GitHub": False, "Google": False}, + "disabled": {"GitHub": False, "Google": False, "ActiveState": False}, "project": project, "github_publisher_form": view.github_publisher_form, "google_publisher_form": view.google_publisher_form, + "activestate_publisher_form": view.activestate_publisher_form, } assert view.metrics.increment.calls == [ pretend.call( @@ -6259,6 +6335,7 @@ def test_add_oidc_publisher_already_registered_with_project( [ ("add_github_oidc_publisher", "GitHub"), ("add_google_oidc_publisher", "Google"), + ("add_activestate_oidc_publisher", "ActiveState"), ], ) def test_add_oidc_publisher_ratelimited( @@ -6307,6 +6384,7 @@ def test_add_oidc_publisher_ratelimited( [ ("add_github_oidc_publisher", "GitHub"), ("add_google_oidc_publisher", "Google"), + ("add_activestate_oidc_publisher", "ActiveState"), ], ) def test_add_oidc_publisher_admin_disabled( @@ -6348,6 +6426,7 @@ def test_add_oidc_publisher_admin_disabled( [ ("add_github_oidc_publisher", "GitHub"), ("add_google_oidc_publisher", "Google"), + ("add_activestate_oidc_publisher", "ActiveState"), ], ) def test_add_oidc_publisher_invalid_form( @@ -6372,11 +6451,13 @@ def test_add_oidc_publisher_invalid_form( publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) view = views.ManageOIDCPublisherViews(project, request) default_response = { "github_publisher_form": publisher_form_obj, "google_publisher_form": publisher_form_obj, + "activestate_publisher_form": publisher_form_obj, } monkeypatch.setattr( views.ManageOIDCPublisherViews, "default_response", default_response @@ -6413,6 +6494,12 @@ def test_add_oidc_publisher_invalid_form( email="some-email@example.com", sub="some-sub", ), + ActiveStatePublisher( + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), ], ) def test_delete_oidc_publisher_registered_to_multiple_projects( @@ -6515,6 +6602,12 @@ def test_delete_oidc_publisher_registered_to_multiple_projects( email="some-email@example.com", sub="some-sub", ), + ActiveStatePublisher( + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), ], ) def test_delete_oidc_publisher_entirely(self, monkeypatch, db_request, publisher): diff --git a/tests/unit/oidc/forms/test_activestate.py b/tests/unit/oidc/forms/test_activestate.py new file mode 100644 index 000000000000..7d2fac4021f9 --- /dev/null +++ b/tests/unit/oidc/forms/test_activestate.py @@ -0,0 +1,594 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend +import pytest +import requests +import wtforms + +from requests import ConnectionError, HTTPError, Timeout +from webob.multidict import MultiDict + +from warehouse.oidc.forms import activestate + +fake_username = "some-username" +fake_org_name = "some-org" +fake_user_info = {"user_id": "some-user-id"} +fake_org_info = {"added": "somedatestring"} +fake_gql_org_response = {"data": {"organizations": [fake_org_info]}} +fake_gql_user_response = {"data": {"users": [fake_user_info]}} + +_requests = requests + + +def _raise(exception): + raise exception + + +class TestPendingActiveStatePublisherForm: + def test_validate(self, monkeypatch): + project_factory = [] + data = MultiDict( + { + "organization": "some-org", + "project": "some-project", + "actor": "someuser", + "project_name": "some-project", + } + ) + form = activestate.PendingActiveStatePublisherForm( + MultiDict(data), project_factory=project_factory + ) + + # Test built-in validations + monkeypatch.setattr(form, "_lookup_actor", lambda *o: {"user_id": "some-id"}) + + monkeypatch.setattr(form, "_lookup_organization", lambda *o: None) + + assert form._project_factory == project_factory + assert form.validate() + + def test_validate_project_name_already_in_use(self): + project_factory = ["some-project"] + form = activestate.PendingActiveStatePublisherForm( + project_factory=project_factory + ) + + field = pretend.stub(data="some-project") + with pytest.raises(wtforms.validators.ValidationError): + form.validate_project_name(field) + + +class TestActiveStatePublisherForm: + def test_validate(self, monkeypatch): + data = MultiDict( + { + "organization": "some-org", + "project": "some-project", + "actor": "someuser", + } + ) + form = activestate.ActiveStatePublisherForm(MultiDict(data)) + + monkeypatch.setattr(form, "_lookup_organization", lambda o: None) + monkeypatch.setattr(form, "_lookup_actor", lambda o: fake_user_info) + + assert form.validate(), str(form.errors) + + def test_lookup_actor_404(self, monkeypatch): + response = pretend.stub( + status_code=404, + raise_for_status=pretend.raiser(HTTPError), + content=b"fake-content", + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + expception=_requests.exceptions, + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}", # noqa: E501 + "variables": {"username": fake_username}, + }, + timeout=5, + ) + ] + + def test_lookup_actor_other_http_error(self, monkeypatch): + response = pretend.stub( + # anything that isn't 404 or 403 + status_code=422, + raise_for_status=pretend.raiser(HTTPError), + content=b"fake-content", + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + expception=_requests.exceptions, + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}", # noqa: E501 + "variables": {"username": fake_username}, + }, + timeout=5, + ) + ] + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected 422 error from ActiveState API: b'fake-content'") + ] + + def test_lookup_actor_http_timeout(self, monkeypatch): + requests = pretend.stub( + post=pretend.raiser(Timeout), + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Connection error from ActiveState API") + ] + + def test_lookup_actor_connection_error(self, monkeypatch): + requests = pretend.stub( + post=pretend.raiser(ConnectionError), + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Connection error from ActiveState API") + ] + + def test_lookup_actor_non_json(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: _raise(_requests.exceptions.JSONDecodeError("", "", 0)), + content=b"", + ) + + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected error from ActiveState API: b''") + ] + + def test_lookup_actor_gql_error(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: {"errors": ["some error"]}, + content=b"fake-content", + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}", # noqa: E501 + "variables": {"username": fake_username}, + }, + timeout=5, + ) + ] + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected error from ActiveState API: ['some error']") + ] + + def test_lookup_actor_gql_no_data(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: {"data": {"users": []}}, + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_actor(fake_username) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}", # noqa: E501 + "variables": {"username": fake_username}, + }, + timeout=5, + ) + ] + + def test_lookup_actor_succeeds(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: fake_gql_user_response, + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + requests=_requests.exceptions, + Timeout=_requests.Timeout, + ConnectionError=_requests.ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + info = form._lookup_actor(fake_username) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}", # noqa: E501 + "variables": {"username": fake_username}, + }, + timeout=5, + ) + ] + assert info == fake_user_info + + # _lookup_organization + def test_lookup_organization_404(self, monkeypatch): + response = pretend.stub( + status_code=404, + raise_for_status=pretend.raiser(HTTPError), + content=b"fake-content", + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + Timeout=_requests.Timeout, + ConnectionError=_requests.ConnectionError, + ) + + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}", # noqa: E501 + "variables": {"orgname": fake_org_name}, + }, + timeout=5, + ) + ] + + def test_lookup_organization_other_http_error(self, monkeypatch): + response = pretend.stub( + # anything that isn't 404 or 403 + status_code=422, + raise_for_status=pretend.raiser(HTTPError), + content=b"fake-content", + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + Timeout=_requests.Timeout, + ConnectionError=_requests.ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}", # noqa: E501 + "variables": {"orgname": fake_org_name}, + }, + timeout=5, + ) + ] + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected 422 error from ActiveState API: b'fake-content'") + ] + + def test_lookup_organization_http_timeout(self, monkeypatch): + requests = pretend.stub( + post=pretend.raiser(Timeout), + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Connection error from ActiveState API") + ] + + def test_lookup_organization_connection_error(self, monkeypatch): + requests = pretend.stub( + post=pretend.raiser(ConnectionError), + Timeout=Timeout, + HTTPError=HTTPError, + ConnectionError=ConnectionError, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Connection error from ActiveState API") + ] + + def test_lookup_organization_non_json(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: _raise(_requests.exceptions.JSONDecodeError("", "", 0)), + content=b"", + ) + + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected error from ActiveState API: b''") + ] + + def test_lookup_organization_gql_error(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: {"errors": ["some error"]}, + content=b'{"errors": ["some error"]}', + ) + + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(activestate, "sentry_sdk", sentry_sdk) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}", # noqa: E501 + "variables": {"orgname": fake_org_name}, + }, + timeout=5, + ) + ] + assert sentry_sdk.capture_message.calls == [ + pretend.call("Unexpected error from ActiveState API: ['some error']") + ] + + def test_lookup_organization_gql_no_data(self, monkeypatch): + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: {"data": {"organizations": []}}, + content='{"data": {"organizations": []}}', + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), + HTTPError=HTTPError, + exceptions=_requests.exceptions, + ) + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_organization(fake_org_name) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}", # noqa: E501 + "variables": {"orgname": fake_org_name}, + }, + timeout=5, + ) + ] + + def test_lookup_organization_succeeds(self, monkeypatch): + response = pretend.stub( + status_code=200, + json=lambda: fake_gql_org_response, + ) + requests = pretend.stub( + post=pretend.call_recorder(lambda o, **kw: response), HTTPError=HTTPError + ) + monkeypatch.setattr(activestate, "requests", requests) + + form = activestate.ActiveStatePublisherForm() + form._lookup_organization(fake_org_name) + + assert requests.post.calls == [ + pretend.call( + "https://platform.activestate.com/graphql/v1/graphql", + json={ + "query": "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}", # noqa: E501 + "variables": {"orgname": fake_org_name}, + }, + timeout=5, + ) + ] + + @pytest.mark.parametrize( + "data", + [ + # Organization + # Missing + # Empty + {"organization": "", "project": "good", "actor": "good"}, + # Actor + # Missing + # Empty + {"actor": "", "project": "good", "organization": "good"}, + {"actor": None, "project": "good", "organization": "good"}, + # Project + # Too short + # Too long + # Invalid characters + # No leading or ending - + # No double -- + # Missing + # Empty + {"project": "AB", "actor": "good", "organization": "good"}, + { + "project": "abcdefghojklmnopqrstuvwxyz123456789012345", + "actor": "good", + "organization": "good", + }, + { + "project": "invalid_characters@", + "actor": "good", + "organization": "good", + }, + {"project": "-foo-", "actor": "good", "organization": "good"}, + {"project": "---", "actor": "good", "organization": "good"}, + {"project": "", "actor": "good", "organization": "good"}, + {"project": None, "actor": "good", "organization": "good"}, + ], + ) + def test_validate_basic_invalid_fields(self, monkeypatch, data): + print(data) + form = activestate.ActiveStatePublisherForm(MultiDict(data)) + + monkeypatch.setattr(form, "_lookup_actor", lambda o: fake_user_info) + monkeypatch.setattr(form, "_lookup_organization", lambda o: None) + + assert not form.validate() + + def test_validate_owner(self, monkeypatch): + form = activestate.ActiveStatePublisherForm() + + monkeypatch.setattr(form, "_lookup_actor", lambda o: fake_user_info) + + field = pretend.stub(data=fake_username) + form.validate_actor(field) + + assert form.actor_id == "some-user-id" diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index f405805aedc7..02e6ccb7c82d 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -79,11 +79,13 @@ from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.forms import ( DeletePublisherForm, + PendingActiveStatePublisherForm, PendingGitHubPublisherForm, PendingGooglePublisherForm, ) from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.oidc.models import ( + PendingActiveStatePublisher, PendingGitHubPublisher, PendingGooglePublisher, PendingOIDCPublisher, @@ -1478,6 +1480,10 @@ def __init__(self, request): self.request.POST, project_factory=self.project_factory, ) + self.pending_activestate_publisher_form = PendingActiveStatePublisherForm( + self.request.POST, + project_factory=self.project_factory, + ) @property def _ratelimiters(self): @@ -1514,6 +1520,7 @@ def default_response(self): return { "pending_github_publisher_form": self.pending_github_publisher_form, "pending_google_publisher_form": self.pending_google_publisher_form, + "pending_activestate_publisher_form": self.pending_activestate_publisher_form, # noqa: E501 "disabled": { "GitHub": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GITHUB_OIDC @@ -1521,6 +1528,9 @@ def default_response(self): "Google": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GOOGLE_OIDC ), + "ActiveState": self.request.flags.disallow_oidc( + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC + ), }, } @@ -1711,6 +1721,32 @@ def add_pending_github_oidc_publisher(self): ), ) + @view_config( + request_method="POST", + request_param=PendingActiveStatePublisherForm.__params__, + ) + def add_pending_activestate_oidc_publisher(self): + form = self.default_response["pending_activestate_publisher_form"] + return self._add_pending_oidc_publisher( + publisher_name="ActiveState", + publisher_class=PendingActiveStatePublisher, + admin_flag=AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, + form=form, + make_pending_publisher=lambda request, form: PendingActiveStatePublisher( + project_name=form.project_name.data, + added_by=self.request.user, + organization=form.organization.data, + activestate_project_name=form.project.data, + actor=form.actor.data, + actor_id=form.actor_id, + ), + make_existence_filters=lambda form: dict( + organization=form.organization.data, + activestate_project_name=form.project.data, + actor_id=form.actor_id, + ), + ) + @view_config( request_method="POST", request_param=DeletePublisherForm.__params__, diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 226d1698a3af..6503f9aef7b0 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -114,221 +114,223 @@ msgstr "" msgid "No user found with that username or email" msgstr "" -#: warehouse/accounts/views.py:115 +#: warehouse/accounts/views.py:117 msgid "" "There have been too many unsuccessful login attempts. You have been " "locked out for {}. Please try again later." msgstr "" -#: warehouse/accounts/views.py:132 +#: warehouse/accounts/views.py:134 msgid "" "Too many emails have been added to this account without verifying them. " "Check your inbox and follow the verification links. (IP: ${ip})" msgstr "" -#: warehouse/accounts/views.py:144 +#: warehouse/accounts/views.py:146 msgid "" "Too many password resets have been requested for this account without " "completing them. Check your inbox and follow the verification links. (IP:" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:328 warehouse/accounts/views.py:397 -#: warehouse/accounts/views.py:399 warehouse/accounts/views.py:428 -#: warehouse/accounts/views.py:430 warehouse/accounts/views.py:536 +#: warehouse/accounts/views.py:330 warehouse/accounts/views.py:399 +#: warehouse/accounts/views.py:401 warehouse/accounts/views.py:430 +#: warehouse/accounts/views.py:432 warehouse/accounts/views.py:538 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:391 +#: warehouse/accounts/views.py:393 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:471 +#: warehouse/accounts/views.py:473 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:567 warehouse/manage/views/__init__.py:826 +#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:832 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:659 +#: warehouse/accounts/views.py:661 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:790 +#: warehouse/accounts/views.py:792 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:792 +#: warehouse/accounts/views.py:794 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:794 warehouse/accounts/views.py:907 -#: warehouse/accounts/views.py:1011 warehouse/accounts/views.py:1180 +#: warehouse/accounts/views.py:796 warehouse/accounts/views.py:909 +#: warehouse/accounts/views.py:1013 warehouse/accounts/views.py:1182 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:798 +#: warehouse/accounts/views.py:800 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:803 +#: warehouse/accounts/views.py:805 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:825 +#: warehouse/accounts/views.py:827 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:843 +#: warehouse/accounts/views.py:845 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:875 +#: warehouse/accounts/views.py:877 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:903 +#: warehouse/accounts/views.py:905 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:905 +#: warehouse/accounts/views.py:907 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:911 +#: warehouse/accounts/views.py:913 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:920 +#: warehouse/accounts/views.py:922 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:923 +#: warehouse/accounts/views.py:925 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:940 +#: warehouse/accounts/views.py:942 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:944 +#: warehouse/accounts/views.py:946 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:949 +#: warehouse/accounts/views.py:951 msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1007 +#: warehouse/accounts/views.py:1009 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1009 +#: warehouse/accounts/views.py:1011 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1015 +#: warehouse/accounts/views.py:1017 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1019 +#: warehouse/accounts/views.py:1021 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1028 +#: warehouse/accounts/views.py:1030 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1079 +#: warehouse/accounts/views.py:1081 msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1142 +#: warehouse/accounts/views.py:1144 msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1176 +#: warehouse/accounts/views.py:1178 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1178 +#: warehouse/accounts/views.py:1180 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1184 +#: warehouse/accounts/views.py:1186 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1188 +#: warehouse/accounts/views.py:1190 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1203 +#: warehouse/accounts/views.py:1205 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1234 +#: warehouse/accounts/views.py:1236 msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1300 +#: warehouse/accounts/views.py:1302 msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1531 warehouse/accounts/views.py:1721 -#: warehouse/manage/views/__init__.py:1193 +#: warehouse/accounts/views.py:1541 warehouse/accounts/views.py:1757 +#: warehouse/manage/views/__init__.py:1204 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1552 +#: warehouse/accounts/views.py:1562 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1568 +#: warehouse/accounts/views.py:1578 msgid "" "You must have a verified email in order to register a pending trusted " "publisher. See https://pypi.org/help#openid-connect for details." msgstr "" -#: warehouse/accounts/views.py:1581 +#: warehouse/accounts/views.py:1591 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1597 warehouse/manage/views/__init__.py:1228 -#: warehouse/manage/views/__init__.py:1341 +#: warehouse/accounts/views.py:1607 warehouse/manage/views/__init__.py:1239 +#: warehouse/manage/views/__init__.py:1352 +#: warehouse/manage/views/__init__.py:1462 msgid "" "There have been too many attempted trusted publisher registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1608 warehouse/manage/views/__init__.py:1242 -#: warehouse/manage/views/__init__.py:1355 +#: warehouse/accounts/views.py:1618 warehouse/manage/views/__init__.py:1253 +#: warehouse/manage/views/__init__.py:1366 +#: warehouse/manage/views/__init__.py:1476 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:1622 +#: warehouse/accounts/views.py:1632 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:1649 +#: warehouse/accounts/views.py:1659 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:1735 warehouse/accounts/views.py:1748 -#: warehouse/accounts/views.py:1755 +#: warehouse/accounts/views.py:1771 warehouse/accounts/views.py:1784 +#: warehouse/accounts/views.py:1791 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:1761 +#: warehouse/accounts/views.py:1797 msgid "Removed trusted publisher for project " msgstr "" @@ -418,118 +420,124 @@ msgstr "" msgid "This team name has already been used. Choose a different team name." msgstr "" -#: warehouse/manage/views/__init__.py:194 +#: warehouse/manage/views/__init__.py:200 msgid "Account details updated" msgstr "" -#: warehouse/manage/views/__init__.py:223 +#: warehouse/manage/views/__init__.py:229 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views/__init__.py:774 +#: warehouse/manage/views/__init__.py:780 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views/__init__.py:775 +#: warehouse/manage/views/__init__.py:781 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views/__init__.py:884 +#: warehouse/manage/views/__init__.py:890 msgid "Verify your email to create an API token." msgstr "" -#: warehouse/manage/views/__init__.py:984 +#: warehouse/manage/views/__init__.py:990 msgid "API Token does not exist." msgstr "" -#: warehouse/manage/views/__init__.py:1016 +#: warehouse/manage/views/__init__.py:1022 msgid "Invalid credentials. Try again" msgstr "" -#: warehouse/manage/views/__init__.py:1209 +#: warehouse/manage/views/__init__.py:1220 msgid "" "GitHub-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1322 +#: warehouse/manage/views/__init__.py:1333 msgid "" "Google-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1556 -#: warehouse/manage/views/__init__.py:1857 -#: warehouse/manage/views/__init__.py:1965 +#: warehouse/manage/views/__init__.py:1442 +msgid "" +"ActiveState-based trusted publishing is temporarily disabled. See " +"https://pypi.org/help#admin-intervention for details." +msgstr "" + +#: warehouse/manage/views/__init__.py:1677 +#: warehouse/manage/views/__init__.py:1978 +#: warehouse/manage/views/__init__.py:2086 msgid "" "Project deletion temporarily disabled. See https://pypi.org/help#admin-" "intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1688 -#: warehouse/manage/views/__init__.py:1773 -#: warehouse/manage/views/__init__.py:1874 -#: warehouse/manage/views/__init__.py:1974 +#: warehouse/manage/views/__init__.py:1809 +#: warehouse/manage/views/__init__.py:1894 +#: warehouse/manage/views/__init__.py:1995 +#: warehouse/manage/views/__init__.py:2095 msgid "Confirm the request" msgstr "" -#: warehouse/manage/views/__init__.py:1700 +#: warehouse/manage/views/__init__.py:1821 msgid "Could not yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:1785 +#: warehouse/manage/views/__init__.py:1906 msgid "Could not un-yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:1886 +#: warehouse/manage/views/__init__.py:2007 msgid "Could not delete release - " msgstr "" -#: warehouse/manage/views/__init__.py:1986 +#: warehouse/manage/views/__init__.py:2107 msgid "Could not find file" msgstr "" -#: warehouse/manage/views/__init__.py:1990 +#: warehouse/manage/views/__init__.py:2111 msgid "Could not delete file - " msgstr "" -#: warehouse/manage/views/__init__.py:2140 +#: warehouse/manage/views/__init__.py:2261 msgid "Team '${team_name}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2247 +#: warehouse/manage/views/__init__.py:2368 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2314 +#: warehouse/manage/views/__init__.py:2435 msgid "${username} is now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/manage/views/__init__.py:2346 +#: warehouse/manage/views/__init__.py:2467 msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for project" msgstr "" -#: warehouse/manage/views/__init__.py:2359 +#: warehouse/manage/views/__init__.py:2480 #: warehouse/manage/views/organizations.py:878 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views/__init__.py:2424 +#: warehouse/manage/views/__init__.py:2545 #: warehouse/manage/views/organizations.py:943 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views/__init__.py:2457 +#: warehouse/manage/views/__init__.py:2578 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views/__init__.py:2468 +#: warehouse/manage/views/__init__.py:2589 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views/__init__.py:2500 +#: warehouse/manage/views/__init__.py:2621 #: warehouse/manage/views/organizations.py:1130 msgid "Invitation revoked from '${username}'." msgstr "" @@ -573,6 +581,45 @@ msgstr "" msgid "Publisher must be specified by ID" msgstr "" +#: warehouse/oidc/forms/activestate.py:48 +msgid "Double dashes are not allowed in the name" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:55 +msgid "Leading or trailing dashes are not allowed in the name" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:79 +#: warehouse/oidc/forms/activestate.py:92 +msgid "Unexpected error from ActiveState. Try again in a few minutes" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:87 +#: warehouse/oidc/forms/activestate.py:103 +#: warehouse/oidc/forms/activestate.py:112 +msgid "Unexpected error from ActiveState. Try again" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:122 +msgid "Specify ActiveState organization name" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:130 +msgid "Specify ActiveState project name" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:134 +msgid "Invalid ActiveState project name" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:157 +msgid "ActiveState organization not found" +msgstr "" + +#: warehouse/oidc/forms/activestate.py:177 +msgid "ActiveState actor not found" +msgstr "" + #: warehouse/oidc/forms/github.py:33 msgid "Specify GitHub repository owner (username or organization)" msgstr "" @@ -1264,6 +1311,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:159 #: warehouse/templates/manage/account/publishing.html:174 #: warehouse/templates/manage/account/publishing.html:189 +#: warehouse/templates/manage/account/publishing.html:224 +#: warehouse/templates/manage/account/publishing.html:246 +#: warehouse/templates/manage/account/publishing.html:268 +#: warehouse/templates/manage/account/publishing.html:290 #: warehouse/templates/manage/account/recovery_codes-burn.html:70 #: warehouse/templates/manage/account/token.html:133 #: warehouse/templates/manage/account/token.html:150 @@ -1289,6 +1340,9 @@ msgstr "" #: warehouse/templates/manage/project/publishing.html:95 #: warehouse/templates/manage/project/publishing.html:142 #: warehouse/templates/manage/project/publishing.html:157 +#: warehouse/templates/manage/project/publishing.html:192 +#: warehouse/templates/manage/project/publishing.html:214 +#: warehouse/templates/manage/project/publishing.html:236 #: warehouse/templates/manage/project/roles.html:273 #: warehouse/templates/manage/project/roles.html:289 #: warehouse/templates/manage/project/roles.html:305 @@ -2462,15 +2516,40 @@ msgstr "" msgid "Subject" msgstr "" -#: warehouse/templates/email/trusted-publisher-added/body.html:48 +#: warehouse/templates/email/trusted-publisher-added/body.html:44 +#: warehouse/templates/email/trusted-publisher-removed/body.html:42 +msgid "ActiveState Project URL" +msgstr "" + +#: warehouse/templates/email/trusted-publisher-added/body.html:45 +#: warehouse/templates/email/trusted-publisher-removed/body.html:43 +#: warehouse/templates/manage/account/publishing.html:244 +#: warehouse/templates/manage/project/publishing.html:190 +#: warehouse/templates/organizations/profile.html:30 +msgid "Organization" +msgstr "" + +#: warehouse/templates/email/trusted-publisher-added/body.html:46 +#: warehouse/templates/email/trusted-publisher-removed/body.html:44 +#: warehouse/templates/manage/account/publishing.html:266 +#: warehouse/templates/manage/project/publishing.html:212 +msgid "ActiveState Project name" +msgstr "" + +#: warehouse/templates/email/trusted-publisher-added/body.html:47 +#: warehouse/templates/email/trusted-publisher-removed/body.html:45 +msgid "Actor" +msgstr "" + +#: warehouse/templates/email/trusted-publisher-added/body.html:53 msgid "" "If you did not make this change and you think it was made maliciously, " "you can remove it from the project via the \"Publishing\" tab on the " "project's page." msgstr "" -#: warehouse/templates/email/trusted-publisher-added/body.html:55 -#: warehouse/templates/email/trusted-publisher-removed/body.html:53 +#: warehouse/templates/email/trusted-publisher-added/body.html:60 +#: warehouse/templates/email/trusted-publisher-removed/body.html:58 #, python-format msgid "" "If you are unable to revert the change and need to do so, you can email " @@ -2485,7 +2564,7 @@ msgid "" "from a project (%(project_name)s) that you manage." msgstr "" -#: warehouse/templates/email/trusted-publisher-removed/body.html:46 +#: warehouse/templates/email/trusted-publisher-removed/body.html:51 msgid "" "If you did not make this change and you think it was made in error, you " "can check the \"Security history\" tab on the project's page." @@ -3695,7 +3774,7 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:80 #: warehouse/templates/manage/manage_base.html:97 #: warehouse/templates/manage/manage_base.html:100 -#: warehouse/templates/manage/manage_base.html:562 +#: warehouse/templates/manage/manage_base.html:566 #: warehouse/templates/manage/organization/roles.html:202 #: warehouse/templates/manage/organization/roles.html:204 #: warehouse/templates/manage/organization/roles.html:209 @@ -3884,7 +3963,7 @@ msgstr "" msgid "Any" msgstr "" -#: warehouse/templates/manage/manage_base.html:569 +#: warehouse/templates/manage/manage_base.html:573 #: warehouse/templates/manage/organization/history.html:166 #: warehouse/templates/manage/project/history.html:43 #: warehouse/templates/manage/project/history.html:97 @@ -3895,7 +3974,7 @@ msgstr "" msgid "Added by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:571 +#: warehouse/templates/manage/manage_base.html:575 #: warehouse/templates/manage/organization/history.html:171 #: warehouse/templates/manage/project/history.html:62 #: warehouse/templates/manage/project/history.html:128 @@ -3906,24 +3985,24 @@ msgstr "" msgid "Removed by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:573 +#: warehouse/templates/manage/manage_base.html:577 msgid "Submitted by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:576 +#: warehouse/templates/manage/manage_base.html:580 #: warehouse/templates/manage/project/history.html:247 msgid "Workflow:" msgstr "" -#: warehouse/templates/manage/manage_base.html:578 +#: warehouse/templates/manage/manage_base.html:582 msgid "Specifier:" msgstr "" -#: warehouse/templates/manage/manage_base.html:581 +#: warehouse/templates/manage/manage_base.html:585 msgid "Publisher:" msgstr "" -#: warehouse/templates/manage/manage_base.html:583 +#: warehouse/templates/manage/manage_base.html:587 #: warehouse/templates/manage/project/history.html:52 #: warehouse/templates/manage/project/history.html:106 msgid "URL:" @@ -4199,16 +4278,19 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:38 #: warehouse/templates/manage/account/publishing.html:157 +#: warehouse/templates/manage/account/publishing.html:222 msgid "PyPI Project Name" msgstr "" #: warehouse/templates/manage/account/publishing.html:43 #: warehouse/templates/manage/account/publishing.html:162 +#: warehouse/templates/manage/account/publishing.html:228 msgid "project name" msgstr "" #: warehouse/templates/manage/account/publishing.html:45 #: warehouse/templates/manage/account/publishing.html:164 +#: warehouse/templates/manage/account/publishing.html:236 msgid "The project (on PyPI) that will be created when this publisher is used" msgstr "" @@ -4286,8 +4368,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:139 #: warehouse/templates/manage/account/publishing.html:210 +#: warehouse/templates/manage/account/publishing.html:307 #: warehouse/templates/manage/project/publishing.html:122 #: warehouse/templates/manage/project/publishing.html:178 +#: warehouse/templates/manage/project/publishing.html:253 #: warehouse/templates/manage/project/roles.html:341 #: warehouse/templates/manage/team/roles.html:131 msgid "Add" @@ -4324,49 +4408,86 @@ msgid "" "identity used for publishing. More details here." msgstr "" -#: warehouse/templates/manage/account/publishing.html:221 +#: warehouse/templates/manage/account/publishing.html:250 +#: warehouse/templates/manage/project/publishing.html:196 +msgid "my-organization" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:258 +#: warehouse/templates/manage/project/publishing.html:204 +msgid "The ActiveState organization name that owns the project" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:272 +#: warehouse/templates/manage/project/publishing.html:218 +msgid "my-project" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:280 +#: warehouse/templates/manage/project/publishing.html:226 +msgid "The ActiveState project that will build your Python artifact." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:288 +#: warehouse/templates/manage/project/publishing.html:234 +msgid "Actor Username" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:294 +#: warehouse/templates/manage/project/publishing.html:240 +msgid "my-username" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:300 +#: warehouse/templates/manage/project/publishing.html:246 +msgid "" +"The username for the ActiveState account that will trigger the build of " +"your Python artifact." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:318 msgid "Manage publishers" msgstr "" -#: warehouse/templates/manage/account/publishing.html:231 +#: warehouse/templates/manage/account/publishing.html:328 msgid "Project" msgstr "" -#: warehouse/templates/manage/account/publishing.html:253 +#: warehouse/templates/manage/account/publishing.html:350 msgid "" "No publishers are currently configured. Publishers for existing projects " "can be added in the publishing configuration for each individual project." msgstr "" -#: warehouse/templates/manage/account/publishing.html:265 +#: warehouse/templates/manage/account/publishing.html:362 msgid "Pending project name" msgstr "" -#: warehouse/templates/manage/account/publishing.html:266 -#: warehouse/templates/manage/project/publishing.html:205 +#: warehouse/templates/manage/account/publishing.html:363 +#: warehouse/templates/manage/project/publishing.html:280 msgid "Publisher" msgstr "" -#: warehouse/templates/manage/account/publishing.html:267 -#: warehouse/templates/manage/project/publishing.html:206 +#: warehouse/templates/manage/account/publishing.html:364 +#: warehouse/templates/manage/project/publishing.html:281 msgid "Details" msgstr "" -#: warehouse/templates/manage/account/publishing.html:279 +#: warehouse/templates/manage/account/publishing.html:376 msgid "" "No pending publishers are currently configured. Publishers for projects " "that don't exist yet can be added below." msgstr "" -#: warehouse/templates/manage/account/publishing.html:287 +#: warehouse/templates/manage/account/publishing.html:384 msgid "Add a new pending publisher" msgstr "" -#: warehouse/templates/manage/account/publishing.html:290 +#: warehouse/templates/manage/account/publishing.html:387 msgid "You can use this page to register \"pending\" trusted publishers." msgstr "" -#: warehouse/templates/manage/account/publishing.html:296 +#: warehouse/templates/manage/account/publishing.html:393 #, python-format msgid "" "These publishers behave similarly to trusted publishers registered " @@ -4377,8 +4498,8 @@ msgid "" "trusted publishers here." msgstr "" -#: warehouse/templates/manage/account/publishing.html:334 -#: warehouse/templates/manage/project/publishing.html:252 +#: warehouse/templates/manage/account/publishing.html:432 +#: warehouse/templates/manage/project/publishing.html:328 #, python-format msgid "" "You must first enable two-factor authentication " @@ -5607,20 +5728,20 @@ msgid "" "here." msgstr "" -#: warehouse/templates/manage/project/publishing.html:197 +#: warehouse/templates/manage/project/publishing.html:272 msgid "Manage current publishers" msgstr "" -#: warehouse/templates/manage/project/publishing.html:201 +#: warehouse/templates/manage/project/publishing.html:276 #, python-format msgid "OpenID Connect publishers associated with %(project_name)s" msgstr "" -#: warehouse/templates/manage/project/publishing.html:217 +#: warehouse/templates/manage/project/publishing.html:292 msgid "No publishers are currently configured." msgstr "" -#: warehouse/templates/manage/project/publishing.html:222 +#: warehouse/templates/manage/project/publishing.html:297 msgid "Add a new publisher" msgstr "" @@ -6383,10 +6504,6 @@ msgstr "" msgid "Profile of %(orgname)s" msgstr "" -#: warehouse/templates/organizations/profile.html:30 -msgid "Organization" -msgstr "" - #: warehouse/templates/organizations/profile.html:73 #, python-format msgid "%(org)s has not uploaded any projects to PyPI, yet" diff --git a/warehouse/manage/views/__init__.py b/warehouse/manage/views/__init__.py index ddb2b36f91ab..2c76afb438cc 100644 --- a/warehouse/manage/views/__init__.py +++ b/warehouse/manage/views/__init__.py @@ -100,12 +100,18 @@ ) from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.forms import ( + ActiveStatePublisherForm, DeletePublisherForm, GitHubPublisherForm, GooglePublisherForm, ) from warehouse.oidc.interfaces import TooManyOIDCRegistrations -from warehouse.oidc.models import GitHubPublisher, GooglePublisher, OIDCPublisher +from warehouse.oidc.models import ( + ActiveStatePublisher, + GitHubPublisher, + GooglePublisher, + OIDCPublisher, +) from warehouse.organizations.interfaces import IOrganizationService from warehouse.organizations.models import ( OrganizationProject, @@ -1139,6 +1145,7 @@ def __init__(self, project, request): api_token=self.request.registry.settings.get("github.token"), ) self.google_publisher_form = GooglePublisherForm(self.request.POST) + self.activestate_publisher_form = ActiveStatePublisherForm(self.request.POST) @property def _ratelimiters(self): @@ -1176,6 +1183,7 @@ def default_response(self): "project": self.project, "github_publisher_form": self.github_publisher_form, "google_publisher_form": self.google_publisher_form, + "activestate_publisher_form": self.activestate_publisher_form, "disabled": { "GitHub": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GITHUB_OIDC @@ -1183,6 +1191,9 @@ def default_response(self): "Google": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GOOGLE_OIDC ), + "ActiveState": self.request.flags.disallow_oidc( + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC + ), }, } @@ -1421,6 +1432,116 @@ def add_google_oidc_publisher(self): return HTTPSeeOther(self.request.path) + @view_config( + request_method="POST", + request_param=ActiveStatePublisherForm.__params__, + ) + def add_activestate_oidc_publisher(self): + if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC): + self.request.session.flash( + self.request._( + "ActiveState-based trusted publishing is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + self.metrics.increment( + "warehouse.oidc.add_publisher.attempt", tags=["publisher:ActiveState"] + ) + + try: + self._check_ratelimits() + except TooManyOIDCRegistrations as exc: + self.metrics.increment( + "warehouse.oidc.add_publisher.ratelimited", + tags=["publisher:ActiveState"], + ) + return HTTPTooManyRequests( + self.request._( + "There have been too many attempted trusted publisher " + "registrations. Try again later." + ), + retry_after=exc.resets_in.total_seconds(), + ) + + self._hit_ratelimits() + + response = self.default_response + form = response["activestate_publisher_form"] + + if not form.validate(): + self.request.session.flash( + self.request._("The trusted publisher could not be registered"), + queue="error", + ) + return response + + # Check for an already registered publisher before creating. + publisher = ( + self.request.db.query(ActiveStatePublisher) + .filter( + ActiveStatePublisher.organization == form.organization.data, + ActiveStatePublisher.activestate_project_name == form.project.data, + ActiveStatePublisher.actor_id == form.actor_id, + ) + .one_or_none() + ) + if publisher is None: + publisher = ActiveStatePublisher( + organization=form.organization.data, + activestate_project_name=form.project.data, + actor=form.actor.data, + actor_id=form.actor_id, + ) + + self.request.db.add(publisher) + + # Each project has a unique set of OIDC publishers; the same + # publisher can't be registered to the project more than once. + if publisher in self.project.oidc_publishers: + self.request.session.flash( + self.request._( + f"{publisher} is already registered with {self.project.name}" + ), + queue="error", + ) + return response + + for user in self.project.users: + send_trusted_publisher_added_email( + self.request, + user, + project_name=self.project.name, + publisher=publisher, + ) + + self.project.oidc_publishers.append(publisher) + + self.project.record_event( + tag=EventTag.Project.OIDCPublisherAdded, + request=self.request, + additional={ + "publisher": publisher.publisher_name, + "id": str(publisher.id), + "specifier": str(publisher), + "url": publisher.publisher_url(), + "submitted_by": self.request.user.username, + }, + ) + + self.request.session.flash( + f"Added {publisher} in {publisher.publisher_url()} to {self.project.name}", + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.add_publisher.ok", tags=["publisher:ActiveState"] + ) + + return HTTPSeeOther(self.request.path) + @view_config( request_method="POST", request_param=DeletePublisherForm.__params__, diff --git a/warehouse/oidc/forms/__init__.py b/warehouse/oidc/forms/__init__.py index c5e16ce4f4e1..c37d8e851b1d 100644 --- a/warehouse/oidc/forms/__init__.py +++ b/warehouse/oidc/forms/__init__.py @@ -11,6 +11,10 @@ # limitations under the License. from warehouse.oidc.forms._core import DeletePublisherForm +from warehouse.oidc.forms.activestate import ( + ActiveStatePublisherForm, + PendingActiveStatePublisherForm, +) from warehouse.oidc.forms.github import GitHubPublisherForm, PendingGitHubPublisherForm from warehouse.oidc.forms.google import GooglePublisherForm, PendingGooglePublisherForm @@ -20,4 +24,6 @@ "PendingGitHubPublisherForm", "GooglePublisherForm", "PendingGooglePublisherForm", + "ActiveStatePublisherForm", + "PendingActiveStatePublisherForm", ] diff --git a/warehouse/oidc/forms/activestate.py b/warehouse/oidc/forms/activestate.py new file mode 100644 index 000000000000..eefa3eda2f70 --- /dev/null +++ b/warehouse/oidc/forms/activestate.py @@ -0,0 +1,201 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re + +from collections.abc import Callable +from typing import Any, TypedDict + +import requests +import sentry_sdk +import wtforms + +from warehouse import forms +from warehouse.i18n import localize as _ +from warehouse.oidc.forms._core import PendingPublisherMixin + +_VALID_PROJECT_NAME = re.compile(r"^[.a-zA-Z0-9-]{3,40}$") +_DOUBLE_DASHES = re.compile(r"--+") + +_ACTIVESTATE_GRAPHQL_API_URL = "https://platform.activestate.com/graphql/v1/graphql" +_GRAPHQL_GET_ORGANIZATION = "query($orgname: String) {organizations(where: {display_name: {_eq: $orgname}}) {added}}" # noqa: E501 +_GRAPHQL_GET_ACTOR = ( + "query($username: String) {users(where: {username: {_eq: $username}}) {user_id}}" +) + + +class UserResponse(TypedDict): + user_id: str + + +class GqlResponse(TypedDict): + data: dict[str, Any] + errors: list[dict[str, Any]] + + +def _no_double_dashes(form, field): + if _DOUBLE_DASHES.search(field.data): + raise wtforms.validators.ValidationError( + _("Double dashes are not allowed in the name") + ) + + +def _no_leading_or_trailing_dashes(form, field): + if field.data.startswith("-") or field.data.endswith("-"): + raise wtforms.validators.ValidationError( + _("Leading or trailing dashes are not allowed in the name") + ) + + +def _activestate_gql_api_call( + query: str, + variables: dict[str, str], + response_handler: Callable[[GqlResponse], Any], +) -> Any: + try: + response = requests.post( + _ACTIVESTATE_GRAPHQL_API_URL, + json={ + "query": query, + "variables": variables, + }, + timeout=5, + ) + if response.status_code == 404: + sentry_sdk.capture_message( + f"Unexpected {response.status_code } error " + f"from ActiveState API: {response.content!r}" + ) + raise wtforms.validators.ValidationError( + _("Unexpected error from ActiveState. Try again in a few minutes") + ) + elif response.status_code >= 400: + sentry_sdk.capture_message( + f"Unexpected {response.status_code } error " + f"from ActiveState API: {response.content!r}" + ) + raise wtforms.validators.ValidationError( + _("Unexpected error from ActiveState. Try again") + ) + except (requests.Timeout, requests.ConnectionError): + sentry_sdk.capture_message("Connection error from ActiveState API") + raise wtforms.validators.ValidationError( + _("Unexpected error from ActiveState. Try again in a few minutes") + ) + # Graphql reports it's errors within the body of the 200 response + try: + response_json = response.json() + errors = response_json.get("errors") + if errors: + sentry_sdk.capture_message( + f"Unexpected error from ActiveState API: {errors}" + ) + raise wtforms.validators.ValidationError( + _("Unexpected error from ActiveState. Try again") + ) + + return response_handler(response_json) + except requests.exceptions.JSONDecodeError: + sentry_sdk.capture_message( + f"Unexpected error from ActiveState API: {response.content!r}" + ) + raise wtforms.validators.ValidationError( + _("Unexpected error from ActiveState. Try again") + ) + + +class ActiveStatePublisherBase(forms.Form): + __params__ = ["organization", "project", "actor"] + + organization = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired( + message=_("Specify ActiveState organization name"), + ), + ] + ) + + project = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired( + message=_("Specify ActiveState project name") + ), + wtforms.validators.Regexp( + _VALID_PROJECT_NAME, + message=_("Invalid ActiveState project name"), + ), + _no_double_dashes, + _no_leading_or_trailing_dashes, + ] + ) + + actor = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired( + message=("Specify the ActiveState actor username") + ), + ] + ) + + def _lookup_organization(self, org_url_name: str) -> None: + """Make gql API call to the ActiveState API to check if the organization + exists""" + + def process_org_response(response: GqlResponse) -> None: + data = response.get("data") + if data and not data.get("organizations"): + raise wtforms.validators.ValidationError( + _("ActiveState organization not found") + ) + + return _activestate_gql_api_call( + _GRAPHQL_GET_ORGANIZATION, {"orgname": org_url_name}, process_org_response + ) + + def validate_organization(self, field): + self._lookup_organization(field.data) + + def _lookup_actor(self, actor: str) -> UserResponse: + """Make gql API call to the ActiveState API to check if the actor/username + exists and return the associated user id""" + + def process_actor_response(response: GqlResponse) -> UserResponse: + users = response.get("data", {}).get("users", []) + if users: + return users[0] + else: + raise wtforms.validators.ValidationError( + _("ActiveState actor not found") + ) + + return _activestate_gql_api_call( + _GRAPHQL_GET_ACTOR, {"username": actor}, process_actor_response + ) + + def validate_actor(self, field): + actor = field.data + + actor_info = self._lookup_actor(actor) + + self.actor_id = actor_info["user_id"] + + +class PendingActiveStatePublisherForm(ActiveStatePublisherBase, PendingPublisherMixin): + __params__ = ActiveStatePublisherBase.__params__ + ["project_name"] + + def __init__(self, *args, project_factory, **kwargs): + super().__init__(*args, **kwargs) + self._project_factory = project_factory + + +class ActiveStatePublisherForm(ActiveStatePublisherBase): + pass diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index db69788a228b..857109bd9899 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -156,7 +156,7 @@ def mint_token_from_oidc(request: Request): errors=[ { "code": "not-enabled", - "description": f"{service_name} trusted publishing functionality not enabled", # noqa + "description": f"{service_name} trusted publishing functionality not enabled", # noqa: E501 } ], request=request, diff --git a/warehouse/templates/email/trusted-publisher-added/body.html b/warehouse/templates/email/trusted-publisher-added/body.html index 66fdaf7cf2f0..899db4c41d91 100644 --- a/warehouse/templates/email/trusted-publisher-added/body.html +++ b/warehouse/templates/email/trusted-publisher-added/body.html @@ -29,17 +29,22 @@