Repository: fedora-infra/bodhi

Issue:
bodhi.get_releases() is now broken
Using `python-fedora-0.7.1-4.fc23.noarch`:

```
In [1]: import fedora.client

In [2]: bodhi =  fedora.client.BodhiClient()

In [3]: bodhi.get_releases()
---------------------------------------------------------------------------
BodhiClientException                      Traceback (most recent call last)
<ipython-input-3-edab0102d5c5> in <module>()
----> 1 bodhi.get_releases()

/usr/lib/python2.7/site-packages/fedora/client/bodhi.pyc in wrapper(*args, **kwargs)
     94         except Exception:
     95             pass
---> 96         raise BodhiClientException(problems)
     97     return wrapper
     98 

BodhiClientException: csrf_token is missing
name is missing
long_name is missing
branch is missing
id_prefix is missing
dist_tag is missing
stable_tag is missing
testing_tag is missing
candidate_tag is missing
override_tag is missing
```

This works when I downgrade to `python-fedora-0.5.5-1.fc23.noarch`.



Discussion:
Very weird, since `curl https://bodhi.fedoraproject.org/releases/` works fine without any arguments or a CSRF token...

fedora.client.BodhiClient() is now deprecated. however, using the replacement bodhi.client.bindings still fails, returning the following server error:

```
ServerError: ServerError(https://bodhi.fedoraproject.org/releases/, 403, Error returned from json module while processing https://bodhi.fedoraproject.org/releases/: Expecting value: line 6 column 1 (char 5) 
```

The steps to get the error are:

```
In [1]: import bodhi.client                                                                     
In [2]: client = bodhi.client.bindings.BodhiClient()
In [3]: client.get_releases()
```

Resolving patch:
diff --git a/bodhi/client/bindings.py b/bodhi/client/bindings.py
--- a/bodhi/client/bindings.py
+++ b/bodhi/client/bindings.py
@@ -35,6 +35,10 @@
 import re
 import textwrap
 
+from iniparse.compat import ConfigParser
+from six.moves import configparser
+import dnf
+import koji
 import six
 
 from fedora.client import AuthError, OpenIdBaseClient, FedoraClientError
@@ -402,8 +406,6 @@ def parse_file(self, input_file):
         can be directly passed to the ``save`` method.
 
         """
-        from six.moves import configparser
-
         if not os.path.exists(input_file):
             raise ValueError("No such file or directory: %s" % input_file)
 
@@ -448,7 +450,6 @@ def testable(self):
 
         Only works on systems with dnf.
         """
-        import dnf
         base = dnf.Base()
         sack = base.fill_sack(load_system_repo=True)
         query = sack.query()
@@ -456,8 +457,7 @@ def testable(self):
         with open('/etc/fedora-release', 'r') as f:
             fedora = f.readlines()[0].split()[2]
         tag = 'f%s-updates-testing' % fedora
-        builds = self.get_koji_session(
-            login=False).listTagged(tag, latest=True)
+        builds = self.get_koji_session().listTagged(tag, latest=True)
         for build in builds:
             pkgs = installed.filter(name=build['name'], version=build['version'],
                                     release=build['release']).run()
@@ -580,23 +580,16 @@ def get_releases(self, **kwargs):
                 {"dist_tag": "dist-f12", "id_prefix": "FEDORA",
                  "locked": false, "name": "F12", "long_name": "Fedora 12"}]}
         """
-        return self.send_request('releases/', params=kwargs)
+        return self.send_request('releases/', verb='GET', params=kwargs)
 
-    def get_koji_session(self, login=True):
+    def get_koji_session(self):
         """ Return an authenticated koji session """
-        import koji
-        from iniparse.compat import ConfigParser
         config = ConfigParser()
         if os.path.exists(os.path.join(os.path.expanduser('~'), '.koji', 'config')):
             config.readfp(open(os.path.join(os.path.expanduser('~'), '.koji', 'config')))
         else:
             config.readfp(open('/etc/koji.conf'))
-        cert = os.path.expanduser(config.get('koji', 'cert'))
-        ca = os.path.expanduser(config.get('koji', 'ca'))
-        serverca = os.path.expanduser(config.get('koji', 'serverca'))
         session = koji.ClientSession(config.get('koji', 'server'))
-        if login:
-            session.ssl_login(cert=cert, ca=ca, serverca=serverca)
         return session
 
     koji_session = property(fget=get_koji_session)
@@ -607,11 +600,10 @@ def candidates(self):
         This method is a generator that returns a list of koji builds that
         could potentially be pushed as updates.
         """
-        if not self.username:
-            raise BodhiClientException('You must specify a username')
+        self.init_username()
         builds = []
         data = self.get_releases()
-        koji = self.get_koji_session(login=False)
+        koji = self.get_koji_session()
         for release in data['releases']:
             try:
                 for build in koji.listTagged(release['candidate_tag'], latest=True):


