1 # Copyright (C) 2008-2015 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."""
31 'make_digest_messages',
32 'make_testable_runner',
34 'specialized_message_from_string',
37 'wait_for_webservice',
54 from base64
import b64encode
55 from contextlib
import contextmanager
56 from email
import message_from_string
57 from httplib2
import Http
58 from lazr
.config
import as_timedelta
59 from mailman
.bin
.master
import Loop
as Master
60 from mailman
.config
import config
61 from mailman
.database
.transaction
import transaction
62 from mailman
.email
.message
import Message
63 from mailman
.interfaces
.member
import MemberRole
64 from mailman
.interfaces
.messages
import IMessageStore
65 from mailman
.interfaces
.styles
import IStyleManager
66 from mailman
.interfaces
.usermanager
import IUserManager
67 from mailman
.runners
.digest
import DigestRunner
68 from mailman
.utilities
.mailbox
import Mailbox
69 from unittest
import mock
70 from urllib
.error
import HTTPError
71 from urllib
.parse
import urlencode
72 from zope
import event
73 from zope
.component
import getUtility
80 def make_testable_runner(runner_class
, name
=None, predicate
=None):
81 """Create a runner that runs until its queue is empty.
83 :param runner_class: The runner class.
84 :type runner_class: class
85 :param name: Optional queue name; if not given, it is calculated from the
87 :type name: string or None
88 :param predicate: Optional alternative predicate for deciding when to stop
89 the runner. When None (the default) it stops when the queue is empty.
90 :type predicate: callable that gets one argument, the queue runner.
91 :return: A runner instance.
94 assert runner_class
.__name
__.endswith('Runner'), (
95 'Unparseable runner class name: %s' % runner_class
.__name
__)
96 name
= runner_class
.__name
__[:-6].lower()
98 class EmptyingRunner(runner_class
):
99 """Stop processing when the queue is empty."""
101 def __init__(self
, *args
, **kws
):
102 super(EmptyingRunner
, self
).__init
__(*args
, **kws
)
103 # We know it's an EmptyingRunner, so really we want to see the
104 # super class in the log files.
105 self
.__class
__.__name
__ = runner_class
.__name
__
107 def _do_periodic(self
):
108 """Stop when the queue is empty."""
109 if predicate
is None:
110 self
._stop
= (len(self
.switchboard
.files
) == 0)
112 self
._stop
= predicate(self
)
114 return EmptyingRunner(name
)
119 def __init__(self
, **kws
):
120 for key
, value
in kws
.items():
121 setattr(self
, key
, value
)
124 def get_queue_messages(queue_name
, sort_on
=None):
125 """Return and clear all the messages in the given queue.
127 :param queue_name: A string naming a queue.
128 :param sort_on: The message header to sort on. If None (the default),
129 no sorting is performed.
130 :return: A list of 2-tuples where each item contains the message and
133 queue
= config
.switchboards
[queue_name
]
135 for filebase
in queue
.files
:
136 msg
, msgdata
= queue
.dequeue(filebase
)
137 messages
.append(_Bag(msg
=msg
, msgdata
=msgdata
))
138 queue
.finish(filebase
)
139 if sort_on
is not None:
140 messages
.sort(key
=lambda item
: str(item
.msg
[sort_on
]))
145 def digest_mbox(mlist
):
146 """The mailing list's pending digest as a mailbox.
148 :param mlist: The mailing list.
149 :return: The mailing list's pending digest as a mailbox.
151 path
= os
.path
.join(mlist
.data_path
, 'digest.mmdf')
156 # Remember, Master is mailman.bin.master.Loop.
157 class TestableMaster(Master
):
158 """A testable master loop watcher."""
160 def __init__(self
, start_check
=None):
161 """Create a testable master loop watcher.
163 :param start_check: Optional callable used to check whether everything
164 is running as the test expects. Called in `loop()` in the
165 subthread before the event is set. The callback should block
166 until the pass condition is set.
167 :type start_check: Callable taking no arguments, returning nothing.
169 super(TestableMaster
, self
).__init
__(
170 restartable
=False, config_file
=config
.filename
)
171 self
.start_check
= start_check
172 self
.event
= threading
.Event()
173 self
.thread
= threading
.Thread(target
=self
.loop
)
174 self
.thread
.daemon
= True
175 self
._started
_kids
= None
179 # No-op this because the tests generally do not signal the master,
180 # which would mean the signal.pause() never exits.
183 def start(self
, *runners
):
184 """Start the master."""
185 self
.start_runners(runners
)
187 # Wait until all the children are definitely started.
191 """Stop the master by killing all the children."""
192 for pid
in self
.runner_pids
:
193 os
.kill(pid
, signal
.SIGTERM
)
198 """Wait until all the runners are actually running before looping."""
199 starting_kids
= set(self
._kids
)
201 for pid
in self
._kids
:
204 starting_kids
.remove(pid
)
205 except OSError as error
:
206 if error
.errno
== errno
.ESRCH
:
207 # The child has not yet started.
210 # Keeping a copy of all the started child processes for use by the
211 # testing environment, even after all have exited.
212 self
._started
_kids
= set(self
._kids
)
213 # If there are extra conditions to check, do it now.
214 if self
.start_check
is not None:
216 # Let the blocking thread know everything's running.
218 super(TestableMaster
, self
).loop()
221 def runner_pids(self
):
222 """The pids of all the child runner processes."""
223 for pid
in self
._started
_kids
:
228 class LMTP(smtplib
.SMTP
):
229 """Like a normal SMTP client, but for LMTP."""
230 def lhlo(self
, name
=''):
231 self
.putcmd('lhlo', name
or self
.local_hostname
)
232 code
, msg
= self
.getreply()
237 def get_lmtp_client(quiet
=False):
238 """Return a connected LMTP client."""
239 # It's possible the process has started but is not yet accepting
240 # connections. Wait a little while.
243 until
= datetime
.datetime
.now() + as_timedelta(config
.devmode
.wait
)
244 while datetime
.datetime
.now() < until
:
246 response
= lmtp
.connect(
247 config
.mta
.lmtp_host
, int(config
.mta
.lmtp_port
))
251 except IOError as error
:
252 if error
.errno
== errno
.ECONNREFUSED
:
257 raise RuntimeError('Connection refused')
261 def get_nntp_server(cleanups
):
262 """Create and start an NNTP server mock.
264 This can be used to retrieve the posted message for verification.
266 patcher
= mock
.patch('nntplib.NNTP')
267 server_class
= patcher
.start()
268 cleanups
.append(patcher
.stop
)
269 nntpd
= server_class()
270 # A class for more convenient access to the posted message.
272 def get_message(self
):
273 args
= nntpd
.post
.call_args
274 return specialized_message_from_string(args
[0][0].read())
279 def wait_for_webservice():
280 """Wait for the REST server to start serving requests."""
281 until
= datetime
.datetime
.now() + as_timedelta(config
.devmode
.wait
)
282 while datetime
.datetime
.now() < until
:
284 socket
.socket().connect((config
.webservice
.hostname
,
285 int(config
.webservice
.port
)))
286 except IOError as error
:
287 if error
.errno
== errno
.ECONNREFUSED
:
294 raise RuntimeError('Connection refused')
297 def call_api(url
, data
=None, method
=None, username
=None, password
=None):
298 """'Call a URL with a given HTTP method and return the resulting object.
300 The object will have been JSON decoded.
302 :param url: The url to open, read, and print.
304 :param data: Data to use to POST to a URL.
306 :param method: Alternative HTTP method to use.
308 :param username: The HTTP Basic Auth user name. None means use the value
309 from the configuration.
311 :param password: The HTTP Basic Auth password. None means use the value
312 from the configuration.
314 :return: A 2-tuple containing the JSON decoded content (if there is any,
315 else None) and the response object.
316 :rtype: 2-tuple of (dict, response)
317 :raises HTTPError: when a non-2xx return code is received.
321 data
= urlencode(data
, doseq
=True)
322 headers
['Content-Type'] = 'application/x-www-form-urlencoded'
328 method
= method
.upper()
329 if method
in ('POST', 'PUT', 'PATCH') and data
is None:
330 data
= urlencode({}, doseq
=True)
331 basic_auth
= '{0}:{1}'.format(
332 (config
.webservice
.admin_user
if username
is None else username
),
333 (config
.webservice
.admin_pass
if password
is None else password
))
334 # b64encode() requires a bytes, but the header value must be str. Do the
335 # necessary conversion dances.
336 token
= b64encode(basic_auth
.encode('utf-8')).decode('ascii')
337 headers
['Authorization'] = 'Basic ' + token
338 response
, content
= Http().request(url
, method
, data
, headers
)
339 # If we did not get a 2xx status code, make this look like a urllib2
340 # exception, for backward compatibility with existing doctests.
341 if response
.status
// 100 != 2:
342 raise HTTPError(url
, response
.status
, content
, response
, None)
343 if len(content
) == 0:
344 return None, response
345 # XXX Workaround http://bugs.python.org/issue10038
346 content
= content
.decode('utf-8')
347 return json
.loads(content
), response
352 def event_subscribers(*subscribers
):
353 """Temporarily extend the Zope event subscribers list.
355 :param subscribers: A sequence of event subscribers.
356 :type subscribers: sequence of callables, each receiving one argument, the
359 old_subscribers
= event
.subscribers
[:]
360 event
.subscribers
.extend(subscribers
)
364 event
.subscribers
[:] = old_subscribers
369 """A decorator/context manager for temporarily setting configurations."""
371 def __init__(self
, section
, **kws
):
372 self
._section
= section
373 # Most tests don't care about the name given to the temporary
374 # configuration. Usually we'll just craft a random one, but some
375 # tests do care, so give them a hook to set it.
376 if '_configname' in kws
:
377 self
._uuid
= kws
.pop('_configname')
379 self
._uuid
= uuid
.uuid4().hex
380 self
._values
= kws
.copy()
383 lines
= ['[{0}]'.format(self
._section
)]
384 for key
, value
in self
._values
.items():
385 lines
.append('{0}: {1}'.format(key
, value
))
386 config
.push(self
._uuid
, NL
.join(lines
))
389 config
.pop(self
._uuid
)
394 def __exit__(self
, *exc_info
):
396 # Do not suppress exceptions.
399 def __call__(self
, func
):
400 def wrapper(*args
, **kws
):
403 return func(*args
, **kws
)
411 def temporary_db(db
):
422 """A context manager for temporary directory changing."""
423 def __init__(self
, directory
):
425 self
._directory
= directory
428 self
._curdir
= os
.getcwd()
429 os
.chdir(self
._directory
)
431 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
432 os
.chdir(self
._curdir
)
433 # Don't suppress exceptions.
438 def subscribe(mlist
, first_name
, role
=MemberRole
.member
, email
=None):
439 """Helper for subscribing a sample person to a mailing list.
441 Returns the newly created member object.
443 user_manager
= getUtility(IUserManager
)
444 email
= ('{0}person@example.com'.format(first_name
[0].lower())
445 if email
is None else email
)
446 full_name
= '{0} Person'.format(first_name
)
448 person
= user_manager
.get_user(email
)
450 address
= user_manager
.get_address(email
)
452 person
= user_manager
.create_user(email
, full_name
)
453 subscription_address
= list(person
.addresses
)[0]
455 subscription_address
= address
457 subscription_address
= list(person
.addresses
)[0]
458 mlist
.subscribe(subscription_address
, role
)
459 roster
= mlist
.get_roster(role
)
460 return roster
.get_member(email
)
464 def reset_the_world():
467 * Clear out the database
468 * Remove all residual queue and digest files
469 * Clear the message store
470 * Reset the global style manager
472 This should be as thorough a reset of the system as necessary to keep
475 # Reset the database between tests.
477 # Remove any digest files and members.txt file (for the file-recips
478 # handler) in the lists' data directories.
479 for dirpath
, dirnames
, filenames
in os
.walk(config
.LIST_DATA_DIR
):
480 for filename
in filenames
:
481 if filename
.endswith('.mmdf') or filename
== 'members.txt':
482 os
.remove(os
.path
.join(dirpath
, filename
))
483 # Remove all residual queue files.
484 for dirpath
, dirnames
, filenames
in os
.walk(config
.QUEUE_DIR
):
485 for filename
in filenames
:
486 os
.remove(os
.path
.join(dirpath
, filename
))
487 # Clear out messages in the message store.
488 message_store
= getUtility(IMessageStore
)
490 for message
in message_store
.messages
:
491 message_store
.delete_message(message
['message-id'])
492 # Delete any other residual messages.
493 for dirpath
, dirnames
, filenames
in os
.walk(config
.MESSAGES_DIR
):
494 for filename
in filenames
:
495 os
.remove(os
.path
.join(dirpath
, filename
))
496 shutil
.rmtree(dirpath
)
497 # Reset the global style manager.
498 getUtility(IStyleManager
).populate()
499 # Remove all dynamic header-match rules.
500 config
.chains
['header-match'].flush()
504 def specialized_message_from_string(unicode_text
):
505 """Parse text into a message object.
507 This is specialized in the sense that an instance of Mailman's own Message
508 object is returned, and this message object has an attribute
509 `original_size` which is the pre-calculated size in bytes of the message's
512 Also, the text must be ASCII-only unicode.
514 # This mimic what Switchboard.dequeue() does when parsing a message from
515 # text into a Message instance.
516 original_size
= len(unicode_text
)
517 message
= message_from_string(unicode_text
, Message
)
518 message
.original_size
= original_size
524 def __init__(self
, log_name
):
525 self
._log
= logging
.getLogger(log_name
)
526 self
._filename
= self
._log
.handlers
[0].filename
527 self
._filepos
= os
.stat(self
._filename
).st_size
530 with
open(self
._filename
) as fp
:
531 fp
.seek(self
._filepos
)
535 with
open(self
._filename
) as fp
:
536 fp
.seek(self
._filepos
)
541 def make_digest_messages(mlist
, msg
=None):
543 msg
= specialized_message_from_string("""\
544 From: anne@example.org
546 Message-ID: <testing>
548 message triggering a digest
549 """.format(listname
=mlist
.fqdn_listname
))
550 mbox_path
= os
.path
.join(mlist
.data_path
, 'digest.mmdf')
551 config
.handlers
['to-digest'].process(mlist
, msg
, {})
552 config
.switchboards
['digest'].enqueue(
554 listname
=mlist
.fqdn_listname
,
555 digest_path
=mbox_path
,
556 volume
=1, digest_number
=1)
557 runner
= make_testable_runner(DigestRunner
, 'digest')