1 # Copyright (C) 2008-2016 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
18 """Various test helpers."""
33 from base64
import b64encode
34 from contextlib
import contextmanager
35 from email
import message_from_string
36 from httplib2
import Http
37 from lazr
.config
import as_timedelta
38 from mailman
import public
39 from mailman
.bin
.master
import Loop
as Master
40 from mailman
.config
import config
41 from mailman
.database
.transaction
import transaction
42 from mailman
.email
.message
import Message
43 from mailman
.interfaces
.member
import MemberRole
44 from mailman
.interfaces
.messages
import IMessageStore
45 from mailman
.interfaces
.styles
import IStyleManager
46 from mailman
.interfaces
.usermanager
import IUserManager
47 from mailman
.runners
.digest
import DigestRunner
48 from mailman
.utilities
.mailbox
import Mailbox
49 from unittest
import mock
50 from urllib
.error
import HTTPError
51 from urllib
.parse
import urlencode
52 from zope
import event
53 from zope
.component
import getUtility
60 def make_testable_runner(runner_class
, name
=None, predicate
=None):
61 """Create a runner that runs until its queue is empty.
63 :param runner_class: The runner class.
64 :type runner_class: class
65 :param name: Optional queue name; if not given, it is calculated from the
67 :type name: string or None
68 :param predicate: Optional alternative predicate for deciding when to stop
69 the runner. When None (the default) it stops when the queue is empty.
70 :type predicate: callable that gets one argument, the queue runner.
71 :return: A runner instance.
74 assert runner_class
.__name
__.endswith('Runner'), (
75 'Unparseable runner class name: %s' % runner_class
.__name
__)
76 name
= runner_class
.__name
__[:-6].lower()
78 class EmptyingRunner(runner_class
):
79 """Stop processing when the queue is empty."""
81 def __init__(self
, *args
, **kws
):
82 super().__init
__(*args
, **kws
)
83 # We know it's an EmptyingRunner, so really we want to see the
84 # super class in the log files.
85 self
.__class
__.__name
__ = runner_class
.__name
__
87 def _do_periodic(self
):
88 """Stop when the queue is empty."""
90 self
._stop
= (len(self
.switchboard
.files
) == 0)
92 self
._stop
= predicate(self
)
94 return EmptyingRunner(name
)
98 def __init__(self
, **kws
):
99 for key
, value
in kws
.items():
100 setattr(self
, key
, value
)
104 def get_queue_messages(queue_name
, sort_on
=None, expected_count
=None):
105 """Return and clear all the messages in the given queue.
107 :param queue_name: A string naming a queue.
108 :param sort_on: The message header to sort on. If None (the default),
109 no sorting is performed.
110 :param expected_count: If given and there aren't exactly this number of
111 messages in the queue, raise an AssertionError.
112 :return: A list of 2-tuples where each item contains the message and
115 queue
= config
.switchboards
[queue_name
]
117 for filebase
in queue
.files
:
118 msg
, msgdata
= queue
.dequeue(filebase
)
119 messages
.append(_Bag(msg
=msg
, msgdata
=msgdata
))
120 queue
.finish(filebase
)
121 if expected_count
is not None:
122 assert len(messages
) == expected_count
, 'Wanted {}, got {}'.format(
123 expected_count
, len(messages
))
124 if sort_on
is not None:
125 messages
.sort(key
=lambda item
: str(item
.msg
[sort_on
]))
130 def digest_mbox(mlist
):
131 """The mailing list's pending digest as a mailbox.
133 :param mlist: The mailing list.
134 :return: The mailing list's pending digest as a mailbox.
136 path
= os
.path
.join(mlist
.data_path
, 'digest.mmdf')
140 # Remember, Master is mailman.bin.master.Loop.
142 class TestableMaster(Master
):
143 """A testable master loop watcher."""
145 def __init__(self
, start_check
=None):
146 """Create a testable master loop watcher.
148 :param start_check: Optional callable used to check whether everything
149 is running as the test expects. Called in `loop()` in the
150 subthread before the event is set. The callback should block
151 until the pass condition is set.
152 :type start_check: Callable taking no arguments, returning nothing.
154 super().__init
__(restartable
=False, config_file
=config
.filename
)
155 self
.start_check
= start_check
156 self
.event
= threading
.Event()
157 self
.thread
= threading
.Thread(target
=self
.loop
)
158 self
.thread
.daemon
= True
159 self
._started
_kids
= None
163 # No-op this because the tests generally do not signal the master,
164 # which would mean the signal.pause() never exits.
167 def start(self
, *runners
):
168 """Start the master."""
169 self
.start_runners(runners
)
171 # Wait until all the children are definitely started.
175 """Stop the master by killing all the children."""
176 for pid
in self
.runner_pids
:
177 os
.kill(pid
, signal
.SIGTERM
)
182 """Wait until all the runners are actually running before looping."""
183 starting_kids
= set(self
._kids
)
185 for pid
in self
._kids
:
188 starting_kids
.remove(pid
)
189 except OSError as error
:
190 if error
.errno
== errno
.ESRCH
:
191 # The child has not yet started.
194 # Keeping a copy of all the started child processes for use by the
195 # testing environment, even after all have exited.
196 self
._started
_kids
= set(self
._kids
)
197 # If there are extra conditions to check, do it now.
198 if self
.start_check
is not None:
200 # Let the blocking thread know everything's running.
205 def runner_pids(self
):
206 """The pids of all the child runner processes."""
207 yield from self
._started
_kids
210 class LMTP(smtplib
.SMTP
):
211 """Like a normal SMTP client, but for LMTP."""
212 def lhlo(self
, name
=''):
213 self
.putcmd('lhlo', name
or self
.local_hostname
)
214 code
, msg
= self
.getreply()
220 def get_lmtp_client(quiet
=False):
221 """Return a connected LMTP client."""
222 # It's possible the process has started but is not yet accepting
223 # connections. Wait a little while.
225 # lmtp.debuglevel = 1
226 until
= datetime
.datetime
.now() + as_timedelta(config
.devmode
.wait
)
227 while datetime
.datetime
.now() < until
:
229 response
= lmtp
.connect(
230 config
.mta
.lmtp_host
, int(config
.mta
.lmtp_port
))
234 except IOError as error
:
235 if error
.errno
== errno
.ECONNREFUSED
:
240 raise RuntimeError('Connection refused')
244 def get_nntp_server(cleanups
):
245 """Create and start an NNTP server mock.
247 This can be used to retrieve the posted message for verification.
249 patcher
= mock
.patch('nntplib.NNTP')
250 server_class
= patcher
.start()
251 cleanups
.append(patcher
.stop
)
252 nntpd
= server_class()
253 # A class for more convenient access to the posted message.
254 class NNTPProxy
: # noqa
255 def get_message(self
):
256 args
= nntpd
.post
.call_args
257 return specialized_message_from_string(args
[0][0].read())
262 def wait_for_webservice():
263 """Wait for the REST server to start serving requests."""
264 until
= datetime
.datetime
.now() + as_timedelta(config
.devmode
.wait
)
265 while datetime
.datetime
.now() < until
:
267 socket
.socket().connect((config
.webservice
.hostname
,
268 int(config
.webservice
.port
)))
269 except IOError as error
:
270 if error
.errno
== errno
.ECONNREFUSED
:
277 raise RuntimeError('Connection refused')
281 def call_api(url
, data
=None, method
=None, username
=None, password
=None):
282 """'Call a URL with a given HTTP method and return the resulting object.
284 The object will have been JSON decoded.
286 :param url: The url to open, read, and print.
288 :param data: Data to use to POST to a URL.
290 :param method: Alternative HTTP method to use.
292 :param username: The HTTP Basic Auth user name. None means use the value
293 from the configuration.
295 :param password: The HTTP Basic Auth password. None means use the value
296 from the configuration.
298 :return: A 2-tuple containing the JSON decoded content (if there is any,
299 else None) and the response object.
300 :rtype: 2-tuple of (dict, response)
301 :raises HTTPError: when a non-2xx return code is received.
305 data
= urlencode(data
, doseq
=True)
306 headers
['Content-Type'] = 'application/x-www-form-urlencoded'
312 method
= method
.upper()
313 if method
in ('POST', 'PUT', 'PATCH') and data
is None:
314 data
= urlencode({}, doseq
=True)
315 basic_auth
= '{0}:{1}'.format(
316 (config
.webservice
.admin_user
if username
is None else username
),
317 (config
.webservice
.admin_pass
if password
is None else password
))
318 # b64encode() requires a bytes, but the header value must be str. Do the
319 # necessary conversion dances.
320 token
= b64encode(basic_auth
.encode('utf-8')).decode('ascii')
321 headers
['Authorization'] = 'Basic ' + token
322 response
, content
= Http().request(url
, method
, data
, headers
)
323 # If we did not get a 2xx status code, make this look like a urllib2
324 # exception, for backward compatibility with existing doctests.
325 if response
.status
// 100 != 2:
326 raise HTTPError(url
, response
.status
, content
, response
, None)
327 if len(content
) == 0:
328 return None, response
329 # XXX Workaround http://bugs.python.org/issue10038
330 content
= content
.decode('utf-8')
331 return json
.loads(content
), response
336 def event_subscribers(*subscribers
):
337 """Temporarily extend the Zope event subscribers list.
339 :param subscribers: A sequence of event subscribers.
340 :type subscribers: sequence of callables, each receiving one argument, the
343 old_subscribers
= event
.subscribers
[:]
344 event
.subscribers
.extend(subscribers
)
348 event
.subscribers
[:] = old_subscribers
353 """A decorator/context manager for temporarily setting configurations."""
355 def __init__(self
, section
, **kws
):
356 self
._section
= section
357 # Most tests don't care about the name given to the temporary
358 # configuration. Usually we'll just craft a random one, but some
359 # tests do care, so give them a hook to set it.
360 if '_configname' in kws
:
361 self
._uuid
= kws
.pop('_configname')
363 self
._uuid
= uuid
.uuid4().hex
364 self
._values
= kws
.copy()
367 lines
= ['[{0}]'.format(self
._section
)]
368 for key
, value
in self
._values
.items():
369 lines
.append('{0}: {1}'.format(key
, value
))
370 config
.push(self
._uuid
, NL
.join(lines
))
373 config
.pop(self
._uuid
)
378 def __exit__(self
, *exc_info
):
380 # Do not suppress exceptions.
383 def __call__(self
, func
):
384 def wrapper(*args
, **kws
):
387 return func(*args
, **kws
)
395 def temporary_db(db
):
406 """A context manager for temporary directory changing."""
407 def __init__(self
, directory
):
409 self
._directory
= directory
412 self
._curdir
= os
.getcwd()
413 os
.chdir(self
._directory
)
415 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
416 os
.chdir(self
._curdir
)
417 # Don't suppress exceptions.
422 def subscribe(mlist
, first_name
, role
=MemberRole
.member
, email
=None):
423 """Helper for subscribing a sample person to a mailing list.
425 Returns the newly created member object.
427 user_manager
= getUtility(IUserManager
)
428 email
= ('{}person@example.com'.format(first_name
[0].lower())
429 if email
is None else email
)
430 full_name
= '{} Person'.format(first_name
)
432 person
= user_manager
.get_user(email
)
434 address
= user_manager
.get_address(email
)
436 person
= user_manager
.create_user(email
, full_name
)
437 subscription_address
= list(person
.addresses
)[0]
439 subscription_address
= address
441 subscription_address
= list(person
.addresses
)[0]
442 mlist
.subscribe(subscription_address
, role
)
443 roster
= mlist
.get_roster(role
)
444 return roster
.get_member(email
)
448 def reset_the_world():
451 * Clear out the database
452 * Remove all residual queue and digest files
453 * Clear the message store
454 * Reset the global style manager
456 This should be as thorough a reset of the system as necessary to keep
459 # Reset the database between tests.
461 # Remove any digest files and members.txt file (for the file-recips
462 # handler) in the lists' data directories.
463 for dirpath
, dirnames
, filenames
in os
.walk(config
.LIST_DATA_DIR
):
464 for filename
in filenames
:
465 if filename
.endswith('.mmdf') or filename
== 'members.txt':
466 os
.remove(os
.path
.join(dirpath
, filename
))
467 # Remove all residual queue files.
468 for dirpath
, dirnames
, filenames
in os
.walk(config
.QUEUE_DIR
):
469 for filename
in filenames
:
470 os
.remove(os
.path
.join(dirpath
, filename
))
471 # Clear out messages in the message store.
472 message_store
= getUtility(IMessageStore
)
474 for message
in message_store
.messages
:
475 message_store
.delete_message(message
['message-id'])
476 # Delete any other residual messages.
477 for dirpath
, dirnames
, filenames
in os
.walk(config
.MESSAGES_DIR
):
478 for filename
in filenames
:
479 os
.remove(os
.path
.join(dirpath
, filename
))
480 shutil
.rmtree(dirpath
)
481 # Reset the global style manager.
482 getUtility(IStyleManager
).populate()
483 # Remove all dynamic header-match rules.
484 config
.chains
['header-match'].flush()
488 def specialized_message_from_string(unicode_text
):
489 """Parse text into a message object.
491 This is specialized in the sense that an instance of Mailman's own Message
492 object is returned, and this message object has an attribute
493 `original_size` which is the pre-calculated size in bytes of the message's
496 Also, the text must be ASCII-only unicode.
498 # This mimic what Switchboard.dequeue() does when parsing a message from
499 # text into a Message instance.
500 original_size
= len(unicode_text
)
501 message
= message_from_string(unicode_text
, Message
)
502 message
.original_size
= original_size
508 def __init__(self
, log_name
):
509 self
._log
= logging
.getLogger(log_name
)
510 self
._filename
= self
._log
.handlers
[0].filename
511 self
._filepos
= os
.stat(self
._filename
).st_size
514 with
open(self
._filename
) as fp
:
515 fp
.seek(self
._filepos
)
519 with
open(self
._filename
) as fp
:
520 fp
.seek(self
._filepos
)
525 def make_digest_messages(mlist
, msg
=None):
527 msg
= specialized_message_from_string("""\
528 From: anne@example.org
530 Message-ID: <testing>
532 message triggering a digest
533 """.format(listname
=mlist
.fqdn_listname
))
534 mbox_path
= os
.path
.join(mlist
.data_path
, 'digest.mmdf')
535 config
.handlers
['to-digest'].process(mlist
, msg
, {})
536 config
.switchboards
['digest'].enqueue(
538 listname
=mlist
.fqdn_listname
,
539 digest_path
=mbox_path
,
540 volume
=1, digest_number
=1)
541 runner
= make_testable_runner(DigestRunner
, 'digest')
546 def set_preferred(user
):
547 # Avoid circular imports.
548 from mailman
.utilities
.datetime
import now
549 preferred
= list(user
.addresses
)[0]
550 preferred
.verified_on
= now()
551 user
.preferred_address
= preferred
557 def hackenv(envar
, new_value
):
558 """Hack the environment temporarily, then reset it."""
559 old_value
= os
.getenv(envar
)
560 if new_value
is None:
561 if envar
in os
.environ
:
562 del os
.environ
[envar
]
564 os
.environ
[envar
] = new_value
568 if old_value
is None:
569 if envar
in os
.environ
:
570 del os
.environ
[envar
]
572 os
.environ
[envar
] = old_value