← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-processors-for-everyone into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-processors-for-everyone into lp:launchpad.

Commit message:
Allow snap package owners to enable/disable unrestricted processors.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-processors-for-everyone/+merge/272507

Allow snap package owners to enable/disable unrestricted processors.  This is fairly similar to https://code.launchpad.net/~cjwatson/launchpad/processors-for-everyone/+merge/272093, but I felt that a slightly different choice of available processors made sense given that we know the distroseries.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-processors-for-everyone into lp:launchpad.
=== modified file 'lib/lp/code/browser/widgets/gitref.py'
--- lib/lp/code/browser/widgets/gitref.py	2015-09-10 17:20:55 +0000
+++ lib/lp/code/browser/widgets/gitref.py	2015-09-26 00:51:11 +0000
@@ -85,9 +85,13 @@
         try:
             repository = self.repository_widget.getInputValue()
         except MissingInputError:
-            raise WidgetInputError(
-                self.name, self.label,
-                LaunchpadValidationError("Please choose a Git repository."))
+            if self.context.required:
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "Please choose a Git repository."))
+            else:
+                return None
         except ConversionError:
             entered_name = self.request.form_ng.getOne(
                 "%s.repository" % self.name)
@@ -101,9 +105,13 @@
         else:
             path = None
         if not path:
-            raise WidgetInputError(
-                self.name, self.label,
-                LaunchpadValidationError("Please enter a Git branch path."))
+            if self.context.required:
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "Please enter a Git branch path."))
+            else:
+                return
         ref = repository.getRefByPath(path)
         if ref is None:
             raise WidgetInputError(

=== modified file 'lib/lp/code/browser/widgets/tests/test_gitrefwidget.py'
--- lib/lp/code/browser/widgets/tests/test_gitrefwidget.py	2015-09-09 14:17:46 +0000
+++ lib/lp/code/browser/widgets/tests/test_gitrefwidget.py	2015-09-26 00:51:11 +0000
@@ -166,6 +166,16 @@
             "The repository at %s does not contain a branch named "
             "'non-existent'." % ref.repository.display_name)
 
+    def test_getInputValue_empty_not_required(self):
+        # If the field is not required, empty input fields are allowed.
+        self.widget.context.required = False
+        form = {
+            "field.git_ref.repository": "",
+            "field.git_ref.path": "",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertIsNone(self.widget.getInputValue())
+
     def test_getInputValue_valid(self):
         # When both the repository and the path are valid, the field value
         # is the reference they identify.

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-09-23 14:21:15 +0000
+++ lib/lp/snappy/browser/snap.py	2015-09-26 00:51:11 +0000
@@ -73,6 +73,7 @@
     SnapFeatureDisabled,
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.build import get_build_by_id_str
 from lp.soyuz.interfaces.archive import IArchive
 
@@ -357,7 +358,7 @@
 
     def validate_widgets(self, data, names=None):
         """See `LaunchpadFormView`."""
-        if 'vcs' in self.widgets:
+        if self.widgets.get('vcs') is not None:
             # Set widgets as required or optional depending on the vcs
             # field.
             super(BaseSnapEditView, self).validate_widgets(data, ['vcs'])
@@ -379,6 +380,12 @@
             data['git_ref'] = None
         elif vcs == VCSType.GIT:
             data['branch'] = None
+        new_processors = data.get('processors')
+        if new_processors is not None:
+            if set(self.context.processors) != set(new_processors):
+                self.context.setProcessors(
+                    new_processors, check_permissions=True, user=self.user)
+            del data['processors']
         self.updateContextFromData(data)
         self.next_url = canonical_url(self.context)
 
@@ -400,7 +407,7 @@
     field_names = ['require_virtualized']
 
 
-class SnapEditView(BaseSnapEditView):
+class SnapEditView(BaseSnapEditView, EnableProcessorsMixin):
     """View for editing snap packages."""
 
     @property
@@ -415,6 +422,15 @@
     custom_widget('vcs', LaunchpadRadioWidget)
     custom_widget('git_ref', GitRefWidget)
 
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        super(SnapEditView, self).setUpFields()
+        self.form_fields += self.createEnabledProcessors(
+            self.context.available_processors,
+            u"The architectures that are available to be enabled or disabled "
+            u"for this snap package. Some architectures are restricted and "
+            u"may only be enabled or disabled by administrators.")
+
     @property
     def initial_values(self):
         if self.context.git_ref is not None:
@@ -437,6 +453,14 @@
                         'this name.' % owner.displayname)
             except NoSuchSnap:
                 pass
+        if 'processors' in data:
+            available_processors = set(self.context.available_processors)
+            for processor in self.context.processors:
+                if (processor not in available_processors and
+                        processor not in data['processors']):
+                    # This processor is not currently available for
+                    # selection, but is enabled.  Leave it untouched.
+                    data['processors'].append(processor)
 
 
 class SnapDeleteView(BaseSnapEditView):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2015-09-23 14:21:15 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2015-09-26 00:51:11 +0000
@@ -16,6 +16,10 @@
 from mechanize import LinkNotFoundError
 import pytz
 import soupmatchers
+from testtools.matchers import (
+    MatchesSetwise,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
@@ -35,10 +39,12 @@
     SnapView,
     )
 from lp.snappy.interfaces.snap import (
+    CannotModifySnapProcessor,
     SNAP_FEATURE_FLAG,
     SnapFeatureDisabled,
     )
 from lp.testing import (
+    admin_logged_in,
     BrowserTestCase,
     login,
     login_person,
@@ -249,7 +255,7 @@
 
 class TestSnapEditView(BrowserTestCase):
 
-    layer = DatabaseFunctionalLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         super(TestSnapEditView, self).setUp()
@@ -324,6 +330,116 @@
             "name.",
             extract_text(find_tags_by_class(browser.contents, "message")[1]))
 