Test patch:
diff --git a/bodhi/tests/client/test_bindings.py b/bodhi/tests/client/test_bindings.py
--- a/bodhi/tests/client/test_bindings.py
+++ b/bodhi/tests/client/test_bindings.py
@@ -1,5 +1,8 @@
 # -*- coding: utf-8 -*-
-
+# Copyright 2008-2017 Red Hat, Inc. and others.
+#
+# This file is part of Bodhi.
+#
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
 # as published by the Free Software Foundation; either version 2
@@ -1054,10 +1057,121 @@ def test___unicode__(self):
         self.assertEqual(unicode(exc), 'Update not found: bodhi-2.2.4-1.el7')
 
 
+class TestBodhiClient_candidates(unittest.TestCase):
+    """
+    Test the BodhiClient.candidates() method.
+    """
+    @mock.patch('__builtin__.raw_input', create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies', mock.MagicMock())
+    @mock.patch('bodhi.client.bindings.BodhiClient.get_koji_session')
+    @mock.patch('bodhi.client.bindings.log.exception')
+    def test_failure(self, exception, get_koji_session, mock_raw_input):
+        """Ensure correct handling when talking to Koji raises an Exception."""
+        get_koji_session.return_value.listTagged.side_effect = [
+            [{'name': 'bodhi', 'version': '2.9.0', 'release': '1.fc25', 'nvr': 'bodhi-2.9.0-1.fc25',
+              'owner_name': 'bowlofeggs'},
+             {'name': 'ipsilon', 'version': '2.0.2', 'release': '1.fc25',
+              'nvr': 'ipsilon-2.0.2-1.fc25', 'owner_name': 'puiterwijk'}],
+            IOError("Bet you didn't expect this.")]
+        mock_raw_input.return_value = 'bowlofeggs'
+        client = bindings.BodhiClient()
+        client.send_request = mock.MagicMock(
+            return_value={'releases': [{'candidate_tag': 'f25-updates-testing'},
+                                       {'candidate_tag': 'f26-updates-testing'}]})
+
+        results = client.candidates()
+
+        self.assertEqual(
+            results,
+            [{'release': '1.fc25', 'version': '2.9.0', 'name': 'bodhi', 'owner_name': 'bowlofeggs',
+              'nvr': 'bodhi-2.9.0-1.fc25'}])
+        get_koji_session.assert_called_once_with()
+        self.assertEqual(
+            get_koji_session.return_value.listTagged.mock_calls,
+            [mock.call('f25-updates-testing', latest=True),
+             mock.call('f26-updates-testing', latest=True)])
+        client.send_request.assert_called_once_with('releases/', params={}, verb='GET')
+        exception.assert_called_once_with(
+            "Unable to query candidate builds for {'candidate_tag': 'f26-updates-testing'}")
+
+    @mock.patch('__builtin__.raw_input', create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies', mock.MagicMock())
+    @mock.patch('bodhi.client.bindings.BodhiClient.get_koji_session')
+    def test_success(self, get_koji_session, mock_raw_input):
+        """Ensure correct behavior when there are no errors talking to Koji."""
+        get_koji_session.return_value.listTagged.side_effect = [
+            [{'name': 'bodhi', 'version': '2.9.0', 'release': '1.fc25', 'nvr': 'bodhi-2.9.0-1.fc25',
+              'owner_name': 'bowlofeggs'},
+             {'name': 'ipsilon', 'version': '2.0.2', 'release': '1.fc25',
+              'nvr': 'ipsilon-2.0.2-1.fc25', 'owner_name': 'puiterwijk'}],
+            [{'name': 'bodhi', 'version': '2.9.0', 'release': '1.fc26', 'nvr': 'bodhi-2.9.0-1.fc26',
+              'owner_name': 'bowlofeggs'}]]
+        mock_raw_input.return_value = 'bowlofeggs'
+        client = bindings.BodhiClient()
+        client.send_request = mock.MagicMock(
+            return_value={'releases': [{'candidate_tag': 'f25-updates-testing'},
+                                       {'candidate_tag': 'f26-updates-testing'}]})
+
+        results = client.candidates()
+
+        self.assertEqual(
+            results,
+            [{'release': '1.fc25', 'version': '2.9.0', 'name': 'bodhi', 'owner_name': 'bowlofeggs',
+              'nvr': 'bodhi-2.9.0-1.fc25'},
+             {'release': '1.fc26', 'version': '2.9.0', 'name': 'bodhi', 'owner_name': 'bowlofeggs',
+              'nvr': 'bodhi-2.9.0-1.fc26'}])
+        get_koji_session.assert_called_once_with()
+        self.assertEqual(
+            get_koji_session.return_value.listTagged.mock_calls,
+            [mock.call('f25-updates-testing', latest=True),
+             mock.call('f26-updates-testing', latest=True)])
+        client.send_request.assert_called_once_with('releases/', params={}, verb='GET')
+
+
+class TestBodhiClient_get_releases(unittest.TestCase):
+    """
+    Test the BodhiClient.get_releases() method.
+    """
+    @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies', mock.MagicMock())
+    def test_get_releases(self):
+        """Assert correct behavior from the get_releases() method."""
+        client = bindings.BodhiClient()
+        client.send_request = mock.MagicMock(
+            return_value={'releases': [{'candidate_tag': 'f25-updates-testing'},
+                                       {'candidate_tag': 'f26-updates-testing'}]})
+
+        results = client.get_releases(some_param='some_value')
+
+        self.assertEqual(
+            results,
+            {'releases': [
+                {'candidate_tag': 'f25-updates-testing'},
+                {'candidate_tag': 'f26-updates-testing'}]})
+        client.send_request.assert_called_once_with(
+            'releases/', params={'some_param': 'some_value'}, verb='GET')
+
+
 class TestBodhiClient_parse_file(unittest.TestCase):
     """
     Test the BodhiClient.parse_file() method.
     """
+    @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies')
+    @mock.patch('bodhi.client.bindings.configparser.SafeConfigParser.read')
+    @mock.patch('bodhi.client.bindings.os.path.exists')
+    def test_parsing_invalid_file(self, exists, read, _load_cookies):
+        """
+        Test parsing an invalid update template file.
+        """
+        exists.return_value = True
+        # This happens when we don't have permission to read the file.
+        read.return_value = []
+        client = bindings.BodhiClient()
+
+        with self.assertRaises(ValueError) as exc:
+            client.parse_file("sad")
+
+        self.assertEqual(unicode(exc.exception), 'Invalid input file: sad')
+
     @mock.patch('__builtin__.open', create=True)
     @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies')
     @mock.patch('bodhi.client.bindings.os.path.exists')
@@ -1122,3 +1236,39 @@ def test_parsing_nonexistant_file(self):
 
         self.assertEqual(unicode(exc.exception),
                          'No such file or directory: /tmp/bodhi-test-parsefile2')
+
+
+class TestBodhiClient_testable(unittest.TestCase):
+    """
+    Test the BodhiClient.testable() method.
+    """
+    @mock.patch('__builtin__.open', create=True)
+    @mock.patch('bodhi.client.bindings.BodhiClient._load_cookies', mock.MagicMock())
+    @mock.patch('bodhi.client.bindings.BodhiClient.get_koji_session')
+    @mock.patch('bodhi.client.bindings.dnf.Base.fill_sack')
+    def test_testable(self, fill_sack, get_koji_session, mock_open):
+        """Assert correct behavior from the testable() method."""
+        get_koji_session.return_value.listTagged.return_value = [
+            {'name': 'bodhi', 'version': '2.9.0', 'release': '1.fc26', 'nvr': 'bodhi-2.9.0-1.fc26'}]
+        fill_sack.return_value.query.return_value.installed.return_value.filter.return_value.run.\
+            return_value = ['bodhi-2.8.1-1.fc26']
+        mock_open.return_value.__enter__.return_value.readlines.return_value = [
+            'Fedora release 26 (Twenty Six)']
+        client = bindings.BodhiClient()
+        client.send_request = mock.MagicMock(
+            return_value={'updates': [{'nvr': 'bodhi-2.9.0-1.fc26'}]})
+
+        updates = client.testable()
+
+        self.assertEqual(list(updates), [{'nvr': 'bodhi-2.9.0-1.fc26'}])
+        fill_sack.assert_called_once_with(load_system_repo=True)
+        fill_sack.return_value.query.assert_called_once_with()
+        fill_sack.return_value.query.return_value.installed.assert_called_once_with()
+        fill_sack.return_value.query.return_value.installed.return_value.filter.\
+            assert_called_once_with(name='bodhi', version='2.9.0', release='1.fc26')
+        fill_sack.return_value.query.return_value.installed.return_value.filter.return_value.run.\
+            assert_called_once_with()
+        get_koji_session.return_value.listTagged.assert_called_once_with('f26-updates-testing',
+                                                                         latest=True)
+        client.send_request.assert_called_once_with(
+            'updates/', params={'builds': 'bodhi-2.9.0-1.fc26'}, verb='GET')
diff --git a/devel/ci/run_tests.sh b/devel/ci/run_tests.sh
--- a/devel/ci/run_tests.sh
+++ b/devel/ci/run_tests.sh
@@ -31,6 +31,7 @@ sudo yum install -y\
     python-cornice\
     python-createrepo_c\
     python-cryptography\
+    python-dnf\
     python-dogpile-cache\
     python-fedora\
     python-kitchen\