← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-store-exports into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-store-exports into lp:launchpad.

Commit message:
Provide parts of Snap:+authorize on the API as Snap.beginAuthorization.  Export various store-related properties on Snap, and store_* arguments to SnapSet.new.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-store-exports/+merge/312854

build.snapcraft.io needs this in order to be able to create store-uploaded snaps using the API.  The ability to control the URL that the authorization workflow redirects to on success lets us steer the user back to build.snapcraft.io when they're finished.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-store-exports into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2016-12-02 12:53:41 +0000
+++ lib/lp/snappy/browser/snap.py	2016-12-08 18:08:19 +0000
@@ -16,15 +16,11 @@
     'SnapView',
     ]
 
-from urllib import urlencode
-from urlparse import urlsplit
-
 from lazr.restful.fields import Reference
 from lazr.restful.interface import (
     copy_field,
     use_template,
     )
-from pymacaroons import Macaroon
 import yaml
 from zope.component import getUtility
 from zope.error.interfaces import IErrorReportingUtility
@@ -60,8 +56,8 @@
 from lp.registry.enums import VCSType
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.features import getFeatureFlag
+from lp.services.fields import URIField
 from lp.services.helpers import english_list
-from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
 from lp.services.propertycache import cachedproperty
 from lp.services.scripts import log
 from lp.services.webapp import (
@@ -74,7 +70,6 @@
     NavigationMenu,
     stepthrough,
     structured,
-    urlappend,
     )
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.breadcrumb import (
@@ -84,6 +79,7 @@
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
 from lp.snappy.interfaces.snap import (
+    CannotAuthorizeStoreUploads,
     ISnap,
     ISnapSet,
     NoSuchSnap,
@@ -98,7 +94,6 @@
     )
 from lp.snappy.interfaces.snapstoreclient import (
     BadRequestPackageUploadResponse,
-    ISnapStoreClient,
     )
 from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.build import get_build_by_id_str
@@ -782,6 +777,8 @@
 
         discharge_macaroon = TextLine(
             title=u'Serialized discharge macaroon', required=True)
+        success_url = URIField(
+            title=u'URL to redirect to on success', allowed_schemes=['https'])
 
     render_context = False
 
@@ -791,55 +788,15 @@
     def cancel_url(self):
         return canonical_url(self.context)
 
-    @staticmethod
-    def extractSSOCaveat(macaroon):
-        locations = [
-            urlsplit(root).netloc
-            for root in CurrentOpenIDEndPoint.getAllRootURLs()]
-        sso_caveats = [
-            c for c in macaroon.third_party_caveats()
-            if c.location in locations]
-        # We must have exactly one SSO caveat; more than one should never be
-        # required and could be an attempt to substitute weaker caveats.  We
-        # might as well OOPS here, even though the cause of this is probably
-        # in some other service, since the user can't do anything about it
-        # and it should show up in our OOPS reports.
-        if not sso_caveats:
-            raise SnapAuthorizationException("Macaroon has no SSO caveats")
-        elif len(sso_caveats) > 1:
-            raise SnapAuthorizationException(
-                "Macaroon has multiple SSO caveats")
-        return sso_caveats[0]
-
     @classmethod
     def requestAuthorization(cls, snap, request):
         """Begin the process of authorizing uploads of a snap package."""
-        if snap.store_series is None:
-            request.response.addInfoNotification(
-                _(u'Cannot authorize uploads of a snap package with no '
-                  u'store series.'))
-            request.response.redirect(canonical_url(snap))
-            return
-        if snap.store_name is None:
-            request.response.addInfoNotification(
-                _(u'Cannot authorize uploads of a snap package with no '
-                  u'store name.'))
-            request.response.redirect(canonical_url(snap))
-            return
-        snap_store_client = getUtility(ISnapStoreClient)
-        root_macaroon_raw = snap_store_client.requestPackageUploadPermission(
-            snap.store_series, snap.store_name)
-        sso_caveat = cls.extractSSOCaveat(
-            Macaroon.deserialize(root_macaroon_raw))
-        snap.store_secrets = {'root': root_macaroon_raw}
-        base_url = canonical_url(snap, view_name='+authorize')
-        login_url = urlappend(base_url, '+login')
-        login_url += '?%s' % urlencode([
-            ('macaroon_caveat_id', sso_caveat.caveat_id),
-            ('discharge_macaroon_action', 'field.actions.complete'),
-            ('discharge_macaroon_field', 'field.discharge_macaroon'),
-            ])
-        return login_url
+        try:
+            return snap.beginAuthorization()
+        except CannotAuthorizeStoreUploads as e:
+            request.response.addInfoNotification(unicode(e))
+            request.response.redirect(canonical_url(snap))
+            return
 
     @action('Begin authorization', name='begin')
     def begin_action(self, action, data):
@@ -854,6 +811,11 @@
                 _(u'Uploads of %(snap)s to the store were not authorized.'),
                 snap=self.context.name))
             return