+    def setUpDistroSeries(self):
+        """Set up a distroseries with some available processors."""
+        distroseries = self.factory.makeUbuntuDistroSeries()
+        processor_names = ["386", "amd64", "hppa"]
+        for name in processor_names:
+            processor = getUtility(IProcessorSet).getByName(name)
+            das = self.factory.makeDistroArchSeries(
+                distroseries=distroseries, architecturetag=name,
+                processor=processor)
+            das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+        return distroseries
+
+    def test_display_processors(self):
+        distroseries = self.setUpDistroSeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries)
+        browser = self.getViewBrowser(snap, view_name="+edit", user=snap.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(
+            ["Intel 386 (386)", "AMD 64bit (amd64)", "HPPA Processor (hppa)"],
+            [extract_text(option) for option in processors.displayOptions])
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.options)
+
+    def test_edit_processors(self):
+        distroseries = self.setUpDistroSeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries)
+        self.assertContentEqual(
+            ["386", "amd64", "hppa"],
+            [processor.name for processor in snap.processors])
+        browser = self.getViewBrowser(snap, view_name="+edit", user=snap.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        processors.value = ["386", "amd64"]
+        browser.getControl("Update snap package").click()
+        login_person(self.person)
+        self.assertContentEqual(
+            ["386", "amd64"],
+            [processor.name for processor in snap.processors])
+
+    def test_edit_with_invisible_processor(self):
+        # It's possible for existing snap packages to have an enabled
+        # processor that's no longer usable with the current distroseries,
+        # which will mean it's hidden from the UI, but the non-admin
+        # Snap.setProcessors isn't allowed to disable it.  Editing the
+        # processor list of such a snap package leaves the invisible
+        # processor intact.
+        proc_386 = getUtility(IProcessorSet).getByName("386")
+        proc_amd64 = getUtility(IProcessorSet).getByName("amd64")
+        proc_armel = self.factory.makeProcessor(
+            name="armel", restricted=True, build_by_default=False)
+        distroseries = self.setUpDistroSeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries)
+        with admin_logged_in():
+            snap.setProcessors([proc_386, proc_amd64, proc_armel])
+        browser = self.getViewBrowser(snap, view_name="+edit", user=snap.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64"], processors.value)
+        processors.value = ["amd64"]
+        browser.getControl("Update snap package").click()
+        login_person(self.person)
+        self.assertContentEqual(
+            ["amd64", "armel"],
+            [processor.name for processor in snap.processors])
+
+    def test_edit_processors_restricted(self):
+        # A restricted processor is shown disabled in the UI and cannot be
+        # enabled.
+        self.useFixture(FakeLogger())
+        distroseries = self.setUpDistroSeries()
+        proc_armhf = self.factory.makeProcessor(
+            name="armhf", restricted=True, build_by_default=False)
+        das_armhf = self.factory.makeDistroArchSeries(
+            distroseries=distroseries, architecturetag="armhf",
+            processor=proc_armhf)
+        das_armhf.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries)
+        self.assertContentEqual(
+            ["386", "amd64", "hppa"],
+            [processor.name for processor in snap.processors])
+        browser = self.getViewBrowser(snap, view_name="+edit", user=snap.owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        self.assertThat(
+            processors.controls, MatchesSetwise(
+                MatchesStructure.byEquality(
+                    optionValue="386", disabled=False),
+                MatchesStructure.byEquality(
+                    optionValue="amd64", disabled=False),
+                MatchesStructure.byEquality(
+                    optionValue="armhf", disabled=True),
+                MatchesStructure.byEquality(
+                    optionValue="hppa", disabled=False),
+                ))
+        # Even if the user works around the disabled checkbox and forcibly
+        # enables it, they can't enable the restricted processor.
+        for control in processors.controls:
+            if control.optionValue == "armhf":
+                control.mech_item.disabled = False
+        processors.value = ["386", "amd64", "armhf"]
+        self.assertRaises(
+            CannotModifySnapProcessor,
+            browser.getControl("Update snap package").click)
+
 
 class TestSnapDeleteView(BrowserTestCase):
 

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2015-09-18 13:32:09 +0000
+++ lib/lp/snappy/configure.zcml	2015-09-26 00:51:11 +0000
@@ -27,7 +27,6 @@
             set_schema="lp.snappy.interfaces.snap.ISnapEditableAttributes" />
         <require
             permission="launchpad.Admin"
-            interface="lp.snappy.interfaces.snap.ISnapAdmin"
             set_schema="lp.snappy.interfaces.snap.ISnapAdminAttributes" />
     </class>
     <subscriber

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2015-09-18 14:14:34 +0000
+++ lib/lp/snappy/interfaces/snap.py	2015-09-26 00:51:11 +0000
@@ -8,6 +8,7 @@
 __all__ = [
     'BadSnapSearchContext',
     'CannotDeleteSnap',
+    'CannotModifySnapProcessor',
     'DuplicateSnapName',
     'ISnap',
     'ISnapSet',
@@ -171,6 +172,19 @@
     """The context is not valid for a snap package search."""
 
 
+@error_status(httplib.FORBIDDEN)
+class CannotModifySnapProcessor(Exception):
+    """Tried to enable or disable a restricted processor on an snap package."""
+
+    _fmt = (
+        '%(processor)s is restricted, and may only be enabled or disabled '
+        'by administrators.')
+
+    def __init__(self, processor):
+        super(CannotModifySnapProcessor, self).__init__(
+            self._fmt % {'processor': processor.name})
+
+
 class ISnapView(Interface):
     """`ISnap` attributes that require launchpad.View permission."""
 
@@ -187,6 +201,19 @@
     source = Attribute(
         "The source branch for this snap package (VCS-agnostic).")
 
+    available_processors = Attribute(
+        "The architectures that are available to be enabled or disabled for "
+        "this snap package.")
+
+    @call_with(check_permissions=True, user=REQUEST_USER)
+    @operation_parameters(
+        processors=List(
+            value_type=Reference(schema=IProcessor), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def setProcessors(processors, check_permissions=False, user=None):
+        """Set the architectures for which the snap package should be built."""
+
     def getAllowedArchitectures():
         """Return all distroarchseries that this package can build for.
 
@@ -317,21 +344,8 @@
         readonly=False))
 
 
-class ISnapAdmin(Interface):
-    """`ISnap` methods that require launchpad.Admin permission."""
-
-    @operation_parameters(
-        processors=List(
-            value_type=Reference(schema=IProcessor), required=True))
-    @export_write_operation()
-    @operation_for_version("devel")
-    def setProcessors(processors):
-        """Set the architectures for which the snap package should be built."""
-
-
 class ISnap(
-    ISnapView, ISnapEdit, ISnapEditableAttributes, ISnapAdminAttributes,
-    ISnapAdmin):
+    ISnapView, ISnapEdit, ISnapEditableAttributes, ISnapAdminAttributes):
     """A buildable snap package."""
 
     # XXX cjwatson 2015-07-17 bug=760849: "beta" is a lie to get WADL

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2015-09-18 14:14:34 +0000
+++ lib/lp/snappy/model/snap.py	2015-09-26 00:51:11 +0000
@@ -19,10 +19,15 @@
     Storm,
     Unicode,
     )
-from zope.component import getUtility
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
 from zope.interface import implementer
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.security import IAuthorization
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.buildmaster.model.processor import Processor
@@ -46,7 +51,10 @@
     IPersonSet,
     )
 from lp.registry.interfaces.product import IProduct
-from lp.registry.interfaces.role import IHasOwner
+from lp.registry.interfaces.role import (
+    IHasOwner,
+    IPersonRoles,
+    )
 from lp.services.database.bulk import load_related
 from lp.services.database.constants import (
     DEFAULT,
@@ -65,6 +73,7 @@
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     CannotDeleteSnap,
+    CannotModifySnapProcessor,
     DuplicateSnapName,
     ISnap,
     ISnapSet,
@@ -84,6 +93,7 @@
     Archive,
     get_enabled_archive_filter,
     )
+from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
 def snap_modified(snap, event):
@@ -177,23 +187,55 @@
         else:
             return None
 
+    @property
+    def available_processors(self):
+        """See `ISnap`."""
+        processors = Store.of(self).find(
+            Processor,
+            Processor.id == DistroArchSeries.processor_id,
+            DistroArchSeries.id.is_in(
+                self.distro_series.buildable_architectures.get_select_expr(
+                    DistroArchSeries.id)),
+            DistroArchSeries.enabled)
+        return processors.config(distinct=True)
+
     def _getProcessors(self):
         return list(Store.of(self).find(
             Processor,
             Processor.id == SnapArch.processor_id,
             SnapArch.snap == self))
 
-    def setProcessors(self, processors):
+    def setProcessors(self, processors, check_permissions=False, user=None):
         """See `ISnap`."""
+        if check_permissions:
+            can_modify = None
+            if user is not None:
+                roles = IPersonRoles(user)
+                authz = lambda perm: getAdapter(self, IAuthorization, perm)
+                if authz('launchpad.Admin').checkAuthenticated(roles):
+                    can_modify = lambda proc: True
+                elif authz('launchpad.Edit').checkAuthenticated(roles):
+                    can_modify = lambda proc: not proc.restricted
+            if can_modify is None:
+                raise Unauthorized(
+                    'Permission launchpad.Admin or launchpad.Edit required '
+                    'on %s.' % self)
+        else:
+            can_modify = lambda proc: True
+
         enablements = dict(Store.of(self).find(
             (Processor, SnapArch),
             Processor.id == SnapArch.processor_id,
             SnapArch.snap == self))
         for proc in enablements:
             if proc not in processors:
+                if not can_modify(proc):
+                    raise CannotModifySnapProcessor(proc)
                 Store.of(self).remove(enablements[proc])
         for proc in processors:
             if proc not in self.processors:
+                if not can_modify(proc):
+                    raise CannotModifySnapProcessor(proc)
                 snaparch = SnapArch()
                 snaparch.snap = self
                 snaparch.processor = proc

=== modified file 'lib/lp/snappy/templates/snap-edit.pt'
--- lib/lp/snappy/templates/snap-edit.pt	2015-09-09 14:17:46 +0000
+++ lib/lp/snappy/templates/snap-edit.pt	2015-09-26 00:51:11 +0000
@@ -59,6 +59,10 @@
             </div>
           </td>
         </tr>
+
+        <tal:widget define="widget nocall:view/widgets/processors">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
       </table>
     </metal:formbody>
   </div>

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2015-09-16 13:30:33 +0000
+++ lib/lp/snappy/tests/test_snap.py	2015-09-26 00:51:11 +0000
@@ -15,6 +15,7 @@
 from zope.event import notify
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import (
     BuildQueueStatus,
     BuildStatus,
@@ -33,6 +34,7 @@
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     CannotDeleteSnap,
+    CannotModifySnapProcessor,
     ISnap,
     ISnapSet,
     ISnapView,
@@ -55,6 +57,7 @@
     )
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
     LaunchpadZopelessLayer,
     )
 from lp.testing.matchers import (
@@ -598,10 +601,10 @@
 
 class TestSnapProcessors(TestCaseWithFactory):
 
-    layer = LaunchpadZopelessLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
-        super(TestSnapProcessors, self).setUp()
+        super(TestSnapProcessors, self).setUp(user="foo.bar@xxxxxxxxxxxxx")
         self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
         self.default_procs = [
             getUtility(IProcessorSet).getByName("386"),
@@ -643,9 +646,45 @@
         snap.setProcessors(self.unrestricted_procs + [self.arm])
         self.assertContentEqual(
             self.unrestricted_procs + [self.arm], snap.processors)
-        snap.processors = []
+        snap.setProcessors([])
         self.assertContentEqual([], snap.processors)
 
+    def test_set_non_admin(self):
+        """Non-admins can only enable or disable unrestricted processors."""
+        snap = self.factory.makeSnap()
+        snap.setProcessors(self.default_procs)
+        self.assertContentEqual(self.default_procs, snap.processors)
+        with person_logged_in(snap.owner) as owner:
+            # Adding arm is forbidden ...
+            self.assertRaises(
+                CannotModifySnapProcessor, snap.setProcessors,
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=owner)
+            # ... but removing amd64 is OK.
+            snap.setProcessors(
+                [self.default_procs[0]], check_permissions=True, user=owner)
+            self.assertContentEqual([self.default_procs[0]], snap.processors)
+        with admin_logged_in() as admin:
+            snap.setProcessors(
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=admin)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm], snap.processors)
+        with person_logged_in(snap.owner) as owner:
+            hppa = getUtility(IProcessorSet).getByName("hppa")
+            self.assertFalse(hppa.restricted)
+            # Adding hppa while removing arm is forbidden ...
+            self.assertRaises(
+                CannotModifySnapProcessor, snap.setProcessors,
+                [self.default_procs[0], hppa],
+                check_permissions=True, user=owner)
+            # ... but adding hppa while retaining arm is OK.
+            snap.setProcessors(
+                [self.default_procs[0], self.arm, hppa],
+                check_permissions=True, user=owner)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm, hppa], snap.processors)
+
 
 class TestSnapWebservice(TestCaseWithFactory):
 
@@ -803,6 +842,77 @@
             "No such snap package with this owner: 'nonexistent'.",
             response.body)
 
+    def setProcessors(self, user, snap, names):
+        ws = webservice_for_person(
+            user, permission=OAuthPermission.WRITE_PUBLIC)
+        return ws.named_post(
+            snap["self_link"], "setProcessors",
+            processors=["/+processors/%s" % name for name in names],
+            api_version="devel")
+
+    def assertProcessors(self, user, snap, names):
+        body = webservice_for_person(user).get(
+            snap["self_link"] + "/processors", api_version="devel").jsonBody()
+        self.assertContentEqual(
+            names, [entry["name"] for entry in body["entries"]])
+
+    def test_setProcessors_admin(self):
+        """An admin can add a new processor to the enabled restricted set."""
+        commercial = getUtility(ILaunchpadCelebrities).commercial_admin
+        commercial_admin = self.factory.makePerson(member_of=[commercial])
+        self.factory.makeProcessor(
+            "arm", "ARM", "ARM", restricted=True, build_by_default=False)
+        snap = self.makeSnap()
+        self.assertProcessors(commercial_admin, snap, ["386", "hppa", "amd64"])
+
+        response = self.setProcessors(commercial_admin, snap, ["386", "arm"])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(commercial_admin, snap, ["386", "arm"])
+
+    def test_setProcessors_non_owner_forbidden(self):
+        """Only commercial admins and snap owners can call setProcessors."""
+        self.factory.makeProcessor(
+            "unrestricted", "Unrestricted", "Unrestricted", restricted=False,
+            build_by_default=False)
+        non_owner = self.factory.makePerson()
+        snap = self.makeSnap()
+
+        response = self.setProcessors(non_owner, snap, ["386", "unrestricted"])
+        self.assertEqual(401, response.status)
+
+    def test_setProcessors_owner(self):
+        """The snap owner can enable/disable unrestricted processors."""
+        snap = self.makeSnap()
+        self.assertProcessors(self.person, snap, ["386", "hppa", "amd64"])
+
+        response = self.setProcessors(self.person, snap, ["386"])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(self.person, snap, ["386"])
+
+        response = self.setProcessors(self.person, snap, ["386", "amd64"])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(self.person, snap, ["386", "amd64"])
+
+    def test_setProcessors_owner_restricted_forbidden(self):
+        """The snap owner cannot enable/disable restricted processors."""
+        commercial = getUtility(ILaunchpadCelebrities).commercial_admin
+        commercial_admin = self.factory.makePerson(member_of=[commercial])
+        self.factory.makeProcessor(
+            "arm", "ARM", "ARM", restricted=True, build_by_default=False)
+        snap = self.makeSnap()
+
+        response = self.setProcessors(self.person, snap, ["386", "arm"])
+        self.assertEqual(403, response.status)
+
+        # If a commercial admin enables arm, the owner cannot disable it.
+        response = self.setProcessors(
+            commercial_admin, snap, ["386", "arm"])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(self.person, snap, ["386", "arm"])
+
+        response = self.setProcessors(self.person, snap, ["386"])
+        self.assertEqual(403, response.status)
+
     def makeBuildableDistroArchSeries(self, **kwargs):
         das = self.factory.makeDistroArchSeries(**kwargs)
         fake_chroot = self.factory.makeLibraryFileAlias(


Follow ups