Repository: fedora-infra/bodhi

Issue:
Security updates should require severity to be set
It recently came to [FESCo's attention](https://pagure.io/fesco/issue/1820) that security updates often don't have severity set by the packager (it's often set to "unspecified", the default). This makes it difficult to tell which updates might be important to push immediately, vs. waiting for the next batch. Outside of that problem, it would also be better for our users if we had indications on the severity of our security updates, now that [severity is included in our updateinfo.xml](https://github.com/fedora-infra/bodhi/pull/2099).


Discussion:
When implementing this, consider whether it may also make sense to force bugs to indicate severity. Enhancement and newpackage updates probably don't make sense to even have "severity", though it's harmless so we can let them have it without enforcing it.
https://github.com/fedora-infra/bodhi/issues/1814 will be a blocker for implementing this.
https://github.com/fedora-infra/bodhi/issues/1726 suggests disabling severity on newpackage and enhancement updates.

Resolving patch:
diff --git a/bodhi/client/__init__.py b/bodhi/client/__init__.py
--- a/bodhi/client/__init__.py
+++ b/bodhi/client/__init__.py
@@ -96,7 +96,8 @@ def _set_logging_debug(ctx, param, value):
     click.option('--type', default='bugfix', help='Update type', required=True,
                  type=click.Choice(['security', 'bugfix', 'enhancement', 'newpackage'])),
     click.option('--severity', help='Update severity',
-                 type=click.Choice(['unspecified', 'low', 'medium', 'high', 'urgent'])),
+                 type=click.Choice(['unspecified', 'low', 'medium', 'high', 'urgent']),
+                 is_eager=True),
     click.option('--notes', help='Update description'),
     click.option('--notes-file', help='Update description from a file'),
     click.option('--bugs', help='Comma-separated list of bug numbers', default=''),
@@ -306,6 +307,19 @@ def updates():
     pass  # pragma: no cover
 
 
+def require_severity_for_security_update(type, severity):
+    """
+    Print an error message if the user did not provide severity for a security update.
+
+    Args:
+        type (unicode): The value of the update 'type'.
+        severity (unicode): The value of the update 'severity'.
+    """
+    if type == 'security' and severity not in ('low', 'medium', 'high', 'urgent'):
+        raise click.BadParameter('must specify severity for a security update',
+                                 param_hint='severity')
+
+
 @updates.command()
 @add_options(new_edit_options)
 @click.argument('builds')
@@ -348,6 +362,7 @@ def new(user, password, url, debug, **kwargs):
         sys.exit(1)
 
     for update in updates:
+        require_severity_for_security_update(type=update['type'], severity=update['severity'])
         try:
             resp = client.save(**update)
             print_resp(resp, client)
@@ -432,6 +447,8 @@ def edit(user, password, url, debug, **kwargs):
             if kwargs[field] in (None, '') and field in former_update:
                 kwargs[field] = former_update[field]
 
+        require_severity_for_security_update(type=kwargs['type'], severity=kwargs['severity'])
+
         resp = client.save(**kwargs)
         print_resp(resp, client)
     except bindings.BodhiClientException as e:
diff --git a/bodhi/server/services/updates.py b/bodhi/server/services/updates.py
--- a/bodhi/server/services/updates.py
+++ b/bodhi/server/services/updates.py
@@ -57,6 +57,7 @@
     validate_requirements,
     validate_bugs,
     validate_request,
+    validate_severity,
 )
 
 
@@ -429,6 +430,7 @@ def query_updates(request):
                   validate_enums,
                   validate_requirements,
                   validate_bugs,
+                  validate_severity,
               ))
 def new_update(request):
     """
diff --git a/bodhi/server/validators.py b/bodhi/server/validators.py
--- a/bodhi/server/validators.py
+++ b/bodhi/server/validators.py
@@ -756,6 +756,23 @@ def validate_bugs(request, **kwargs):
                                "Invalid bug ID specified: {}".format(bugs))
 
 
+@postschema_validator
+def validate_severity(request, **kwargs):
+    """
+    Ensure that severity is specified for a 'security' update.
+
+    Args:
+        request (pyramid.util.Request): The current request.
+        kwargs (dict): The kwargs of the related service definition. Unused.
+    """
+    type = request.validated.get('type')
+    severity = request.validated.get('severity')
+
+    if type == UpdateType.security and severity == UpdateSeverity.unspecified:
+        request.errors.add("body", "severity",
+                           "Must specify severity for a security update")
+
+
 @postschema_validator
 def validate_update(request, **kwargs):
     """


Test patch:
diff --git a/bodhi/tests/client/test___init__.py b/bodhi/tests/client/test___init__.py
--- a/bodhi/tests/client/test___init__.py
+++ b/bodhi/tests/client/test___init__.py
@@ -568,6 +568,19 @@ def test_new_update_without_notes(self):
         self.assertEqual(result.output, u'ERROR: must specify at least one of --file, --notes, or '
                                         '--notes-file\n')
 
+    def test_new_security_update_with_unspecified_severity(self):
+        """Assert not providing --severity to new security update request results in an error."""
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.new,
+            ['--user', 'bowlofeggs', '--password', 's3kr3t', 'bodhi-2.2.4-1.el7',
+             '--notes', 'bla bla bla', '--type', 'security'])
+
+        self.assertEqual(result.exit_code, 2)
+        self.assertEqual(result.output, ('Usage: new [OPTIONS] BUILDS\n\nError: Invalid '
+                         'value for severity: must specify severity for a security update\n'))
+
 
 class TestPrintOverrideKojiHint(unittest.TestCase):
     """
@@ -1514,6 +1527,23 @@ def test_edit_bugless_update_without_bugs_param(self, send_request, query):
         ]
         self.assertEqual(send_request.mock_calls, calls)
 
+    @mock.patch('bodhi.client.bindings.BodhiClient.csrf',
+                mock.MagicMock(return_value='a_csrf_token'))
+    @mock.patch('bodhi.client.bindings.BodhiClient.query',
+                return_value=client_test_data.EXAMPLE_QUERY_MUNCH, autospec=True)
+    def test_edit_security_update_with_unspecified_severity(self, query):
+        """Assert 'unspecified' severity while editing a security update results in an error."""
+        runner = testing.CliRunner()
+
+        result = runner.invoke(
+            client.edit, ['FEDORA-2017-cc8582d738', '--user', 'bowlofeggs',
+                          '--password', 's3kr3t', '--notes', 'this is an edited note',
+                          '--type', 'security', '--severity', 'unspecified'])
+
+        self.assertEqual(result.exit_code, 2)
+        self.assertEqual(result.output, ('Usage: edit [OPTIONS] UPDATE\n\nError: Invalid '
+                         'value for severity: must specify severity for a security update\n'))
+
 
 class TestEditBuilrootOverrides(unittest.TestCase):
     """
diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py
--- a/bodhi/tests/server/services/test_updates.py
+++ b/bodhi/tests/server/services/test_updates.py
@@ -706,6 +706,19 @@ def test_obsoletion_with_exception(self, *args):
         expected_comment = u'This update has been submitted for testing by guest. '
         self.assertEqual(up.comments[-1].text, expected_comment)
 
+    @mock.patch(**mock_valid_requirements)
+    def test_security_update_without_severity(self, *args):
+        """Ensure that severity is required for a security update."""
+        update = self.get_update('bodhi-2.3.2-1.fc17')
+        update['type'] = u'security'
+        update['severity'] = u'unspecified'
+
+        r = self.app.post_json('/updates/', update, status=400)
+
+        self.assertEqual(r.json_body['status'], 'error')
+        self.assertEqual(r.json_body['errors'][0]['description'],
+                         "Must specify severity for a security update")
+
 
 class TestSetRequest(BaseTestCase):
     """
@@ -844,6 +857,20 @@ def test_edit_not_loggedin(self):
         self.assertIn('<h1>403 <small>Forbidden</small></h1>', resp)
         self.assertIn('<p class="lead">Access was denied to this resource.</p>', resp)
 
+    def test_disabled_unspecified_severity_for_security_update(self):
+        update_json = self.get_update()
+        update_json['csrf_token'] = self.app.get('/csrf').json_body['csrf_token']
+        update_json['edited'] = u'bodhi-2.0-1.fc17'
+        update_json['requirements'] = u''
+        update_json['type'] = u'security'
+        update_json['severity'] = u'low'
+        self.app.post_json('/updates/', update_json)
+
+        resp = self.app.get('/updates/bodhi-2.0-1.fc17/edit',
+                            headers={'accept': 'text/html'})
+        self.assertRegexpMatches(str(resp), ('<input type="radio" name="severity" '
+                                             'value="unspecified"\\n.*disabled="disabled"\\n.*>'))
+
 
 class TestUpdatesService(BaseTestCase):