+        success_url = data.get('success_url')
+        # XXX cjwatson 2016-12-08: Drop this once all appservers have been
+        # upgraded to always pass success_url.
+        if success_url is None:
+            success_url = canonical_url(self.context)
         # We have to set a whole new dict here to avoid problems with
         # security proxies.
         new_store_secrets = dict(self.context.store_secrets)
@@ -862,7 +824,7 @@
         self.request.response.addInfoNotification(structured(
             _(u'Uploads of %(snap)s to the store are now authorized.'),
             snap=self.context.name))
-        self.request.response.redirect(canonical_url(self.context))
+        self.request.response.redirect(success_url)
 
     @property
     def adapters(self):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2016-12-02 12:53:41 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2016-12-08 18:08:19 +0000
@@ -11,6 +11,7 @@
     )
 import json
 import re
+from urllib import quote_plus
 from urllib2 import HTTPError
 from urlparse import (
     parse_qs,
@@ -52,7 +53,6 @@
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.snappy.browser.snap import (
     SnapAdminView,
-    SnapAuthorizeView,
     SnapEditView,
     SnapView,
     )
@@ -433,6 +433,7 @@
             "discharge_macaroon_action": ["field.actions.complete"],
             "discharge_macaroon_field": ["field.discharge_macaroon"],
             "macaroon_caveat_id": ["dummy"],
+            "field.success_url": [canonical_url(snap, rootsite="code")],
             }
         self.assertEqual(expected_args, parse_qs(parsed_location[3]))
 
@@ -973,6 +974,7 @@
             "discharge_macaroon_action": ["field.actions.complete"],
             "discharge_macaroon_field": ["field.discharge_macaroon"],
             "macaroon_caveat_id": ["dummy"],
+            "field.success_url": [canonical_url(snap)],
             }
         self.assertEqual(expected_args, parse_qs(parsed_location[3]))
 
@@ -996,54 +998,6 @@
             store_series=self.snappyseries,
             store_name=self.factory.getUniqueUnicode())
 
-    def assertRequestsAuthorization(self, snap, func, *args, **kwargs):
-        owner = snap.owner
-        root_macaroon = Macaroon()
-        root_macaroon.add_third_party_caveat(
-            urlsplit(config.launchpad.openid_provider_root).netloc, '',
-            'dummy')
-        root_macaroon_raw = root_macaroon.serialize()
-
-        @all_requests
-        def handler(url, request):
-            self.request = request
-            return {
-                "status_code": 200,
-                "content": {"macaroon": root_macaroon_raw},
-                }
-
-        self.pushConfig("snappy", store_url="http://sca.example/";)
-        with HTTMock(handler):
-            ret = func(*args, **kwargs)
-        self.assertThat(self.request, MatchesStructure.byEquality(
-            url="http://sca.example/dev/api/acl/";, method="POST"))
-        with person_logged_in(owner):
-            expected_body = {
-                "packages": [{
-                    "name": snap.store_name,
-                    "series": snap.store_series.name,
-                    }],
-                "permissions": ["package_upload"],
-                }
-            self.assertEqual(expected_body, json.loads(self.request.body))
-            self.assertEqual({"root": root_macaroon_raw}, snap.store_secrets)
-        return ret
-
-    def test_requestAuthorization(self):
-        def request_authorization():
-            with person_logged_in(self.snap.owner):
-                return SnapAuthorizeView.requestAuthorization(
-                    self.snap, LaunchpadTestRequest())
-
-        login_url = self.assertRequestsAuthorization(
-            self.snap, request_authorization)
-        self.assertEqual(
-            canonical_url(self.snap) +
-            "/+authorize/+login?macaroon_caveat_id=dummy&"
-            "discharge_macaroon_action=field.actions.complete&"
-            "discharge_macaroon_field=field.discharge_macaroon",
-            login_url)
-
     def test_unauthorized(self):
         # A user without edit access cannot authorize snap package uploads.
         self.useFixture(FakeLogger())
@@ -1057,20 +1011,46 @@
         # to begin authorization.  This allows (re-)authorizing uploads of
         # an existing snap package without having to edit it.
         snap_url = canonical_url(self.snap)
-
-        def begin_authorization():
+        owner = self.snap.owner
+        root_macaroon = Macaroon()
+        root_macaroon.add_third_party_caveat(
+            urlsplit(config.launchpad.openid_provider_root).netloc, '',
+            'dummy')
+        root_macaroon_raw = root_macaroon.serialize()
+
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {
+                "status_code": 200,
+                "content": {"macaroon": root_macaroon_raw},
+                }
+
+        self.pushConfig("snappy", store_url="http://sca.example/";)
+        with HTTMock(handler):
             browser = self.getNonRedirectingBrowser(
                 url=snap_url + "/+authorize", user=self.snap.owner)
-            return self.assertRaises(
+            redirection = self.assertRaises(
                 HTTPError, browser.getControl("Begin authorization").click)
-
-        redirection = self.assertRequestsAuthorization(
-            self.snap, begin_authorization)
+        self.assertThat(self.request, MatchesStructure.byEquality(
+            url="http://sca.example/dev/api/acl/";, method="POST"))
+        with person_logged_in(owner):
+            expected_body = {
+                "packages": [{
+                    "name": self.snap.store_name,
+                    "series": self.snap.store_series.name,
+                    }],
+                "permissions": ["package_upload"],
+                }
+            self.assertEqual(expected_body, json.loads(self.request.body))
+            self.assertEqual(
+                {"root": root_macaroon_raw}, self.snap.store_secrets)
         self.assertEqual(303, redirection.code)
         self.assertEqual(
             snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
             "discharge_macaroon_action=field.actions.complete&"
-            "discharge_macaroon_field=field.discharge_macaroon",
+            "discharge_macaroon_field=field.discharge_macaroon&"
+            "field.success_url=" + quote_plus(snap_url),
             redirection.hdrs["Location"])
 
     def test_complete_authorization_missing_discharge_macaroon(self):
@@ -1116,6 +1096,32 @@
                 {"root": "root", "discharge": "discharge"},
                 self.snap.store_secrets)
 
