Merge ~cjwatson/launchpad:test-notification-unittest into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 5321fefb88fe518ff55dc694d70367c0322395ff
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:test-notification-unittest
Merge into: launchpad:master
Diff against target: 625 lines (+274/-258)
4 files modified
dev/null (+0/-88)
lib/lp/services/webapp/notifications.py (+2/-135)
lib/lp/services/webapp/tests/test_doc.py (+0/-5)
lib/lp/services/webapp/tests/test_notifications.py (+272/-30)
Reviewer Review Type Date Requested Status
Andrey Fedoseev (community) Approve
Review via email: mp+426667@code.launchpad.net

Commit message

Rewrite notification doctests as unit tests

Description of the change

The `zope.container` removal in c4be9ccd819340ce13c04b84889ba7e536da1975 meant that the notifications doctests didn't clean up after themselves properly any more due to using `provideAdapter` directly rather than something like `ZopeAdapterFixture`, causing test isolation errors. Rewrite these doctests as unit tests, making it easier to avoid this problem using fixtures.

To post a comment you must log in.
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/services/webapp/doc/notification-text-escape.rst b/lib/lp/services/webapp/doc/notification-text-escape.rst
2deleted file mode 100644
3index 4c13fb6..0000000
4--- a/lib/lp/services/webapp/doc/notification-text-escape.rst
5+++ /dev/null
6@@ -1,88 +0,0 @@
7-Notification Text Escaping
8-==========================
9-
10-There are a number of user actions that may generate on-screen
11-notifications, such as moving a bug or deleting a branch. Some of
12-these notifications display potentially unsafe text that is obtained
13-from the user. In order to prevent a cross-site-scripting attack,
14-HTML characters in notifications must be escaped. However, there are
15-special cases where notifications from known safe sources must be
16-allowed to pass HTML through. This document exercises these
17-mechanisms.
18-
19- >>> from lp.services.webapp.notifications import (
20- ... NotificationResponse, NotificationRequest)
21- >>> def new_response():
22- ... response = NotificationResponse()
23- ... request = NotificationRequest()
24- ... request.response = response
25- ... response._request = request
26- ... return response
27- >>>
28-
29-Plain text passed into the object's addNotification() method is
30-unchanged:
31-
32- >>> response = new_response()
33- >>> response.addNotification('clean')
34- >>> for notification in response.notifications:
35- ... print(notification.message)
36- clean
37-
38-But text containing markup is CGI-escaped:
39-
40- >>> response = new_response()
41- >>> response.addNotification(u'<br/>dirty')
42- >>> for notification in response.notifications:
43- ... print(notification.message)
44- &lt;br/&gt;dirty
45-
46-
47-If the object passed to addNotification() publishes the
48-IStructuredString interface, then a string will be returned with the
49-appropriate sections escaped and unescaped.
50-
51- >>> from lp.services.webapp.interfaces import IStructuredString
52- >>> from lp.services.webapp.escaping import structured
53- >>> msg = u'<b>%(escaped)s</b>'
54- >>> structured_text = structured(msg, escaped=u'<br/>foo')
55- >>> IStructuredString.providedBy(structured_text)
56- True
57- >>> print(structured_text.escapedtext)
58- <b>&lt;br/&gt;foo</b>
59-
60- >>> response = new_response()
61- >>> response.addNotification(structured_text)
62- >>> for notification in response.notifications:
63- ... print(notification.message)
64- <b>&lt;br/&gt;foo</b>
65-
66-Passing an object to addNotification() that is an instance of
67-zope.i18n.Message will be escaped in the same
68-manner as raw text.
69-
70- >>> import zope.i18n
71- >>> msgtxt = zope.i18n.Message(u'<br/>foo')
72- >>> response = new_response()
73- >>> response.addNotification(msgtxt)
74- >>> for notification in response.notifications:
75- ... print(notification.message)
76- &lt;br/&gt;foo
77-
78-To pass internationalized text that contains markup, one may call
79-structured() directly with an internationalized object. structured()
80-performs the translation and substitution, and the resulting object
81-may then be passed to addNotification().
82-
83- >>> from lp import _
84- >>> msgid = _(u'<good/>%(evil)s')
85- >>> escapee = '<evil/>'
86- >>> text = structured(msgid, evil=escapee)
87- >>> print(text.escapedtext)
88- <good/>&lt;evil/&gt;
89-
90- >>> response = new_response()
91- >>> response.addNotification(text)
92- >>> for notification in response.notifications:
93- ... print(notification.message)
94- <good/>&lt;evil/&gt;
95diff --git a/lib/lp/services/webapp/notifications.py b/lib/lp/services/webapp/notifications.py
96index 9e76431..12a4516 100644
97--- a/lib/lp/services/webapp/notifications.py
98+++ b/lib/lp/services/webapp/notifications.py
99@@ -41,35 +41,7 @@ class NotificationRequest:
100 """NotificationRequest extracts notifications to display to the user
101 from the request and session
102
103- It is designed to be mixed in with an IBrowserRequest
104-
105- By default, there are no notifications
106-
107- >>> request = NotificationRequest()
108- >>> len(request.notifications)
109- 0
110- >>> INotificationRequest.providedBy(request)
111- True
112-
113- >>> request = NotificationRequest()
114- >>> session = ISession(request)[SESSION_KEY]
115- >>> notifications = NotificationList()
116- >>> session['notifications'] = notifications
117- >>> notifications.append(Notification(0, 'Fnord'))
118- >>> for notification in request.notifications:
119- ... print(notification.message)
120- Fnord
121-
122- Note that NotificationRequest.notifications also returns any notifications
123- that have been added so far in this request, making it the single source
124- you need to interogate to display notifications to the user.
125-
126- >>> response = INotificationResponse(request)
127- >>> response.addNotification('Aargh')
128- >>> for notification in request.notifications:
129- ... print(notification.message)
130- Fnord
131- Aargh
132+ It is designed to be mixed in with an IBrowserRequest.
133 """
134
135 @property
136@@ -86,84 +58,6 @@ class NotificationResponse:
137
138 It needs to be mixed in with an IHTTPApplicationResponse so its redirect
139 method intercepts the default behaviour.
140-
141- >>> class MyNotificationResponse(NotificationResponse, MockResponse):
142- ... pass
143- >>> response = MyNotificationResponse()
144- >>> INotificationResponse.providedBy(response)
145- True
146- >>> request = NotificationRequest()
147- >>> request.response = response
148- >>> response._request = request
149- >>> request.principal = None # full IRequests are zope.security
150- ... # participations, and NotificationResponse.redirect expects a
151- ... # principal, as in the full IRequest interface.
152-
153- >>> len(response.notifications)
154- 0
155-
156- >>> response.addNotification("something")
157- >>> len(response.notifications)
158- 1
159-
160- >>> response.removeAllNotifications()
161- >>> len(response.notifications)
162- 0
163-
164- >>> msg = structured("<b>%(escaped)s</b>", escaped="<Fnord>")
165- >>> response.addNotification(msg)
166-
167- >>> response.addNotification("Whatever", BrowserNotificationLevel.DEBUG)
168- >>> response.addDebugNotification('Debug')
169- >>> response.addInfoNotification('Info')
170- >>> response.addWarningNotification('Warning')
171-
172- And an odd one to test Bug #54987
173-
174- >>> from lp import _
175- >>> response.addErrorNotification(_('Error${value}', mapping={'value':''}))
176-
177- >>> INotificationList.providedBy(response.notifications)
178- True
179-
180- >>> for notification in response.notifications:
181- ... print("%d -- %s" % (notification.level, notification.message))
182- 20 -- <b>&lt;Fnord&gt;</b>
183- 10 -- Whatever
184- 10 -- Debug
185- 20 -- Info
186- 30 -- Warning
187- 40 -- Error
188-
189- >>> response.redirect("http://example.com?foo=bar")
190- 302: http://example.com?foo=bar
191-
192- Once redirect has been called, any notifications that have been set
193- are stored in the session
194-
195- >>> for notification in ISession(request)[SESSION_KEY]['notifications']:
196- ... print("%d -- %s" % (notification.level, notification.message))
197- ... break
198- 20 -- <b>&lt;Fnord&gt;</b>
199-
200- If there are no notifications, the session is not touched. This ensures
201- that we don't needlessly burden the session storage.
202-
203- >>> response = MyNotificationResponse()
204- >>> request = NotificationRequest()
205- >>> request.response = response
206- >>> response._request = request
207-
208- >>> session = ISession(request)[SESSION_KEY]
209- >>> del ISession(request)[SESSION_KEY]['notifications']
210- >>> 'notifications' in session
211- False
212- >>> len(response.notifications)
213- 0
214- >>> response.redirect("http://example.com")
215- 302: http://example.com
216- >>> 'notifications' in session
217- False
218 """
219
220 # We stuff our Notifications here until we are sure we should persist it
221@@ -245,34 +139,7 @@ class NotificationResponse:
222
223 @implementer(INotificationList)
224 class NotificationList(list):
225- """
226- Collection of INotification instances with a creation date
227-
228- >>> notifications = NotificationList()
229- >>> notifications.created <= datetime.utcnow()
230- True
231- >>> notifications[0]
232- Traceback (most recent call last):
233- ...
234- IndexError: list index out of range
235-
236- >>> debug = BrowserNotificationLevel.DEBUG
237- >>> error = BrowserNotificationLevel.ERROR
238- >>> notifications.append(Notification(error, u'An error'))
239- >>> notifications.append(Notification(debug, u'A debug message'))
240- >>> for notification in notifications:
241- ... print(notification.message)
242- An error
243- A debug message
244-
245- The __getitem__ method is also overloaded to allow TALES expressions
246- to easily retrieve lists of notifications that match a particular
247- notification level.
248-
249- >>> for notification in notifications['debug']:
250- ... print(notification.message)
251- A debug message
252- """
253+ """Collection of INotification instances with a creation date."""
254
255 created = None
256
257diff --git a/lib/lp/services/webapp/tests/test_doc.py b/lib/lp/services/webapp/tests/test_doc.py
258index fa82b88..b8a2e9c 100644
259--- a/lib/lp/services/webapp/tests/test_doc.py
260+++ b/lib/lp/services/webapp/tests/test_doc.py
261@@ -8,7 +8,6 @@ Run the doctests and pagetests.
262 import os
263
264 from lp.services.testing import build_test_suite
265-from lp.services.webapp.tests import test_notifications
266 from lp.testing.layers import (
267 FunctionalLayer,
268 LaunchpadFunctionalLayer,
269@@ -29,10 +28,6 @@ special = {
270 '../doc/canonical_url.rst',
271 setUp=setUp, tearDown=tearDown,
272 layer=FunctionalLayer,),
273- 'notification-text-escape.rst': LayeredDocFileSuite(
274- '../doc/notification-text-escape.rst',
275- setUp=test_notifications.setUp,
276- stdout_logging=False, layer=FunctionalLayer),
277 'test_adapter.rst': LayeredDocFileSuite(
278 '../doc/test_adapter.rst',
279 setUp=setGlobs,
280diff --git a/lib/lp/services/webapp/tests/test_notifications.py b/lib/lp/services/webapp/tests/test_notifications.py
281index fda9346..434dac1 100644
282--- a/lib/lp/services/webapp/tests/test_notifications.py
283+++ b/lib/lp/services/webapp/tests/test_notifications.py
284@@ -3,10 +3,13 @@
285
286 """Module docstring goes here."""
287
288-from doctest import DocTestSuite
289-import unittest
290+from datetime import datetime
291
292-from zope.component import provideAdapter
293+from testtools.matchers import (
294+ MatchesListwise,
295+ MatchesStructure,
296+ )
297+from zope.i18n import Message
298 from zope.interface import implementer
299 from zope.publisher.browser import TestRequest
300 from zope.publisher.interfaces.browser import IBrowserRequest
301@@ -16,12 +19,24 @@ from zope.session.interfaces import (
302 ISessionData,
303 )
304
305+from lp import _
306 from lp.services.webapp.escaping import structured
307 from lp.services.webapp.interfaces import (
308+ BrowserNotificationLevel,
309+ INotificationList,
310 INotificationRequest,
311 INotificationResponse,
312+ IStructuredString,
313+ )
314+from lp.services.webapp.notifications import (
315+ Notification,
316+ NotificationList,
317+ NotificationRequest,
318+ NotificationResponse,
319+ SESSION_KEY,
320 )
321-from lp.services.webapp.notifications import NotificationResponse
322+from lp.testing import TestCase
323+from lp.testing.fixture import ZopeAdapterFixture
324 from lp.testing.layers import FunctionalLayer
325
326
327@@ -48,11 +63,20 @@ class MockSessionData(dict):
328 @implementer(IHTTPApplicationResponse)
329 class MockHTTPApplicationResponse:
330
331+ def __init__(self, *args, **kwargs):
332+ super().__init__(*args, **kwargs)
333+ self.redirect_log = []
334+
335 def redirect(self, location, status=None, trusted=False):
336- """Just report the redirection to the doctest"""
337+ """Just log the redirection."""
338 if status is None:
339 status = 302
340- print('%d: %s' % (status, location))
341+ self.redirect_log.append((status, location))
342+
343+
344+class MyNotificationResponse(
345+ NotificationResponse, MockHTTPApplicationResponse):
346+ pass
347
348
349 def adaptNotificationRequestToResponse(request):
350@@ -65,33 +89,251 @@ def adaptNotificationRequestToResponse(request):
351 return response
352
353
354-def setUp(test):
355- mock_session = MockSession()
356- provideAdapter(lambda x: mock_session, (INotificationRequest,), ISession)
357- provideAdapter(lambda x: mock_session, (INotificationResponse,), ISession)
358- provideAdapter(
359- adaptNotificationRequestToResponse,
360- (INotificationRequest,), INotificationResponse)
361+class TestNotificationsBase:
362+
363+ def setUp(self):
364+ super().setUp()
365+
366+ mock_session = MockSession()
367+ self.useFixture(ZopeAdapterFixture(
368+ lambda x: mock_session, (INotificationRequest,), ISession))
369+ self.useFixture(ZopeAdapterFixture(
370+ lambda x: mock_session, (INotificationResponse,), ISession))
371+ self.useFixture(ZopeAdapterFixture(
372+ adaptNotificationRequestToResponse,
373+ (INotificationRequest,), INotificationResponse))
374+
375+ mock_browser_request = TestRequest()
376+ self.useFixture(ZopeAdapterFixture(
377+ lambda x: mock_browser_request, (INotificationRequest,),
378+ IBrowserRequest))
379+
380+
381+class TestNotificationRequest(TestNotificationsBase, TestCase):
382+
383+ layer = FunctionalLayer
384+
385+ def test_provides_interface(self):
386+ request = NotificationRequest()
387+ self.assertTrue(INotificationRequest.providedBy(request))
388+
389+ def test_no_notifications_by_default(self):
390+ # By default, there are no notifications.
391+ request = NotificationRequest()
392+ self.assertEqual(0, len(request.notifications))
393+
394+ def test_single_notification(self):
395+ request = NotificationRequest()
396+ session = ISession(request)[SESSION_KEY]
397+ notifications = NotificationList()
398+ session["notifications"] = notifications
399+ notifications.append(Notification(0, "Fnord"))
400+ self.assertThat(
401+ request.notifications,
402+ MatchesListwise([MatchesStructure.byEquality(message="Fnord")]))
403+
404+ def test_multiple_notifications(self):
405+ # NotificationRequest.notifications also returns any notifications
406+ # that have been added so far in this request, making it the single
407+ # source you need to interrogate to display notifications to the
408+ # user.
409+ request = NotificationRequest()
410+ session = ISession(request)[SESSION_KEY]
411+ notifications = NotificationList()
412+ session["notifications"] = notifications
413+ notifications.append(Notification(0, "Fnord"))
414+ response = INotificationResponse(request)
415+ response.addNotification("Aargh")
416+ self.assertThat(
417+ request.notifications,
418+ MatchesListwise([
419+ MatchesStructure.byEquality(message="Fnord"),
420+ MatchesStructure.byEquality(message="Aargh"),
421+ ]))
422+
423+
424+class TestNotificationResponse(TestNotificationsBase, TestCase):
425+
426+ layer = FunctionalLayer
427+
428+ def test_provides_interface(self):
429+ response = NotificationResponse()
430+ self.assertTrue(INotificationResponse.providedBy(response))
431+
432+ def makeNotificationResponse(self):
433+ # Return a `NotificationResponse` linked to a `NotificationRequest`.
434+ response = MyNotificationResponse()
435+ request = NotificationRequest()
436+ request.response = response
437+ response._request = request
438+ # Full IRequests are zope.security participations, and
439+ # NotificationResponse.redirect expects a principal, as in the full
440+ # IRequest interface.
441+ request.principal = None
442+ return response
443+
444+ def test_no_notifications(self):
445+ response = self.makeNotificationResponse()
446+ self.assertEqual(0, len(response.notifications))
447+
448+ def test_addNotification(self):
449+ response = self.makeNotificationResponse()
450+ response.addNotification("something")
451+ self.assertEqual(1, len(response.notifications))
452+
453+ def test_removeAllNotifications(self):
454+ response = self.makeNotificationResponse()
455+ response.addNotification("something")
456+ response.removeAllNotifications()
457+ self.assertEqual(0, len(response.notifications))
458+
459+ def test_many_notifications(self):
460+ response = self.makeNotificationResponse()
461+ msg = structured("<b>%(escaped)s</b>", escaped="<Fnord>")
462+ response.addNotification(msg)
463+ response.addNotification("Whatever", BrowserNotificationLevel.DEBUG)
464+ response.addDebugNotification("Debug")
465+ response.addInfoNotification("Info")
466+ response.addWarningNotification("Warning")
467+ # And an odd one to test
468+ # https://bugs.launchpad.net/launchpad/+bug/54987
469+ response.addErrorNotification(
470+ _("Error${value}", mapping={"value": ""}))
471+
472+ self.assertTrue(INotificationList.providedBy(response.notifications))
473+ self.assertThat(response.notifications, MatchesListwise([
474+ MatchesStructure.byEquality(
475+ level=20, message="<b>&lt;Fnord&gt;</b>"),
476+ MatchesStructure.byEquality(level=10, message="Whatever"),
477+ MatchesStructure.byEquality(level=10, message="Debug"),
478+ MatchesStructure.byEquality(level=20, message="Info"),
479+ MatchesStructure.byEquality(level=30, message="Warning"),
480+ MatchesStructure.byEquality(level=40, message="Error"),
481+ ]))
482+
483+ def test_no_notifications_session_untouched(self):
484+ # If there are no notifications, the session is not touched. This
485+ # ensures that we don't needlessly burden the session storage.
486+ response = self.makeNotificationResponse()
487+ session = ISession(response._request)[SESSION_KEY]
488+ self.assertNotIn("notifications", session)
489+ self.assertEqual(0, len(response.notifications))
490+ response.redirect("http://example.com")
491+ self.assertEqual([(302, "http://example.com")], response.redirect_log)
492+ self.assertNotIn("notifications", session)
493+
494+
495+class TestNotificationResponseTextEscaping(TestNotificationsBase, TestCase):
496+ """Test notification text escaping.
497+
498+ There are a number of user actions that may generate on-screen
499+ notifications, such as moving a bug or deleting a branch. Some of these
500+ notifications display potentially unsafe text that is obtained from the
501+ user. In order to prevent a cross-site-scripting attack, HTML
502+ characters in notifications must be escaped. However, there are special
503+ cases where notifications from known safe sources must be allowed to
504+ pass HTML through.
505+ """
506+
507+ layer = FunctionalLayer
508+
509+ def makeNotificationResponse(self):
510+ # Return a `NotificationResponse` linked to a `NotificationRequest`.
511+ response = MyNotificationResponse()
512+ request = NotificationRequest()
513+ request.response = response
514+ response._request = request
515+ return response
516+
517+ def test_addNotification_plain_text(self):
518+ # Plain text is left unchanged.
519+ response = self.makeNotificationResponse()
520+ response.addNotification("clean")
521+ self.assertThat(
522+ response.notifications,
523+ MatchesListwise([MatchesStructure.byEquality(message="clean")]))
524+
525+ def test_addNotification_html(self):
526+ # HTML is escaped.
527+ response = self.makeNotificationResponse()
528+ response.addNotification("<br/>dirty")
529+ self.assertThat(
530+ response.notifications,
531+ MatchesListwise([
532+ MatchesStructure.byEquality(message="&lt;br/&gt;dirty"),
533+ ]))
534+
535+ def test_addNotification_structured_string(self):
536+ # If the object passed to `addNotification` publishes the
537+ # `IStructuredString` interface, then a string is returned with the
538+ # appropriate sections escaped and unescaped.
539+ structured_text = structured("<b>%(escaped)s</b>", escaped="<br/>foo")
540+ self.assertTrue(IStructuredString.providedBy(structured_text))
541+ self.assertEqual("<b>&lt;br/&gt;foo</b>", structured_text.escapedtext)
542+ response = self.makeNotificationResponse()
543+ response.addNotification(structured_text)
544+ self.assertThat(
545+ response.notifications,
546+ MatchesListwise([
547+ MatchesStructure.byEquality(message="<b>&lt;br/&gt;foo</b>"),
548+ ]))
549+
550+ def test_addNotification_i18n_message(self):
551+ # An instance of `zope.i18n.Message` is escaped in the same manner
552+ # as raw text.
553+ response = self.makeNotificationResponse()
554+ response.addNotification(Message("<br/>foo"))
555+ self.assertThat(
556+ response.notifications,
557+ MatchesListwise([
558+ MatchesStructure.byEquality(message="&lt;br/&gt;foo"),
559+ ]))
560+
561+ def test_addNotification_structured_internationalized(self):
562+ # To pass internationalized text that contains markup, one may call
563+ # `structured` directly with an internationalized object.
564+ # `structured` performs the translation and substitution, and the
565+ # resulting object may then be passed to `addNotification`.
566+ structured_text = structured(_("<good/>%(evil)s"), evil="<evil/>")
567+ self.assertEqual("<good/>&lt;evil/&gt;", structured_text.escapedtext)
568+ response = self.makeNotificationResponse()
569+ response.addNotification(structured_text)
570+ self.assertThat(
571+ response.notifications,
572+ MatchesListwise([
573+ MatchesStructure.byEquality(message="<good/>&lt;evil/&gt;"),
574+ ]))
575
576- mock_browser_request = TestRequest()
577- provideAdapter(
578- lambda x: mock_browser_request, (INotificationRequest,),
579- IBrowserRequest)
580
581- test.globs['MockResponse'] = MockHTTPApplicationResponse
582- test.globs['structured'] = structured
583+class TestNotificationList(TestNotificationsBase, TestCase):
584
585+ layer = FunctionalLayer
586
587-def test_suite():
588- suite = unittest.TestSuite()
589- doctest_suite = DocTestSuite(
590- 'lp.services.webapp.notifications',
591- setUp=setUp,
592- )
593- doctest_suite.layer = FunctionalLayer
594- suite.addTest(doctest_suite)
595- return suite
596+ def test_empty(self):
597+ notifications = NotificationList()
598+ self.assertLessEqual(notifications.created, datetime.utcnow())
599+ self.assertRaises(IndexError, notifications.__getitem__, 0)
600
601+ def test_iterate(self):
602+ notifications = NotificationList()
603+ notifications.append(
604+ Notification(BrowserNotificationLevel.ERROR, "An error"))
605+ notifications.append(
606+ Notification(BrowserNotificationLevel.DEBUG, "A debug message"))
607+ self.assertThat(notifications, MatchesListwise([
608+ MatchesStructure.byEquality(message="An error"),
609+ MatchesStructure.byEquality(message="A debug message"),
610+ ]))
611
612-if __name__ == '__main__':
613- unittest.main(defaultTest='test_suite')
614+ def test___getitem__(self):
615+ # The __getitem__ method is also overloaded to allow TALES
616+ # expressions to easily retrieve lists of notifications that match a
617+ # particular notification level.
618+ notifications = NotificationList()
619+ notifications.append(
620+ Notification(BrowserNotificationLevel.ERROR, "An error"))
621+ notifications.append(
622+ Notification(BrowserNotificationLevel.DEBUG, "A debug message"))
623+ self.assertThat(notifications["debug"], MatchesListwise([
624+ MatchesStructure.byEquality(message="A debug message"),
625+ ]))

Subscribers

People subscribed via source and target branches

to status/vote changes: