1
# Copyright (C) 2012 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
"""Test the NNTP runner and related utilities."""
20
from __future__ import absolute_import, print_function, unicode_literals
34
from mailman.app.lifecycle import create_list
35
from mailman.config import config
36
from mailman.interfaces.nntp import NewsModeration
37
from mailman.runners import nntp
38
from mailman.testing.helpers import (
43
specialized_message_from_string as mfs)
44
from mailman.testing.layers import ConfigLayer
48
class TestPrepareMessage(unittest.TestCase):
49
"""Test message preparation."""
54
self._mlist = create_list('test@example.com')
55
self._mlist.linked_newsgroup = 'example.test'
57
From: anne@example.com
59
Subject: A newsgroup posting
65
def test_moderated_approved_header(self):
66
# When the mailing list is moderated , the message will get an
67
# Approved header, which NNTP software uses to forward to the
68
# newsgroup. The message would not have gotten to the mailing list if
69
# it wasn't already approved.
70
self._mlist.news_moderation = NewsModeration.moderated
71
nntp.prepare_message(self._mlist, self._msg, {})
72
self.assertEqual(self._msg['approved'], 'test@example.com')
74
def test_open_moderated_approved_header(self):
75
# When the mailing list is moderated using an open posting policy, the
76
# message will get an Approved header, which NNTP software uses to
77
# forward to the newsgroup. The message would not have gotten to the
78
# mailing list if it wasn't already approved.
79
self._mlist.news_moderation = NewsModeration.open_moderated
80
nntp.prepare_message(self._mlist, self._msg, {})
81
self.assertEqual(self._msg['approved'], 'test@example.com')
83
def test_moderation_removes_previous_approved_header(self):
84
# Any existing Approved header is removed from moderated messages.
85
self._msg['Approved'] = 'a bogus approval'
86
self._mlist.news_moderation = NewsModeration.moderated
87
nntp.prepare_message(self._mlist, self._msg, {})
88
headers = self._msg.get_all('approved')
89
self.assertEqual(len(headers), 1)
90
self.assertEqual(headers[0], 'test@example.com')
92
def test_open_moderation_removes_previous_approved_header(self):
93
# Any existing Approved header is removed from moderated messages.
94
self._msg['Approved'] = 'a bogus approval'
95
self._mlist.news_moderation = NewsModeration.open_moderated
96
nntp.prepare_message(self._mlist, self._msg, {})
97
headers = self._msg.get_all('approved')
98
self.assertEqual(len(headers), 1)
99
self.assertEqual(headers[0], 'test@example.com')
101
def test_stripped_subject(self):
102
# The cook-headers handler adds the original and/or stripped (of the
103
# prefix) subject to the metadata. Assume that handler's been run;
104
# check the Subject header.
105
self._mlist.news_prefix_subject_too = False
106
del self._msg['subject']
107
self._msg['subject'] = 'Re: Your test'
108
msgdata = dict(stripped_subject='Your test')
109
nntp.prepare_message(self._mlist, self._msg, msgdata)
110
headers = self._msg.get_all('subject')
111
self.assertEqual(len(headers), 1)
112
self.assertEqual(headers[0], 'Your test')
114
def test_original_subject(self):
115
# The cook-headers handler adds the original and/or stripped (of the
116
# prefix) subject to the metadata. Assume that handler's been run;
117
# check the Subject header.
118
self._mlist.news_prefix_subject_too = False
119
del self._msg['subject']
120
self._msg['subject'] = 'Re: Your test'
121
msgdata = dict(original_subject='Your test')
122
nntp.prepare_message(self._mlist, self._msg, msgdata)
123
headers = self._msg.get_all('subject')
124
self.assertEqual(len(headers), 1)
125
self.assertEqual(headers[0], 'Your test')
127
def test_stripped_subject_prefix_okay(self):
128
# The cook-headers handler adds the original and/or stripped (of the
129
# prefix) subject to the metadata. Assume that handler's been run;
130
# check the Subject header.
131
self._mlist.news_prefix_subject_too = True
132
del self._msg['subject']
133
self._msg['subject'] = 'Re: Your test'
134
msgdata = dict(stripped_subject='Your test')
135
nntp.prepare_message(self._mlist, self._msg, msgdata)
136
headers = self._msg.get_all('subject')
137
self.assertEqual(len(headers), 1)
138
self.assertEqual(headers[0], 'Re: Your test')
140
def test_original_subject_prefix_okay(self):
141
# The cook-headers handler adds the original and/or stripped (of the
142
# prefix) subject to the metadata. Assume that handler's been run;
143
# check the Subject header.
144
self._mlist.news_prefix_subject_too = True
145
del self._msg['subject']
146
self._msg['subject'] = 'Re: Your test'
147
msgdata = dict(original_subject='Your test')
148
nntp.prepare_message(self._mlist, self._msg, msgdata)
149
headers = self._msg.get_all('subject')
150
self.assertEqual(len(headers), 1)
151
self.assertEqual(headers[0], 'Re: Your test')
153
def test_add_newsgroups_header(self):
154
# Prepared messages get a Newsgroups header.
155
msgdata = dict(original_subject='Your test')
156
nntp.prepare_message(self._mlist, self._msg, msgdata)
157
self.assertEqual(self._msg['newsgroups'], 'example.test')
159
def test_add_newsgroups_header_to_existing(self):
160
# If the message already has a Newsgroups header, the linked newsgroup
161
# gets appended to that value, using comma-space separated lists.
162
self._msg['Newsgroups'] = 'foo.test, bar.test'
163
msgdata = dict(original_subject='Your test')
164
nntp.prepare_message(self._mlist, self._msg, msgdata)
165
headers = self._msg.get_all('newsgroups')
166
self.assertEqual(len(headers), 1)
167
self.assertEqual(headers[0], 'foo.test, bar.test, example.test')
169
def test_add_lines_header(self):
170
# A Lines: header seems useful.
171
nntp.prepare_message(self._mlist, self._msg, {})
172
self.assertEqual(self._msg['lines'], '1')
174
def test_the_message_has_been_prepared(self):
175
# A key gets added to the metadata so that a retry won't try to
176
# re-apply all the preparations.
178
nntp.prepare_message(self._mlist, self._msg, msgdata)
179
self.assertTrue(msgdata.get('prepped'))
181
@configuration('nntp', remove_headers='x-complaints-to')
182
def test_remove_headers(self):
183
# During preparation, headers which cause problems with certain NNTP
184
# servers such as INN get removed.
185
self._msg['X-Complaints-To'] = 'arguments@example.com'
186
nntp.prepare_message(self._mlist, self._msg, {})
187
self.assertEqual(self._msg['x-complaints-to'], None)
189
@configuration('nntp', rewrite_duplicate_headers="""
191
X-Fake X-Original-Fake
193
def test_rewrite_headers(self):
194
# Some NNTP servers are very strict about duplicate headers. What we
195
# can do is look at some headers and if they is more than one of that
196
# header in the message, all the headers are deleted except the first
197
# one, and then the other values are moved to the destination header.
199
# In this example, we'll create multiple To headers, which will all
200
# get moved to X-Original-To. However, because there will only be one
201
# X-Fake header, it doesn't get rewritten.
202
self._msg['To'] = 'test@example.org'
203
self._msg['To'] = 'test@example.net'
204
self._msg['X-Fake'] = 'ignore me'
205
self.assertEqual(len(self._msg.get_all('to')), 3)
206
self.assertEqual(len(self._msg.get_all('x-fake')), 1)
207
nntp.prepare_message(self._mlist, self._msg, {})
208
tos = self._msg.get_all('to')
209
self.assertEqual(len(tos), 1)
210
self.assertEqual(tos[0], 'test@example.com')
211
original_tos = self._msg.get_all('x-original-to')
212
self.assertEqual(len(original_tos), 2)
213
self.assertEqual(original_tos,
214
['test@example.org', 'test@example.net'])
215
fakes = self._msg.get_all('x-fake')
216
self.assertEqual(len(fakes), 1)
217
self.assertEqual(fakes[0], 'ignore me')
218
self.assertEqual(self._msg.get_all('x-original-fake'), None)
220
@configuration('nntp', rewrite_duplicate_headers="""
224
def test_odd_duplicates(self):
225
# This is just a corner case, where there is an odd number of rewrite
226
# headers. In that case, the odd-one-out does not get rewritten.
227
self._msg['x-fake'] = 'one'
228
self._msg['x-fake'] = 'two'
229
self._msg['x-fake'] = 'three'
230
self.assertEqual(len(self._msg.get_all('x-fake')), 3)
231
nntp.prepare_message(self._mlist, self._msg, {})
232
fakes = self._msg.get_all('x-fake')
233
self.assertEqual(len(fakes), 3)
234
self.assertEqual(fakes, ['one', 'two', 'three'])
238
class TestNNTPRunner(unittest.TestCase):
239
"""The NNTP runner hands messages off to the NNTP server."""
244
self._mlist = create_list('test@example.com')
245
self._mlist.linked_newsgroup = 'example.test'
247
From: anne@example.com
249
Subject: A newsgroup posting
254
self._runner = make_testable_runner(nntp.NNTPRunner, 'nntp')
255
self._nntpq = config.switchboards['nntp']
257
@mock.patch('nntplib.NNTP')
258
def test_connect(self, class_mock):
259
# Test connection to the NNTP server with default values.
260
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
262
class_mock.assert_called_once_with(
263
'', 119, user='', password='', readermode=True)
265
@configuration('nntp', user='alpha', password='beta',
266
host='nntp.example.com', port='2112')
267
@mock.patch('nntplib.NNTP')
268
def test_connect_with_configuration(self, class_mock):
269
# Test connection to the NNTP server with specific values.
270
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
272
class_mock.assert_called_once_with(
273
'nntp.example.com', 2112,
274
user='alpha', password='beta', readermode=True)
276
@mock.patch('nntplib.NNTP')
277
def test_post(self, class_mock):
278
# Test that the message is posted to the NNTP server.
279
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
281
# Get the mocked instance, which was used in the runner.
282
conn_mock = class_mock()
283
# The connection object's post() method was called once with a
284
# file-like object containing the message's bytes. Read those bytes
285
# and make some simple checks that the message is what we expected.
286
args = conn_mock.post.call_args
287
# One positional argument.
288
self.assertEqual(len(args[0]), 1)
289
# No keyword arguments.
290
self.assertEqual(len(args[1]), 0)
291
msg = mfs(args[0][0].read())
292
self.assertEqual(msg['subject'], 'A newsgroup posting')
294
@mock.patch('nntplib.NNTP')
295
def test_connection_got_quit(self, class_mock):
296
# The NNTP connection gets closed after a successful post.
297
# Test that the message is posted to the NNTP server.
298
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
300
# Get the mocked instance, which was used in the runner.
301
conn_mock = class_mock()
302
# The connection object's post() method was called once with a
303
# file-like object containing the message's bytes. Read those bytes
304
# and make some simple checks that the message is what we expected.
305
conn_mock.quit.assert_called_once_with()
307
@mock.patch('nntplib.NNTP', side_effect=nntplib.error_temp)
308
def test_connect_with_nntplib_failure(self, class_mock):
309
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
310
mark = LogFileMark('mailman.error')
312
log_message = mark.readline()[:-1]
313
self.assertTrue(log_message.endswith(
314
'NNTP error for test@example.com'))
316
@mock.patch('nntplib.NNTP', side_effect=socket.error)
317
def test_connect_with_socket_failure(self, class_mock):
318
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
319
mark = LogFileMark('mailman.error')
321
log_message = mark.readline()[:-1]
322
self.assertTrue(log_message.endswith(
323
'NNTP socket error for test@example.com'))
325
@mock.patch('nntplib.NNTP', side_effect=RuntimeError)
326
def test_connect_with_other_failure(self, class_mock):
327
# In this failure mode, the message stays queued, so we can only run
328
# the nntp runner once.
330
# I.e. stop immediately, since the queue will not be empty.
332
runner = make_testable_runner(nntp.NNTPRunner, 'nntp', predicate=once)
333
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
334
mark = LogFileMark('mailman.error')
336
log_message = mark.readline()[:-1]
337
self.assertTrue(log_message.endswith(
338
'NNTP unexpected exception for test@example.com'))
339
messages = get_queue_messages('nntp')
340
self.assertEqual(len(messages), 1)
341
self.assertEqual(messages[0].msgdata['listname'], 'test@example.com')
342
self.assertEqual(messages[0].msg['subject'], 'A newsgroup posting')
344
@mock.patch('nntplib.NNTP', side_effect=nntplib.error_temp)
345
def test_connection_never_gets_quit_after_failures(self, class_mock):
346
# The NNTP connection doesn't get closed after a unsuccessful
347
# connection, since there's nothing to close.
348
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
350
# Get the mocked instance, which was used in the runner. Turn off the
351
# exception raising side effect first though!
352
class_mock.side_effect = None
353
conn_mock = class_mock()
354
# The connection object's post() method was called once with a
355
# file-like object containing the message's bytes. Read those bytes
356
# and make some simple checks that the message is what we expected.
357
self.assertEqual(conn_mock.quit.call_count, 0)
359
@mock.patch('nntplib.NNTP')
360
def test_connection_got_quit_after_post_failure(self, class_mock):
361
# The NNTP connection does get closed after a unsuccessful post.
362
# Add a side-effect to the instance mock's .post() method.
363
conn_mock = class_mock()
364
conn_mock.post.side_effect = nntplib.error_temp
365
self._nntpq.enqueue(self._msg, {}, listname='test@example.com')
367
# The connection object's post() method was called once with a
368
# file-like object containing the message's bytes. Read those bytes
369
# and make some simple checks that the message is what we expected.
370
conn_mock.quit.assert_called_once_with()