+    def test_complete_authorization_with_success_url(self):
+        # The "complete" action honours the provided success_url.
+        with person_logged_in(self.snap.owner):
+            self.snap.store_secrets = {"root": "root"}
+            transaction.commit()
+            form = {
+                "field.actions.complete": "1",
+                "field.discharge_macaroon": "discharge",
+                "field.success_url": "https://example.org/";,
+                }
+            view = create_initialized_view(
+                self.snap, "+authorize", form=form, method="POST",
+                principal=self.snap.owner)
+            self.assertEqual("", view())
+            self.assertEqual(302, view.request.response.getStatus())
+            self.assertEqual(
+                "https://example.org/";,
+                view.request.response.getHeader("Location"))
+            self.assertEqual(
+                "Uploads of %s to the store are now authorized." %
+                self.snap.name,
+                view.request.response.notifications[0].message)
+            self.assertEqual(
+                {"root": "root", "discharge": "discharge"},
+                self.snap.store_secrets)
+
 
 class TestSnapDeleteView(BrowserTestCase):
 

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-12-05 18:57:11 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-12-08 18:08:19 +0000
@@ -7,6 +7,7 @@
 
 __all__ = [
     'BadSnapSearchContext',
+    'CannotAuthorizeStoreUploads',
     'CannotModifySnapProcessor',
     'CannotRequestAutoBuilds',
     'DuplicateSnapName',
@@ -19,6 +20,7 @@
     'SNAP_PRIVATE_FEATURE_FLAG',
     'SNAP_TESTING_FLAGS',
     'SNAP_WEBHOOKS_FEATURE_FLAG',
+    'SnapAuthorizationBadMacaroon',
     'SnapBuildAlreadyPending',
     'SnapBuildArchiveOwnerMismatch',
     'SnapBuildDisallowedArchitecture',
@@ -208,6 +210,16 @@
 
 
 @error_status(httplib.BAD_REQUEST)
+class CannotAuthorizeStoreUploads(Exception):
+    """Cannot authorize uploads of a snap package to the store."""
+
+
+@error_status(httplib.INTERNAL_SERVER_ERROR)
+class SnapAuthorizationBadMacaroon(Exception):
+    """The macaroon generated to authorize store uploads is unusable."""
+
+
+@error_status(httplib.BAD_REQUEST)
 class CannotRequestAutoBuilds(Exception):
     """Snap package is not configured for automatic builds."""
 
@@ -252,9 +264,11 @@
         :return: Sequence of `IDistroArchSeries` instances.
         """
 
-    can_upload_to_store = Attribute(
-        "Whether everything is set up to allow uploading builds of this snap "
-        "package to the store.")
+    can_upload_to_store = exported(Bool(
+        title=_("Can upload to store"), required=True, readonly=True,
+        description=_(
+            "Whether everything is set up to allow uploading builds of this "
+            "snap package to the store.")))
 
     @call_with(requester=REQUEST_USER)
     @operation_parameters(
@@ -333,6 +347,31 @@
         :return: A sequence of `ISnapBuild` instances.
         """
 
+    @operation_parameters(
+        success_url=URIField(
+            title=_("URL to redirect to on success"),
+            allowed_schemes=["https"]))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def beginAuthorization(success_url=None):
+        """Begin authorizing uploads of this snap package to the store.
+
+        This is intended for use by third-party sites integrating with
+        Launchpad.  Most users should visit <snap URL>/+authorize instead.
+
+        :param success_url: The URL to redirect to when authorization is
+            complete.  Defaults to the canonical URL of the snap.
+        :raises CannotAuthorizeStoreUploads: if the snap package is not
+            properly configured for store uploads.
+        :raises BadRequestPackageUploadResponse: if the store returns an
+            error or a response without a macaroon when asked to issue a
+            package_upload macaroon.
+        :raises SnapAuthorizationBadMacaroon: if the package_upload macaroon
+            returned by the store has unsuitable SSO caveats.
+        :return: A URL that the third-party site should redirect the user to
+            in order to continue authorization.
+        """
+
     @export_destructor_operation()
     @operation_for_version("devel")
     def destroySelf():
@@ -430,31 +469,36 @@
         title=_("Snap package is stale and is due to be rebuilt."),
         required=True, readonly=False)
 
-    store_upload = Bool(
+    store_upload = exported(Bool(
         title=_("Automatically upload to store"),
         required=True, readonly=False,
         description=_(
             "Whether builds of this snap package are automatically uploaded "
-            "to the store."))
+            "to the store.")))
 
-    store_series = ReferenceChoice(
+    # XXX cjwatson 2016-12-08: We should limit this to series that are
+    # compatible with distro_series, but that entails validating the case
+    # where both are changed in a single PATCH request in such a way that
+    # neither is compatible with the old value of the other.  As far as I
+    # can tell lazr.restful only supports per-field validation.
+    store_series = exported(ReferenceChoice(
         title=_("Store series"),
         schema=ISnappySeries, vocabulary="SnappySeries",
         required=False, readonly=False,
         description=_(
             "The series in which this snap package should be published in the "
-            "store."))
+            "store.")))
 
     store_distro_series = ReferenceChoice(
         title=_("Store and distro series"),
         schema=ISnappyDistroSeries, vocabulary="SnappyDistroSeries",
         required=False, readonly=False)
 
-    store_name = TextLine(
+    store_name = exported(TextLine(
         title=_("Registered store package name"),
         required=False, readonly=False,
         description=_(
-            "The registered name of this snap package in the store."))
+            "The registered name of this snap package in the store.")))
 
     store_secrets = List(
         value_type=TextLine(), title=_("Store upload tokens"),
@@ -518,7 +562,7 @@
             "owner", "distro_series", "name", "description", "branch",
             "git_repository", "git_repository_url", "git_path", "git_ref",
             "auto_build", "auto_build_archive", "auto_build_pocket",
-            "private"])
+            "private", "store_upload", "store_series", "store_name"])
     @operation_for_version("devel")
     def new(registrant, owner, distro_series, name, description=None,
             branch=None, git_repository=None, git_repository_url=None,

=== modified file 'lib/lp/snappy/interfaces/snapstoreclient.py'
--- lib/lp/snappy/interfaces/snapstoreclient.py	2016-06-30 16:05:11 +0000
+++ lib/lp/snappy/interfaces/snapstoreclient.py	2016-12-08 18:08:19 +0000
@@ -21,9 +21,13 @@
     'UploadNotScannedYetResponse',
     ]
 
+import httplib
+
+from lazr.restful.declarations import error_status
 from zope.interface import Interface
 
 
+@error_status(httplib.INTERNAL_SERVER_ERROR)
 class BadRequestPackageUploadResponse(Exception):
     pass
 

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-12-06 00:02:46 +0000
+++ lib/lp/snappy/model/snap.py	2016-12-08 18:08:19 +0000
@@ -10,7 +10,10 @@
     datetime,
     timedelta,
     )
+from urllib import urlencode
+from urlparse import urlsplit
 
+from pymacaroons import Macaroon
 import pytz
 from storm.expr import (
     And,
@@ -97,11 +100,15 @@
     LibraryFileAlias,
     LibraryFileContent,
     )
+from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
 from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webapp.url import urlappend
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
+    CannotAuthorizeStoreUploads,
     CannotModifySnapProcessor,
     CannotRequestAutoBuilds,
     DuplicateSnapName,
@@ -110,6 +117,7 @@
     NoSourceForSnap,
     NoSuchSnap,
     SNAP_PRIVATE_FEATURE_FLAG,
+    SnapAuthorizationBadMacaroon,
     SnapBuildAlreadyPending,
     SnapBuildArchiveOwnerMismatch,
     SnapBuildDisallowedArchitecture,
@@ -119,6 +127,7 @@
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.snappy.model.snapbuild import SnapBuild
 from lp.soyuz.interfaces.archive import ArchiveDisabled
 from lp.soyuz.interfaces.buildrecords import IncompatibleArguments
@@ -340,6 +349,54 @@
         self.distro_series = value.distro_series
         self.store_series = value.snappy_series
 
+    @staticmethod
+    def extractSSOCaveat(macaroon):
+        locations = [
+            urlsplit(root).netloc
+            for root in CurrentOpenIDEndPoint.getAllRootURLs()]
+        sso_caveats = [
+            c for c in macaroon.third_party_caveats()
+            if c.location in locations]
+        # We must have exactly one SSO caveat; more than one should never be
+        # required and could be an attempt to substitute weaker caveats.  We
+        # might as well OOPS here, even though the cause of this is probably
+        # in some other service, since the user can't do anything about it
+        # and it should show up in our OOPS reports.
+        if not sso_caveats:
+            raise SnapAuthorizationBadMacaroon("Macaroon has no SSO caveats")
+        elif len(sso_caveats) > 1:
+            raise SnapAuthorizationBadMacaroon(
+                "Macaroon has multiple SSO caveats")
+        return sso_caveats[0]
+
+    def beginAuthorization(self, success_url=None):
+        """See `ISnap`."""
+        if self.store_series is None:
+            raise CannotAuthorizeStoreUploads(
+                "Cannot authorize uploads of a snap package with no store "
+                "series.")
+        if self.store_name is None:
+            raise CannotAuthorizeStoreUploads(
+                "Cannot authorize uploads of a snap package with no store "
+                "name.")
+        if success_url is None:
+            success_url = canonical_url(self)
+        snap_store_client = getUtility(ISnapStoreClient)
+        root_macaroon_raw = snap_store_client.requestPackageUploadPermission(
+            self.store_series, self.store_name)
+        sso_caveat = self.extractSSOCaveat(
+            Macaroon.deserialize(root_macaroon_raw))
+        self.store_secrets = {'root': root_macaroon_raw}
+        base_url = canonical_url(self, view_name='+authorize')
+        login_url = urlappend(base_url, '+login')
+        login_url += '?%s' % urlencode([
+            ('macaroon_caveat_id', sso_caveat.caveat_id),
+            ('discharge_macaroon_action', 'field.actions.complete'),
+            ('discharge_macaroon_field', 'field.discharge_macaroon'),
+            ('field.success_url', success_url),
+            ])
+        return login_url
+
     @property
     def can_upload_to_store(self):
         return (

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2016-12-02 12:53:41 +0000
+++ lib/lp/snappy/tests/test_snap.py	2016-12-08 18:08:19 +0000
@@ -9,8 +9,16 @@
     datetime,
     timedelta,
     )
