Merge ~cjwatson/launchpad:simplify-appserverlayer-browser into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: d505382bc5f0034288888bab9a206ecaae864869
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:simplify-appserverlayer-browser
Merge into: launchpad:master
Diff against target: 442 lines (+84/-123)
10 files modified
constraints.txt (+1/-0)
lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt (+5/-1)
lib/lp/services/webapp/tests/test_cookie_authentication.py (+3/-7)
lib/lp/services/webapp/tests/test_login.py (+17/-26)
lib/lp/services/webapp/tests/test_no_anonymous_session_cookies.py (+3/-7)
lib/lp/services/webservice/doc/launchpadlib.txt (+2/-1)
lib/lp/services/webservice/tests/test_doc.py (+3/-5)
lib/lp/testing/browser.py (+33/-76)
lib/lp/testing/layers.py (+16/-0)
setup.py (+1/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+374878@code.launchpad.net

Commit message

Refactor lp.testing.browser using zope.testbrowser.wsgi

This test browser instance is used for a few tests that use
AppServerLayer and make out-of-process connections to the test app
server via a test browser.

The old mechanize-based test browser is going away, so we need to do
something. A reasonably future-proof approach seems to be to use the
new WSGI test browser and fool it into connecting to the app server
using WSGIProxy2 (which in fact WebTest also uses in some situations).
We'll still need some slight adjustments here to upgrade to newer
versions of zope.testbrowser, but they should be much more manageable.

We do need to take some special care in a few places:

 * TestOpenIDReplayAttack can't pass in a custom mech_browser any more.
   For now, we switch off redirect handling entirely and follow
   redirections manually; this will need further rearrangements for
   zope.testbrowser >= 5.0.0.

 * Both the httplib and the requests clients offered by WSGIProxy2
   incorrectly fold multiple Set-Cookie headers in the response into
   one, joining them with commas; this causes test failures.
   Fortunately, the urllib3 client doesn't have this flaw, but we do
   need to take care to disable certificate verification.

 * The new WSGI test browser needs to be monkey-patched to allow talking
   to our various test hosts, as by default it only allows localhost,
   127.0.0.1, *.example.com, *.example.net, and *.example.org.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/constraints.txt b/constraints.txt
2index ada9fd0..6a36571 100644
3--- a/constraints.txt
4+++ b/constraints.txt
5@@ -362,6 +362,7 @@ waitress==1.3.1
6 WebOb==1.8.5
7 WebTest==2.0.33
8 wheel==0.29.0
9+WSGIProxy2==0.4.6
10 wsgiref==0.1.2
11 z3c.pt==2.2.3
12 z3c.ptcompat==0.5.7
13diff --git a/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt b/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
14index fbd726e..68477f6 100644
15--- a/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
16+++ b/lib/lp/services/webapp/tests/no-anonymous-session-cookies.txt
17@@ -54,9 +54,13 @@ time for browsers with bad system clocks.
18 >>> # XXX 2010-05-08 bac bug=577596 This work-around for the fact
19 >>> # that loggerhead is not running needs to be replaced with
20 >>> # something more robust and clear.
21+ >>> import warnings
22 >>> from urllib2 import HTTPError, URLError
23+ >>> from urllib3.exceptions import InsecureRequestWarning
24 >>> try:
25- ... browser.getControl('Log Out').click()
26+ ... with warnings.catch_warnings():
27+ ... warnings.simplefilter('ignore', InsecureRequestWarning)
28+ ... browser.getControl('Log Out').click()
29 ... except (HTTPError, URLError):
30 ... pass
31
32diff --git a/lib/lp/services/webapp/tests/test_cookie_authentication.py b/lib/lp/services/webapp/tests/test_cookie_authentication.py
33index 9b011fb..0dbe9dc 100644
34--- a/lib/lp/services/webapp/tests/test_cookie_authentication.py
35+++ b/lib/lp/services/webapp/tests/test_cookie_authentication.py
36@@ -1,4 +1,4 @@
37-# Copyright 2010 Canonical Ltd. This software is licensed under the
38+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
39 # GNU Affero General Public License version 3 (see the file LICENSE).
40
41 """Test harness for running the cookie-authentication.txt tests."""
42@@ -9,10 +9,7 @@ __all__ = []
43
44 import unittest
45
46-from lp.testing.browser import (
47- setUp,
48- tearDown,
49- )
50+from lp.testing.browser import setUp
51 from lp.testing.layers import AppServerLayer
52 from lp.testing.systemdocs import LayeredDocFileSuite
53
54@@ -23,6 +20,5 @@ def test_suite():
55 # page (+login), which cannot be used through the normal testbrowser that
56 # goes straight to zope's publication instead of making HTTP requests.
57 suite.addTest(LayeredDocFileSuite(
58- 'cookie-authentication.txt', setUp=setUp, tearDown=tearDown,
59- layer=AppServerLayer))
60+ 'cookie-authentication.txt', setUp=setUp, layer=AppServerLayer))
61 return suite
62diff --git a/lib/lp/services/webapp/tests/test_login.py b/lib/lp/services/webapp/tests/test_login.py
63index 2ae9c2e..eaa0d3e 100644
64--- a/lib/lp/services/webapp/tests/test_login.py
65+++ b/lib/lp/services/webapp/tests/test_login.py
66@@ -1,4 +1,4 @@
67-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
68+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
69 # GNU Affero General Public License version 3 (see the file LICENSE).
70 """Test harness for running the new-login.txt tests."""
71
72@@ -20,10 +20,8 @@ from datetime import (
73 import httplib
74 import unittest
75 import urllib
76-import urllib2
77 import urlparse
78
79-import mechanize
80 from openid.consumer.consumer import (
81 FAILURE,
82 SUCCESS,
83@@ -33,6 +31,7 @@ from openid.extensions import (
84 sreg,
85 )
86 from openid.yadis.discover import DiscoveryFailure
87+from six.moves.urllib.error import HTTPError
88 from testtools.matchers import (
89 Contains,
90 ContainsDict,
91@@ -76,7 +75,6 @@ from lp.testing import (
92 from lp.testing.browser import (
93 Browser,
94 setUp,
95- tearDown,
96 )
97 from lp.testing.fixture import ZopeViewReplacementFixture
98 from lp.testing.layers import (
99@@ -661,21 +659,6 @@ class TestOpenIDCallbackRedirects(TestCaseWithFactory):
100 urls_redirected_to = []
101
102
103-class MyHTTPRedirectHandler(mechanize.HTTPRedirectHandler):
104- """Custom HTTPRedirectHandler which stores the URLs redirected to."""
105-
106- def redirect_request(self, req, fp, code, msg, headers, newurl):
107- urls_redirected_to.append(newurl)
108- return mechanize.HTTPRedirectHandler.redirect_request(
109- self, req, fp, code, msg, headers, newurl)
110-
111-
112-class MyMechanizeBrowser(mechanize.Browser):
113- """Custom Browser which uses MyHTTPRedirectHandler to handle redirects."""
114- handler_classes = mechanize.Browser.handler_classes.copy()
115- handler_classes['_redirect'] = MyHTTPRedirectHandler
116-
117-
118 def fill_login_form_and_submit(browser, email_address):
119 assert browser.getControl(name='field.email') is not None, (
120 "We don't seem to be looking at a login form.")
121@@ -687,7 +670,8 @@ class TestOpenIDReplayAttack(TestCaseWithFactory):
122 layer = AppServerLayer
123
124 def test_replay_attacks_do_not_succeed(self):
125- browser = Browser(mech_browser=MyMechanizeBrowser())
126+ browser = Browser()
127+ browser.mech_browser.set_handle_redirect(False)
128 browser.open('%s/+login' % self.layer.appserver_root_url())
129 # On a JS-enabled browser this page would've been auto-submitted
130 # (thanks to the onload handler), but here we have to do it manually.
131@@ -695,7 +679,17 @@ class TestOpenIDReplayAttack(TestCaseWithFactory):
132 browser.getControl('Continue').click()
133
134 self.assertEqual('Login', browser.title)
135- fill_login_form_and_submit(browser, 'test@canonical.com')
136+ redirection = self.assertRaises(
137+ HTTPError,
138+ fill_login_form_and_submit, browser, 'test@canonical.com')
139+ self.assertEqual(httplib.FOUND, redirection.code)
140+ callback_url = redirection.hdrs['Location']
141+ self.assertIn('+openid-callback', callback_url)
142+ callback_redirection = self.assertRaises(
143+ HTTPError, browser.open, callback_url)
144+ self.assertEqual(
145+ httplib.TEMPORARY_REDIRECT, callback_redirection.code)
146+ browser.open(callback_redirection.hdrs['Location'])
147 login_status = extract_text(
148 find_tag_by_id(browser.contents, 'logincontrol'))
149 self.assertIn('Sample Person (name12)', login_status)
150@@ -704,9 +698,6 @@ class TestOpenIDReplayAttack(TestCaseWithFactory):
151 # was used to complete the authentication and open it on a different
152 # browser with a fresh set of cookies.
153 replay_browser = Browser()
154- [callback_url] = [
155- url for url in urls_redirected_to if '+openid-callback' in url]
156- self.assertIsNot(None, callback_url)
157 replay_browser.open(callback_url)
158 login_status = extract_text(
159 find_tag_by_id(replay_browser.contents, 'logincontrol'))
160@@ -741,7 +732,7 @@ class TestMissingServerShowsNiceErrorPage(TestCase):
161 fixture.replacement = OpenIDLoginThatFailsDiscovery
162 self.useFixture(fixture)
163 browser = TestBrowser()
164- self.assertRaises(urllib2.HTTPError,
165+ self.assertRaises(HTTPError,
166 browser.open, 'http://launchpad.test/+login')
167 self.assertEqual('503 Service Unavailable',
168 browser.headers.get('status'))
169@@ -946,5 +937,5 @@ def test_suite():
170 suite = unittest.TestSuite()
171 suite.addTest(unittest.TestLoader().loadTestsFromName(__name__))
172 suite.addTest(LayeredDocFileSuite(
173- 'login.txt', setUp=setUp, tearDown=tearDown, layer=AppServerLayer))
174+ 'login.txt', setUp=setUp, layer=AppServerLayer))
175 return suite
176diff --git a/lib/lp/services/webapp/tests/test_no_anonymous_session_cookies.py b/lib/lp/services/webapp/tests/test_no_anonymous_session_cookies.py
177index 15c1446..c1f18ab 100644
178--- a/lib/lp/services/webapp/tests/test_no_anonymous_session_cookies.py
179+++ b/lib/lp/services/webapp/tests/test_no_anonymous_session_cookies.py
180@@ -1,4 +1,4 @@
181-# Copyright 2010 Canonical Ltd. This software is licensed under the
182+# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
183 # GNU Affero General Public License version 3 (see the file LICENSE).
184
185 """Test harness for running the no-anonymous-session-cookies.txt tests."""
186@@ -9,10 +9,7 @@ __all__ = []
187
188 import unittest
189
190-from lp.testing.browser import (
191- setUp,
192- tearDown,
193- )
194+from lp.testing.browser import setUp
195 from lp.testing.layers import AppServerLayer
196 from lp.testing.systemdocs import LayeredDocFileSuite
197
198@@ -23,6 +20,5 @@ def test_suite():
199 # page (+login), which cannot be used through the normal testbrowser that
200 # goes straight to zope's publication instead of making HTTP requests.
201 suite.addTest(LayeredDocFileSuite(
202- 'no-anonymous-session-cookies.txt', setUp=setUp, tearDown=tearDown,
203- layer=AppServerLayer))
204+ 'no-anonymous-session-cookies.txt', setUp=setUp, layer=AppServerLayer))
205 return suite
206diff --git a/lib/lp/services/webservice/doc/launchpadlib.txt b/lib/lp/services/webservice/doc/launchpadlib.txt
207index 5edbf92..c3c0cde 100644
208--- a/lib/lp/services/webservice/doc/launchpadlib.txt
209+++ b/lib/lp/services/webservice/doc/launchpadlib.txt
210@@ -3,7 +3,8 @@
211 Just to show that we're actually talking to the appserver, first check to see
212 if a specific user exists...
213
214- >>> browser = Browser('foo.bar@canonical.com:test')
215+ >>> browser = Browser()
216+ >>> browser.addHeader('Authorization', 'Basic foo.bar@canonical.com:test')
217 >>> from lp.testing.layers import BaseLayer
218 >>> root_url = BaseLayer.appserver_root_url()
219 >>> browser.open(root_url)
220diff --git a/lib/lp/services/webservice/tests/test_doc.py b/lib/lp/services/webservice/tests/test_doc.py
221index cd5acc3..ce59e0e 100644
222--- a/lib/lp/services/webservice/tests/test_doc.py
223+++ b/lib/lp/services/webservice/tests/test_doc.py
224@@ -1,4 +1,4 @@
225-# Copyright 2011 Canonical Ltd. This software is licensed under the
226+# Copyright 2011-2019 Canonical Ltd. This software is licensed under the
227 # GNU Affero General Public License version 3 (see the file LICENSE).
228
229 """
230@@ -37,13 +37,11 @@ special = {
231 # properly isolates the database between tests.
232 'launchpadlib.txt': LayeredDocFileSuite(
233 '../doc/launchpadlib.txt',
234- layer=AppServerLayer,
235- setUp=browser.setUp, tearDown=browser.tearDown,),
236+ layer=AppServerLayer, setUp=browser.setUp),
237 'launchpadlib.txt-2': LayeredDocFileSuite(
238 '../doc/launchpadlib.txt',
239 id_extensions=['launchpadlib.txt-2'],
240- layer=AppServerLayer,
241- setUp=browser.setUp, tearDown=browser.tearDown,),
242+ layer=AppServerLayer, setUp=browser.setUp),
243 }
244
245
246diff --git a/lib/lp/testing/browser.py b/lib/lp/testing/browser.py
247index 9df40df..7f4284b 100644
248--- a/lib/lp/testing/browser.py
249+++ b/lib/lp/testing/browser.py
250@@ -1,4 +1,4 @@
251-# Copyright 2009 Canonical Ltd. This software is licensed under the
252+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
253 # GNU Affero General Public License version 3 (see the file LICENSE).
254
255 """A real, socket connecting browser.
256@@ -7,30 +7,24 @@ This browser performs actual socket connections to a real HTTP server. This
257 is used in tests which utilize the AppServerLayer to run the app server in a
258 child process. The Zope testing browser fakes its connections in-process, so
259 that's not good enough.
260-
261-The browser provided here extends `zope.app.testing.testbrowser.Browser` by
262-providing a close() method that delegates to the underlying mechanize browser,
263-and it tracks all Browser instances to ensure that they are closed. This
264-latter prevents open socket leaks even when the doctest doesn't explicitly
265-close or delete the browser instance.
266 """
267-from lazr.uri._uri import URI
268-
269
270 __metaclass__ = type
271 __all__ = [
272- 'Browser',
273 'setUp',
274- 'tearDown',
275 ]
276
277+import ssl
278
279-import base64
280-import urllib2
281-import weakref
282-
283+from lazr.uri import URI
284 import transaction
285-from zope.testbrowser.browser import Browser as _Browser
286+from urllib3 import PoolManager
287+from wsgiproxy.proxies import TransparentProxy
288+from wsgiproxy.urllib3_client import HttpClient
289+from zope.testbrowser.wsgi import (
290+ AuthorizationMiddleware,
291+ Browser as _Browser,
292+ )
293
294 from lp.testing.pages import (
295 extract_text,
296@@ -40,65 +34,36 @@ from lp.testing.pages import (
297 )
298
299
300-class SocketClosingOnErrorHandler(urllib2.BaseHandler):
301- """A handler that ensures that the socket gets closed on errors.
302+class TransactionMiddleware:
303+ """Middleware to commit the current transaction before the test.
304
305- Interestingly enough <wink> without this, a 404 will cause mechanize to
306- leak open socket objects.
307+ This is like `zope.app.wsgi.TransactionMiddleware`, but avoids ZODB.
308 """
309- # Ensure that this handler is the first default error handler to execute,
310- # because right after this, the built-in default handler will raise an
311- # exception.
312- handler_order = 0
313-
314- # Copy signature from base class.
315- def http_error_default(self, req, fp, code, msg, hdrs):
316- """See `urllib2.BaseHandler`."""
317- fp.close()
318
319+ def __init__(self, app):
320+ self.app = app
321
322-# To ensure that the mechanize browser doesn't leak socket connections past
323-# the end of the test, we manage a set of weak references to live browser
324-# objects. The layer can then call a function here to ensure that all live
325-# browsers get properly closed.
326-_live_browser_set = set()
327+ def __call__(self, environ, start_response):
328+ transaction.commit()
329+ for entry in self.app(environ, start_response):
330+ yield entry
331
332
333 class Browser(_Browser):
334- """A browser subclass that knows about basic auth."""
335-
336- def __init__(self, auth=None, mech_browser=None):
337- super(Browser, self).__init__(mech_browser=mech_browser)
338- # We have to add the error handler to the mechanize browser underlying
339- # the Zope browser, because it's the former that's actually doing all
340- # the work.
341- self.mech_browser.add_handler(SocketClosingOnErrorHandler())
342- if auth:
343- # Unlike the higher level Zope test browser, we actually have to
344- # encode the basic auth information.
345- userpass = base64.b64encode(auth)
346- self.addHeader('Authorization', 'Basic ' + userpass)
347- _live_browser_set.add(weakref.ref(self, self._refclose))
348-
349- def _refclose(self, obj):
350- """For weak reference cleanup."""
351- self.close()
352-
353- def close(self):
354- """Yay! Zope browsers don't have a close() method."""
355- self.mech_browser.close()
356-
357- def _changed(self):
358- """Ensure the current transaction is committed.
359-
360- Because this browser is used in the AppServerLayer where it talks
361- real-HTTP to a child process, we need to ensure that the parent
362- process also gets its current transaction in sync with the child's
363- changes. The easiest way to do that is to just commit the current
364- transaction.
365- """
366- super(Browser, self)._changed()
367- transaction.commit()
368+
369+ def __init__(self, url=None, wsgi_app=None):
370+ if wsgi_app is None:
371+ # urllib3 is carefully-chosen: both the httplib and requests
372+ # clients incorrectly comma-join multiple Set-Cookie headers, at
373+ # least on Python 2.7, which causes failures in some of the
374+ # +login tests. However, we have to go to a bit of effort to
375+ # disable certificate verification to avoid problems with e.g.
376+ # +logout redirecting to https://bazaar.launchpad.test/+logout.
377+ client = HttpClient(pool=PoolManager(10, cert_reqs=ssl.CERT_NONE))
378+ wsgi_app = AuthorizationMiddleware(
379+ TransactionMiddleware(
380+ TransparentProxy(client=client)))
381+ super(Browser, self).__init__(url=url, wsgi_app=wsgi_app)
382
383 @property
384 def vhost(self):
385@@ -124,11 +89,3 @@ def setUp(test):
386 test.globs['find_main_content'] = find_main_content
387 test.globs['print_feedback_messages'] = print_feedback_messages
388 test.globs['extract_text'] = extract_text
389-
390-
391-def tearDown(test):
392- """Tear down appserver tests."""
393- for ref in _live_browser_set:
394- browser = ref()
395- if browser is not None:
396- browser.close()
397diff --git a/lib/lp/testing/layers.py b/lib/lp/testing/layers.py
398index 3ec422f..22a510f 100644
399--- a/lib/lp/testing/layers.py
400+++ b/lib/lp/testing/layers.py
401@@ -1085,6 +1085,20 @@ class FunctionalLayer(BaseLayer):
402 root = fs.connection.root()
403 root[ZopePublication.root_name] = MockRootFolder()
404
405+ # Allow the WSGI test browser to talk to our various test hosts.
406+ def assert_allowed_host(self):
407+ host = self.host
408+ if ':' in host:
409+ host = host.split(':')[0]
410+ if host == 'localhost' or host.endswith('.test'):
411+ return
412+ self._allowed = False
413+
414+ FunctionalLayer._testbrowser_allowed = MonkeyPatch(
415+ 'zope.testbrowser.wsgi.WSGIConnection.assert_allowed_host',
416+ assert_allowed_host)
417+ FunctionalLayer._testbrowser_allowed.setUp()
418+
419 # Should be impossible, as the CA cannot be unloaded. Something
420 # mighty nasty has happened if this is triggered.
421 if not is_ca_available():
422@@ -1094,6 +1108,8 @@ class FunctionalLayer(BaseLayer):
423 @classmethod
424 @profiled
425 def testTearDown(cls):
426+ FunctionalLayer._testbrowser_allowed.cleanUp()
427+
428 # Should be impossible, as the CA cannot be unloaded. Something
429 # mighty nasty has happened if this is triggered.
430 if not is_ca_available():
431diff --git a/setup.py b/setup.py
432index 0b8d56b..90c4424 100644
433--- a/setup.py
434+++ b/setup.py
435@@ -233,6 +233,7 @@ setup(
436 'txpkgupload',
437 'virtualenv-tools3',
438 'wadllib',
439+ 'WSGIProxy2',
440 'z3c.pt',
441 'z3c.ptcompat',
442 'zc.zservertracelog',

Subscribers

People subscribed via source and target branches

to status/vote changes: