Merge lp:~adeuring/launchpad/bug-261254-fix-mantis-login into lp:launchpad

Proposed by Abel Deuring
Status: Merged
Approved by: Deryck Hodge
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~adeuring/launchpad/bug-261254-fix-mantis-login
Merge into: lp:launchpad
Diff against target: 348 lines (+162/-31)
4 files modified
lib/lp/bugs/externalbugtracker/mantis.py (+28/-7)
lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt (+11/-3)
lib/lp/bugs/tests/externalbugtracker.py (+26/-21)
lib/lp/bugs/tests/test_bugtracker.py (+97/-0)
To merge this branch: bzr merge lp:~adeuring/launchpad/bug-261254-fix-mantis-login
Reviewer Review Type Date Requested Status
Deryck Hodge (community) code Approve
Review via email: mp+23097@code.launchpad.net

Description of the change

This branch fixes bug 261254: Launchpad couldn't connect to ALSA Bug Tracker.

The communication between the Launchpad's "Mantis bug watcher" and
a Mantis bug tracker was hampered by three issues:

1. Some time ago, ClientCookie.HTTPRedirectHandler was replaced by
   urllib2.HTTPRedirectHandler as the base class of MantisLoginHandler,
   but the cookie handler from urllib2 was not added. Since many
   Mantis trackers require users to login (be it as a guest), and since
   the credentials are stored in a cookie, access to a Mantis tracker
   was no longer possible. (I also think that things like bug filter
   settings are stored in cookies.)

2. class MantisLoginHandler works by overloading the method
   redirect_request() of ClientCookie.HTTPRedirectHandler /
   urllib2.HTTPRedirectHandler. Somehow, this method simply
   disappeared.

3. At least the Alsa bug tracker returns a HTTP 500 error when the
   URL for CSV data is accessed. This led to an exception in
   Mantis.csv_data. The new implementation simply treats this
   repsonse as an indication that the Mantis isntance does not
   provide any CSV data and that we must screen-scrape the bug data.

The code changes to fix these bugs are quite straightforward, I think.

In order to ensure that a similiar unnoticed disappearance of the
method MantisLoginHandler.redirect_request() does not happen again,
I added a unit test for it. Similary, I added a test to ensure that
MantisLoginHandler is used.

Finally, I added a test that HTTP 500 errors are treated by
Mantis.csv_data as described above. In order to test that other HTTP
errors are not swallowed by Mantis.csv_data, I moved the the
code from this cached property into the method _csv_data(), so that
I can use assertRaises.

The redirect and HTTP error tests require a sort of "fake
request/response handling", which was already implemented by the
class Urlib2TransportTestHandler, but the implementation was
"tailor-made" for some XMLRPC tests. I generalized it a bit, which
in turn required some modifications of
externalbugtracker-xmlrpc-transport.txt.

The new method setError(self, error, url) indeed needs the second
parameter:
1. Mantis._csv_data() issues two HTTP requests, and we want that
   the second request returns an error.
2. While HTTPError.__init__() wants the URL as a parameter too,
   it seems that it is nowhere stored, so we can't use something
   like seif.raise_error.url in Urlib2TransportTestHandler to check
   in Urlib2TransportTestHandler.default_open() if the current
   URL the one where the error should be returned.

Finally, "make lint" had three complaints:

    1620: [E0702, Urlib2TransportTestHandler.default_open] Raising NoneType while only classes, instances or string are allowed
    1626: [W0108 , Urlib2TransportTestHandler.default_open.<lambda>] Lambda may not be necessary
    1639: [W0108, Urlib2TransportTestHandler.default_open.<lambda>] Lambda may not be necessary

I don't think that we'll ever hae a "raise None" in line 1620.

Lambda _may_ not be necessary, but using something like

            response.geturl = req.get_full_url

is not a good idea, since response and req are instances of different
classes.

I disabled these messages.

tests:

./bin/test -vvt test_bugtracker
./bin/test -vvt tests/externalbugtracker-xmlrpc-transport.txt

No lint except the wrong messages mentioned above.

To post a comment you must log in.
Revision history for this message
Deryck Hodge (deryck) wrote :

Looks good. Nice work debugging what was going on here.

Cheers,
deryck

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/externalbugtracker/mantis.py'
2--- lib/lp/bugs/externalbugtracker/mantis.py 2010-03-16 16:52:42 +0000
3+++ lib/lp/bugs/externalbugtracker/mantis.py 2010-04-12 09:54:39 +0000
4@@ -18,9 +18,9 @@
5 from canonical.launchpad.webapp.url import urlparse
6
7 from lp.bugs.externalbugtracker import (
8- BugNotFound, BugWatchUpdateError, BugWatchUpdateWarning,
9- ExternalBugTracker, InvalidBugId, LookupTree, UnknownRemoteStatusError,
10- UnparseableBugData)
11+ BugNotFound, BugTrackerConnectError, BugWatchUpdateError,
12+ BugWatchUpdateWarning, ExternalBugTracker, InvalidBugId, LookupTree,
13+ UnknownRemoteStatusError, UnparseableBugData)
14 from lp.bugs.externalbugtracker.isolation import ensure_no_transaction
15 from lp.bugs.interfaces.bugtask import BugTaskImportance, BugTaskStatus
16 from lp.bugs.interfaces.externalbugtracker import UNKNOWN_REMOTE_IMPORTANCE
17@@ -79,6 +79,10 @@
18
19 return url
20
21+ def redirect_request(self, request, fp, code, msg, hdrs, new_url):
22+ return urllib2.HTTPRedirectHandler.redirect_request(
23+ self, request, fp, code, msg, hdrs, self.rewrite_url(new_url))
24+
25
26 class Mantis(ExternalBugTracker):
27 """An `ExternalBugTracker` for dealing with Mantis instances.
28@@ -89,9 +93,13 @@
29 https://dev.launchpad.net/Bugs/ExternalBugTrackers/Mantis
30 """
31
32- # Custom opener that automatically sends anonymous credentials to
33- # Mantis if (and only if) needed.
34- _opener = urllib2.build_opener(MantisLoginHandler)
35+ def __init__(self, baseurl):
36+ super(Mantis, self).__init__(baseurl)
37+ # Custom cookie aware opener that automatically sends anonymous
38+ # credentials to Mantis if (and only if) needed.
39+ self._cookie_handler = urllib2.HTTPCookieProcessor()
40+ self._opener = urllib2.build_opener(
41+ self._cookie_handler, MantisLoginHandler())
42
43 @ensure_no_transaction
44 def urlopen(self, request, data=None):
45@@ -111,6 +119,10 @@
46 If the export fails (i.e. the response is 0-length), None will
47 be returned.
48 """
49+ return self._csv_data()
50+
51+ def _csv_data(self):
52+ """See `csv_data()."""
53 # Next step is getting our query filter cookie set up; we need
54 # to do this weird submit in order to get the closed bugs
55 # included in the results; the default Mantis filter excludes
56@@ -157,7 +169,16 @@
57 # Finally grab the full CSV export, which uses the
58 # MANTIS_VIEW_ALL_COOKIE set in the previous step to specify
59 # what's being viewed.
60- csv_data = self._getPage("csv_export.php")
61+ try:
62+ csv_data = self._getPage("csv_export.php")
63+ except BugTrackerConnectError, value:
64+ # Some Mantis installations simply return a 500 error
65+ # when the csv_export.php page is accessed. Since the
66+ # bug data may be nevertheless available from ordinary
67+ # web pages, we simply ignore this error.
68+ if value.error.startswith('HTTP Error 500'):
69+ return None
70+ raise
71
72 if not csv_data:
73 return None
74
75=== modified file 'lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt'
76--- lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt 2009-10-16 22:12:33 +0000
77+++ lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt 2010-04-12 09:54:39 +0000
78@@ -5,7 +5,7 @@
79 which can connect through an http proxy server.
80
81 >>> from lp.bugs.tests.externalbugtracker import (
82- ... patch_transport_opener)
83+ ... Urlib2TransportTestHandler)
84 >>> from lp.bugs.externalbugtracker.xmlrpc import (
85 ... UrlLib2Transport)
86
87@@ -23,7 +23,8 @@
88 connection. The response returns the request-url as an XMLRPC parameter, and
89 sets a cookie from the server, 'foo=bar'.
90
91- >>> patch_transport_opener(transport)
92+ >>> test_handler = Urlib2TransportTestHandler()
93+ >>> transport.opener.add_handler(test_handler)
94
95 Before sending the request, the transport's cookie jar is empty.
96
97@@ -61,6 +62,12 @@
98 If an error occurs trying to make the request, an
99 `xmlrpclib.ProtocolError` is raised.
100
101+ >>> from urllib2 import HTTPError
102+ >>> test_handler.setError(
103+ ... HTTPError(
104+ ... 'http://www.example.com/xmlrpc', 500, 'Internal Error', {},
105+ ... None),
106+ ... 'http://www.example.com/xmlrpc')
107 >>> request_body = """<?xml version="1.0"?>
108 ... <methodCall>
109 ... <methodName>examples.testError</methodName>
110@@ -83,9 +90,10 @@
111 to the location indicated in that response rather than the original
112 location.
113
114+ >>> test_handler.setRedirect('http://www.example.com/xmlrpc/redirected')
115 >>> request_body = """<?xml version="1.0"?>
116 ... <methodCall>
117- ... <methodName>examples.testRedirect</methodName>
118+ ... <methodName>examples.whatever</methodName>
119 ... <params>
120 ... <param>
121 ... <value>
122
123=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
124--- lib/lp/bugs/tests/externalbugtracker.py 2010-04-09 11:07:02 +0000
125+++ lib/lp/bugs/tests/externalbugtracker.py 2010-04-12 09:54:39 +0000
126@@ -1,7 +1,7 @@
127 # Copyright 2009 Canonical Ltd. This software is licensed under the
128 # GNU Affero General Public License version 3 (see the file LICENSE).
129
130-# pylint: disable-msg=W0231
131+# pylint: disable-msg=W0231,E0702,W0108
132
133 """Helper classes for testing ExternalSystem."""
134
135@@ -17,7 +17,7 @@
136 from StringIO import StringIO
137 from datetime import datetime, timedelta
138 from httplib import HTTPMessage
139-from urllib2 import BaseHandler, HTTPError, Request
140+from urllib2 import BaseHandler, Request
141
142 from zope.component import getUtility
143
144@@ -42,7 +42,6 @@
145 from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
146 from lp.bugs.scripts import debbugs
147 from canonical.launchpad.testing.systemdocs import ordered_dict_as_string
148-from canonical.launchpad.webapp import urlappend
149 from canonical.launchpad.xmlrpc import ExternalBugTrackerTokenAPI
150 from canonical.testing.layers import LaunchpadZopelessLayer
151
152@@ -1592,6 +1591,20 @@
153 class Urlib2TransportTestHandler(BaseHandler):
154 """A test urllib2 handler returning a hard-coded response."""
155
156+ def __init__(self):
157+ self.redirect_url = None
158+ self.raise_error = None
159+ self.accessed_urls = []
160+
161+ def setRedirect(self, new_url):
162+ """The next call of default_open() will redirect to `url`."""
163+ self.redirect_url = new_url
164+
165+ def setError(self, error, url):
166+ """Raise `error` when `url` is accessed."""
167+ self.raise_error = error
168+ self.raise_url = url
169+
170 def default_open(self, req):
171 """Catch all requests and return a hard-coded response.
172
173@@ -1601,31 +1614,23 @@
174 assert isinstance(req, Request), (
175 'Expected a urllib2.Request, got %s' % req)
176
177- if 'testError' in req.data:
178- raise HTTPError(
179- req.get_full_url(), 500, 'Internal Error', {}, None)
180-
181- elif ('testRedirect' in req.data and
182- 'redirected' not in req.get_full_url()):
183- # Big hack to make calls to testRedirect act as though a 302
184- # has been received. Note the slightly cheaty check for
185- # 'redirected' in the URL. This is to stop urllib2 from
186- # whinging about infinite loops.
187- redirect_url = urlappend(
188- req.get_full_url(), 'redirected')
189-
190+ self.accessed_urls.append(req.get_full_url())
191+ if (self.raise_error is not None and
192+ req.get_full_url() == self.raise_url):
193+ error = self.raise_error
194+ self.raise_error = None
195+ raise error
196+ elif self.redirect_url is not None:
197 headers = HTTPMessage(StringIO())
198- headers['location'] = redirect_url
199-
200+ headers['location'] = self.redirect_url
201 response = StringIO()
202 response.info = lambda: headers
203 response.geturl = lambda: req.get_full_url()
204 response.code = 302
205 response.msg = 'Moved'
206+ self.redirect_url = None
207 response = self.parent.error(
208- 'http', req, response, 302, 'Moved',
209- headers)
210-
211+ 'http', req, response, 302, 'Moved', headers)
212 else:
213 xmlrpc_response = xmlrpclib.dumps(
214 (req.get_full_url(),), methodresponse=True)
215
216=== modified file 'lib/lp/bugs/tests/test_bugtracker.py'
217--- lib/lp/bugs/tests/test_bugtracker.py 2010-03-26 14:16:21 +0000
218+++ lib/lp/bugs/tests/test_bugtracker.py 2010-04-12 09:54:39 +0000
219@@ -4,11 +4,14 @@
220 __metaclass__ = type
221
222 import unittest
223+from urllib2 import HTTPError, Request
224
225 from datetime import datetime, timedelta
226
227 from pytz import utc
228
229+import transaction
230+
231 from zope.security.proxy import removeSecurityProxy
232 from zope.testing.doctest import NORMALIZE_WHITESPACE, ELLIPSIS
233 from zope.testing.doctestunit import DocTestSuite
234@@ -18,13 +21,20 @@
235 from canonical.launchpad.ftests import login_person
236 from canonical.testing import LaunchpadFunctionalLayer
237
238+from lp.bugs.externalbugtracker import (
239+ BugTrackerConnectError, Mantis, MantisLoginHandler)
240 from lp.bugs.interfaces.bugtracker import BugTrackerType, IBugTracker
241+from lp.bugs.tests.externalbugtracker import Urlib2TransportTestHandler
242 from lp.testing import TestCaseWithFactory
243
244
245 class TestBugTracker(TestCaseWithFactory):
246 layer = LaunchpadFunctionalLayer
247
248+ def setUp(self):
249+ super(TestBugTracker, self).setUp()
250+ transaction.commit()
251+
252 def test_multi_product_constraints_observed(self):
253 """BugTrackers for which multi_product=True should return None
254 when no remote product is passed to getBugFilingURL().
255@@ -121,6 +131,93 @@
256 removeSecurityProxy(bug_message).remote_comment_id = 'brains'
257 self.failUnless(bug_tracker.watches_with_unpushed_comments.is_empty())
258
259+ def test_mantis_login_redirects(self):
260+ # The Mantis bug tracker needs a special HTTP redirect handler
261+ # in order to login in. Ensure that redirects to the page with
262+ # the login form are indeed changed to redirects the form submit
263+ # URL.
264+ handler = MantisLoginHandler()
265+ request = Request('http://mantis.example.com/some/path')
266+ # Let's pretend that Mantis sent a redirect request to the
267+ # login page.
268+ new_request = handler.redirect_request(
269+ request, None, 302, None, None,
270+ 'http://mantis.example.com/login_page.php'
271+ '?return=%2Fview.php%3Fid%3D3301')
272+ self.assertEqual(
273+ 'http://mantis.example.com/login.php?'
274+ 'username=guest&password=guest&return=%2Fview.php%3Fid%3D3301',
275+ new_request.get_full_url())
276+
277+ def test_mantis_login_redirect_handler_is_used(self):
278+ # Ensure that the special Mantis login handler is used
279+ # by the Mantis tracker
280+ tracker = Mantis('http://mantis.example.com')
281+ test_handler = Urlib2TransportTestHandler()
282+ test_handler.setRedirect('http://mantis.example.com/login_page.php'
283+ '?return=%2Fsome%2Fpage')
284+ opener = tracker._opener
285+ opener.add_handler(test_handler)
286+ opener.open('http://mantis.example.com/some/page')
287+ # We should now have two entries in the test handler's list
288+ # of visited URLs: The original URL we wanted to visit and the
289+ # URL changed by the MantisLoginHandler.
290+ self.assertEqual(
291+ ['http://mantis.example.com/some/page',
292+ 'http://mantis.example.com/login.php?'
293+ 'username=guest&password=guest&return=%2Fsome%2Fpage'],
294+ test_handler.accessed_urls)
295+
296+ def test_mantis_opener_can_handle_cookies(self):
297+ # Ensure that the OpenerDirector of the Mantis bug tracker
298+ # handles cookies.
299+ tracker = Mantis('http://mantis.example.com')
300+ test_handler = Urlib2TransportTestHandler()
301+ opener = tracker._opener
302+ opener.add_handler(test_handler)
303+ opener.open('http://mantis.example.com', '')
304+ cookies = list(tracker._cookie_handler.cookiejar)
305+ self.assertEqual(1, len(cookies))
306+ self.assertEqual('foo', cookies[0].name)
307+ self.assertEqual('bar', cookies[0].value)
308+
309+ def test_mantis_csv_file_http_500_error(self):
310+ # If a Mantis bug tracker returns a HTTP 500 error when the
311+ # URL for CSV data is accessed, we treat this as an
312+ # indication that we should screen scrape the bug data and
313+ # thus set csv_data to None.
314+ tracker = Mantis('http://mantis.example.com')
315+ test_handler = Urlib2TransportTestHandler()
316+ opener = tracker._opener
317+ opener.add_handler(test_handler)
318+ test_handler.setError(
319+ HTTPError(
320+ 'http://mantis.example.com/csv_export.php', 500,
321+ 'Internal Error', {}, None),
322+ 'http://mantis.example.com/csv_export.php')
323+ self.assertIs(None, tracker.csv_data)
324+
325+ def test_mantis_csv_file_other_http_errors(self):
326+ # If the Mantis server returns other HTTP errors than 500,
327+ # they appear as BugTrackerConnectErrors.
328+ tracker = Mantis('http://mantis.example.com')
329+ test_handler = Urlib2TransportTestHandler()
330+ opener = tracker._opener
331+ opener.add_handler(test_handler)
332+ test_handler.setError(
333+ HTTPError(
334+ 'http://mantis.example.com/csv_export.php', 503,
335+ 'Service Unavailable', {}, None),
336+ 'http://mantis.example.com/csv_export.php')
337+ self.assertRaises(BugTrackerConnectError, tracker._csv_data)
338+
339+ test_handler.setError(
340+ HTTPError(
341+ 'http://mantis.example.com/csv_export.php', 404,
342+ 'Not Found', {}, None),
343+ 'http://mantis.example.com/csv_export.php')
344+ self.assertRaises(BugTrackerConnectError, tracker._csv_data)
345+
346
347 def test_suite():
348 suite = unittest.TestSuite()