+import json
+from urllib import quote_plus
+from urlparse import urlsplit
 
+from httmock import (
+    all_requests,
+    HTTMock,
+    )
 from lazr.lifecycle.event import ObjectModifiedEvent
+from pymacaroons import Macaroon
 import pytz
 from storm.exceptions import LostObjectError
 from storm.locals import Store
@@ -37,6 +45,7 @@
 from lp.registry.enums import PersonVisibility
 from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.config import config
 from lp.services.database.constants import (
     ONE_DAY_AGO,
     UTC_NOW,
@@ -46,6 +55,7 @@
 from lp.services.log.logger import BufferLogger
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.publisher import canonical_url
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     CannotModifySnapProcessor,
@@ -1106,7 +1116,7 @@
 
 class TestSnapWebservice(TestCaseWithFactory):
 
-    layer = DatabaseFunctionalLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         super(TestSnapWebservice, self).setUp()
@@ -1397,6 +1407,90 @@
         response = self.setProcessors(self.person, snap, ["386"])
         self.assertEqual(403, response.status)
 
+    def assertBeginsAuthorization(self, snap, **kwargs):
+        snap_url = api_url(snap)
+        root_macaroon = Macaroon()
+        root_macaroon.add_third_party_caveat(
+            urlsplit(config.launchpad.openid_provider_root).netloc, '',
+            'dummy')
+        root_macaroon_raw = root_macaroon.serialize()
+
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {
+                "status_code": 200,
+                "content": {"macaroon": root_macaroon_raw},
+                }
+
+        self.pushConfig("snappy", store_url="http://sca.example/";)
+        logout()
+        with HTTMock(handler):
+            response = self.webservice.named_post(
+                snap_url, "beginAuthorization", **kwargs)
+        self.assertThat(self.request, MatchesStructure.byEquality(
+            url="http://sca.example/dev/api/acl/";, method="POST"))
+        with person_logged_in(self.person):
+            expected_body = {
+                "packages": [{
+                    "name": snap.store_name,
+                    "series": snap.store_series.name,
+                    }],
+                "permissions": ["package_upload"],
+                }
+            self.assertEqual(expected_body, json.loads(self.request.body))
+            self.assertEqual({"root": root_macaroon_raw}, snap.store_secrets)
+        return response
+
+    def test_beginAuthorization(self):
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode())
+        snap_url = canonical_url(snap)
+        response = self.assertBeginsAuthorization(snap)
+        self.assertEqual(
+            snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
+            "discharge_macaroon_action=field.actions.complete&"
+            "discharge_macaroon_field=field.discharge_macaroon&"
+            "field.success_url=" + quote_plus(snap_url),
+            response.jsonBody())
+
+    def test_beginAuthorization_with_success_url(self):
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode())
+        snap_url = canonical_url(snap)
+        response = self.assertBeginsAuthorization(
+            snap, success_url="https://example.org/";)
+        self.assertEqual(
+            snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
+            "discharge_macaroon_action=field.actions.complete&"
+            "discharge_macaroon_field=field.discharge_macaroon&"
+            "field.success_url=https%3A%2F%2Fexample.org%2F",
+            response.jsonBody())
+
+    def test_beginAuthorization_unauthorized(self):
+        # A user without edit access cannot authorize snap package uploads.
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode())
+        snap_url = api_url(snap)
+        other_person = self.factory.makePerson()
+        other_webservice = webservice_for_person(
+            other_person, permission=OAuthPermission.WRITE_PUBLIC)
+        other_webservice.default_api_version = "devel"
+        response = other_webservice.named_post(snap_url, "beginAuthorization")
+        self.assertEqual(401, response.status)
+
     def makeBuildableDistroArchSeries(self, **kwargs):
         das = self.factory.makeDistroArchSeries(**kwargs)
         fake_chroot = self.factory.makeLibraryFileAlias(


Follow ups