blob: 3e84f3429ee8546c8bbb9510e3a95b52052e0850 [file] [log] [blame]
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001import io
Giampaolo RodolĂ 424298a2011-03-03 18:34:06 +00002import socket
Antoine Pitrou69ab9512010-09-29 15:03:40 +00003import datetime
4import textwrap
5import unittest
Antoine Pitroude609182010-11-18 17:29:23 +00006import functools
Antoine Pitrou69ab9512010-09-29 15:03:40 +00007import contextlib
Martin Panter8f19e8e2016-01-19 01:10:58 +00008import os.path
Antoine Pitrou69ab9512010-09-29 15:03:40 +00009from test import support
Serhiy Storchaka43767632013-11-03 21:31:38 +020010from nntplib import NNTP, GroupInfo
Antoine Pitrou69ab9512010-09-29 15:03:40 +000011import nntplib
Serhiy Storchaka52027c32015-03-21 09:40:26 +020012from unittest.mock import patch
Serhiy Storchaka43767632013-11-03 21:31:38 +020013try:
Antoine Pitrou1cb121e2010-11-09 18:54:37 +000014 import ssl
Serhiy Storchaka43767632013-11-03 21:31:38 +020015except ImportError:
16 ssl = None
Martin Panter8f19e8e2016-01-19 01:10:58 +000017try:
18 import threading
19except ImportError:
20 threading = None
Antoine Pitrou69ab9512010-09-29 15:03:40 +000021
22TIMEOUT = 30
Martin Panter8f19e8e2016-01-19 01:10:58 +000023certfile = os.path.join(os.path.dirname(__file__), 'keycert3.pem')
Antoine Pitrou69ab9512010-09-29 15:03:40 +000024
25# TODO:
26# - test the `file` arg to more commands
27# - test error conditions
Antoine Pitroua5785b12010-09-29 16:19:50 +000028# - test auth and `usenetrc`
Antoine Pitrou69ab9512010-09-29 15:03:40 +000029
30
31class NetworkedNNTPTestsMixin:
32
33 def test_welcome(self):
34 welcome = self.server.getwelcome()
35 self.assertEqual(str, type(welcome))
36
37 def test_help(self):
Antoine Pitrou08eeada2010-11-04 21:36:15 +000038 resp, lines = self.server.help()
Antoine Pitrou69ab9512010-09-29 15:03:40 +000039 self.assertTrue(resp.startswith("100 "), resp)
Antoine Pitrou08eeada2010-11-04 21:36:15 +000040 for line in lines:
Antoine Pitrou69ab9512010-09-29 15:03:40 +000041 self.assertEqual(str, type(line))
42
43 def test_list(self):
Antoine Pitrou08eeada2010-11-04 21:36:15 +000044 resp, groups = self.server.list()
45 if len(groups) > 0:
46 self.assertEqual(GroupInfo, type(groups[0]))
47 self.assertEqual(str, type(groups[0].group))
48
49 def test_list_active(self):
50 resp, groups = self.server.list(self.GROUP_PAT)
51 if len(groups) > 0:
52 self.assertEqual(GroupInfo, type(groups[0]))
53 self.assertEqual(str, type(groups[0].group))
Antoine Pitrou69ab9512010-09-29 15:03:40 +000054
55 def test_unknown_command(self):
56 with self.assertRaises(nntplib.NNTPPermanentError) as cm:
57 self.server._shortcmd("XYZZY")
58 resp = cm.exception.response
59 self.assertTrue(resp.startswith("500 "), resp)
60
61 def test_newgroups(self):
62 # gmane gets a constant influx of new groups. In order not to stress
63 # the server too much, we choose a recent date in the past.
64 dt = datetime.date.today() - datetime.timedelta(days=7)
65 resp, groups = self.server.newgroups(dt)
66 if len(groups) > 0:
67 self.assertIsInstance(groups[0], GroupInfo)
68 self.assertIsInstance(groups[0].group, str)
69
70 def test_description(self):
71 def _check_desc(desc):
72 # Sanity checks
73 self.assertIsInstance(desc, str)
74 self.assertNotIn(self.GROUP_NAME, desc)
75 desc = self.server.description(self.GROUP_NAME)
76 _check_desc(desc)
77 # Another sanity check
78 self.assertIn("Python", desc)
79 # With a pattern
80 desc = self.server.description(self.GROUP_PAT)
81 _check_desc(desc)
82 # Shouldn't exist
83 desc = self.server.description("zk.brrtt.baz")
84 self.assertEqual(desc, '')
85
86 def test_descriptions(self):
87 resp, descs = self.server.descriptions(self.GROUP_PAT)
88 # 215 for LIST NEWSGROUPS, 282 for XGTITLE
89 self.assertTrue(
90 resp.startswith("215 ") or resp.startswith("282 "), resp)
91 self.assertIsInstance(descs, dict)
92 desc = descs[self.GROUP_NAME]
93 self.assertEqual(desc, self.server.description(self.GROUP_NAME))
94
95 def test_group(self):
96 result = self.server.group(self.GROUP_NAME)
97 self.assertEqual(5, len(result))
98 resp, count, first, last, group = result
99 self.assertEqual(group, self.GROUP_NAME)
100 self.assertIsInstance(count, int)
101 self.assertIsInstance(first, int)
102 self.assertIsInstance(last, int)
103 self.assertLessEqual(first, last)
104 self.assertTrue(resp.startswith("211 "), resp)
105
106 def test_date(self):
107 resp, date = self.server.date()
108 self.assertIsInstance(date, datetime.datetime)
109 # Sanity check
110 self.assertGreaterEqual(date.year, 1995)
111 self.assertLessEqual(date.year, 2030)
112
113 def _check_art_dict(self, art_dict):
114 # Some sanity checks for a field dictionary returned by OVER / XOVER
115 self.assertIsInstance(art_dict, dict)
116 # NNTP has 7 mandatory fields
117 self.assertGreaterEqual(art_dict.keys(),
118 {"subject", "from", "date", "message-id",
119 "references", ":bytes", ":lines"}
120 )
121 for v in art_dict.values():
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000122 self.assertIsInstance(v, (str, type(None)))
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000123
124 def test_xover(self):
125 resp, count, first, last, name = self.server.group(self.GROUP_NAME)
Antoine Pitroud28f7902010-11-18 15:11:43 +0000126 resp, lines = self.server.xover(last - 5, last)
127 if len(lines) == 0:
128 self.skipTest("no articles retrieved")
129 # The 'last' article is not necessarily part of the output (cancelled?)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000130 art_num, art_dict = lines[0]
Antoine Pitroud28f7902010-11-18 15:11:43 +0000131 self.assertGreaterEqual(art_num, last - 5)
132 self.assertLessEqual(art_num, last)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000133 self._check_art_dict(art_dict)
134
Xavier de Gayeac13bee2016-12-16 20:49:10 +0100135 @unittest.skipIf(True, 'temporarily skipped until a permanent solution'
136 ' is found for issue #28971')
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000137 def test_over(self):
138 resp, count, first, last, name = self.server.group(self.GROUP_NAME)
139 start = last - 10
140 # The "start-" article range form
141 resp, lines = self.server.over((start, None))
142 art_num, art_dict = lines[0]
143 self._check_art_dict(art_dict)
144 # The "start-end" article range form
145 resp, lines = self.server.over((start, last))
146 art_num, art_dict = lines[-1]
Antoine Pitroud28f7902010-11-18 15:11:43 +0000147 # The 'last' article is not necessarily part of the output (cancelled?)
148 self.assertGreaterEqual(art_num, start)
149 self.assertLessEqual(art_num, last)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000150 self._check_art_dict(art_dict)
151 # XXX The "message_id" form is unsupported by gmane
152 # 503 Overview by message-ID unsupported
153
154 def test_xhdr(self):
155 resp, count, first, last, name = self.server.group(self.GROUP_NAME)
156 resp, lines = self.server.xhdr('subject', last)
157 for line in lines:
158 self.assertEqual(str, type(line[1]))
159
160 def check_article_resp(self, resp, article, art_num=None):
161 self.assertIsInstance(article, nntplib.ArticleInfo)
162 if art_num is not None:
163 self.assertEqual(article.number, art_num)
164 for line in article.lines:
165 self.assertIsInstance(line, bytes)
166 # XXX this could exceptionally happen...
167 self.assertNotIn(article.lines[-1], (b".", b".\n", b".\r\n"))
168
169 def test_article_head_body(self):
170 resp, count, first, last, name = self.server.group(self.GROUP_NAME)
Antoine Pitroud28f7902010-11-18 15:11:43 +0000171 # Try to find an available article
172 for art_num in (last, first, last - 1):
173 try:
174 resp, head = self.server.head(art_num)
175 except nntplib.NNTPTemporaryError as e:
176 if not e.response.startswith("423 "):
177 raise
178 # "423 No such article" => choose another one
179 continue
180 break
181 else:
182 self.skipTest("could not find a suitable article number")
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000183 self.assertTrue(resp.startswith("221 "), resp)
Antoine Pitroud28f7902010-11-18 15:11:43 +0000184 self.check_article_resp(resp, head, art_num)
185 resp, body = self.server.body(art_num)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000186 self.assertTrue(resp.startswith("222 "), resp)
Antoine Pitroud28f7902010-11-18 15:11:43 +0000187 self.check_article_resp(resp, body, art_num)
188 resp, article = self.server.article(art_num)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000189 self.assertTrue(resp.startswith("220 "), resp)
Antoine Pitroud28f7902010-11-18 15:11:43 +0000190 self.check_article_resp(resp, article, art_num)
Nick Coghlan14d99a12012-06-17 21:27:18 +1000191 # Tolerate running the tests from behind a NNTP virus checker
Antoine Pitrou1f5d2a02012-06-24 16:28:18 +0200192 blacklist = lambda line: line.startswith(b'X-Antivirus')
193 filtered_head_lines = [line for line in head.lines
194 if not blacklist(line)]
Nick Coghlan14d99a12012-06-17 21:27:18 +1000195 filtered_lines = [line for line in article.lines
Antoine Pitrou1f5d2a02012-06-24 16:28:18 +0200196 if not blacklist(line)]
197 self.assertEqual(filtered_lines, filtered_head_lines + [b''] + body.lines)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000198
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000199 def test_capabilities(self):
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000200 # The server under test implements NNTP version 2 and has a
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000201 # couple of well-known capabilities. Just sanity check that we
202 # got them.
203 def _check_caps(caps):
204 caps_list = caps['LIST']
205 self.assertIsInstance(caps_list, (list, tuple))
206 self.assertIn('OVERVIEW.FMT', caps_list)
207 self.assertGreaterEqual(self.server.nntp_version, 2)
208 _check_caps(self.server.getcapabilities())
209 # This re-emits the command
210 resp, caps = self.server.capabilities()
211 _check_caps(caps)
212
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000213 def test_zlogin(self):
214 # This test must be the penultimate because further commands will be
215 # refused.
216 baduser = "notarealuser"
217 badpw = "notarealpassword"
218 # Check that bogus credentials cause failure
219 self.assertRaises(nntplib.NNTPError, self.server.login,
220 user=baduser, password=badpw, usenetrc=False)
221 # FIXME: We should check that correct credentials succeed, but that
222 # would require valid details for some server somewhere to be in the
223 # test suite, I think. Gmane is anonymous, at least as used for the
224 # other tests.
225
226 def test_zzquit(self):
227 # This test must be called last, hence the name
228 cls = type(self)
Antoine Pitrou3bce11c2010-11-21 17:14:19 +0000229 try:
230 self.server.quit()
231 finally:
232 cls.server = None
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000233
Antoine Pitroude609182010-11-18 17:29:23 +0000234 @classmethod
235 def wrap_methods(cls):
236 # Wrap all methods in a transient_internet() exception catcher
237 # XXX put a generic version in test.support?
238 def wrap_meth(meth):
239 @functools.wraps(meth)
240 def wrapped(self):
241 with support.transient_internet(self.NNTP_HOST):
242 meth(self)
243 return wrapped
244 for name in dir(cls):
245 if not name.startswith('test_'):
246 continue
247 meth = getattr(cls, name)
Florent Xicluna5d1155c2011-10-28 14:45:05 +0200248 if not callable(meth):
Antoine Pitroude609182010-11-18 17:29:23 +0000249 continue
250 # Need to use a closure so that meth remains bound to its current
251 # value
252 setattr(cls, name, wrap_meth(meth))
253
Giampaolo RodolĂ 424298a2011-03-03 18:34:06 +0000254 def test_with_statement(self):
255 def is_connected():
256 if not hasattr(server, 'file'):
257 return False
258 try:
259 server.help()
Andrew Svetlov0832af62012-12-18 23:10:48 +0200260 except (OSError, EOFError):
Giampaolo RodolĂ 424298a2011-03-03 18:34:06 +0000261 return False
262 return True
263
264 with self.NNTP_CLASS(self.NNTP_HOST, timeout=TIMEOUT, usenetrc=False) as server:
265 self.assertTrue(is_connected())
266 self.assertTrue(server.help())
267 self.assertFalse(is_connected())
268
269 with self.NNTP_CLASS(self.NNTP_HOST, timeout=TIMEOUT, usenetrc=False) as server:
270 server.quit()
271 self.assertFalse(is_connected())
272
273
Antoine Pitroude609182010-11-18 17:29:23 +0000274NetworkedNNTPTestsMixin.wrap_methods()
275
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000276
277class NetworkedNNTPTests(NetworkedNNTPTestsMixin, unittest.TestCase):
278 # This server supports STARTTLS (gmane doesn't)
279 NNTP_HOST = 'news.trigofacile.com'
280 GROUP_NAME = 'fr.comp.lang.python'
281 GROUP_PAT = 'fr.comp.lang.*'
282
Antoine Pitroude609182010-11-18 17:29:23 +0000283 NNTP_CLASS = NNTP
284
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000285 @classmethod
286 def setUpClass(cls):
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000287 support.requires("network")
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000288 with support.transient_internet(cls.NNTP_HOST):
Victor Stinner4dc3b9c2017-04-27 18:25:03 +0200289 try:
290 cls.server = cls.NNTP_CLASS(cls.NNTP_HOST, timeout=TIMEOUT,
291 usenetrc=False)
292 except EOFError:
293 raise unittest.SkipTest(f"{cls} got EOF error on connecting "
294 f"to {cls.NNTP_HOST!r}")
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000295
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000296 @classmethod
297 def tearDownClass(cls):
298 if cls.server is not None:
299 cls.server.quit()
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000300
Serhiy Storchaka43767632013-11-03 21:31:38 +0200301@unittest.skipUnless(ssl, 'requires SSL support')
302class NetworkedNNTP_SSLTests(NetworkedNNTPTests):
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000303
Serhiy Storchaka43767632013-11-03 21:31:38 +0200304 # Technical limits for this public NNTP server (see http://www.aioe.org):
305 # "Only two concurrent connections per IP address are allowed and
306 # 400 connections per day are accepted from each IP address."
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000307
Serhiy Storchaka43767632013-11-03 21:31:38 +0200308 NNTP_HOST = 'nntp.aioe.org'
309 GROUP_NAME = 'comp.lang.python'
310 GROUP_PAT = 'comp.lang.*'
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000311
Serhiy Storchaka43767632013-11-03 21:31:38 +0200312 NNTP_CLASS = getattr(nntplib, 'NNTP_SSL', None)
Antoine Pitrou45ca9872010-11-13 00:28:53 +0000313
Serhiy Storchaka43767632013-11-03 21:31:38 +0200314 # Disabled as it produces too much data
315 test_list = None
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000316
Serhiy Storchaka43767632013-11-03 21:31:38 +0200317 # Disabled as the connection will already be encrypted.
318 test_starttls = None
Antoine Pitrou1cb121e2010-11-09 18:54:37 +0000319
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000320
321#
322# Non-networked tests using a local server (or something mocking it).
323#
324
325class _NNTPServerIO(io.RawIOBase):
326 """A raw IO object allowing NNTP commands to be received and processed
327 by a handler. The handler can push responses which can then be read
328 from the IO object."""
329
330 def __init__(self, handler):
331 io.RawIOBase.__init__(self)
332 # The channel from the client
333 self.c2s = io.BytesIO()
334 # The channel to the client
335 self.s2c = io.BytesIO()
336 self.handler = handler
337 self.handler.start(self.c2s.readline, self.push_data)
338
339 def readable(self):
340 return True
341
342 def writable(self):
343 return True
344
345 def push_data(self, data):
346 """Push (buffer) some data to send to the client."""
347 pos = self.s2c.tell()
348 self.s2c.seek(0, 2)
349 self.s2c.write(data)
350 self.s2c.seek(pos)
351
352 def write(self, b):
353 """The client sends us some data"""
354 pos = self.c2s.tell()
355 self.c2s.write(b)
356 self.c2s.seek(pos)
357 self.handler.process_pending()
358 return len(b)
359
360 def readinto(self, buf):
361 """The client wants to read a response"""
362 self.handler.process_pending()
363 b = self.s2c.read(len(buf))
364 n = len(b)
365 buf[:n] = b
366 return n
367
368
Serhiy Storchaka52027c32015-03-21 09:40:26 +0200369def make_mock_file(handler):
370 sio = _NNTPServerIO(handler)
371 # Using BufferedRWPair instead of BufferedRandom ensures the file
372 # isn't seekable.
373 file = io.BufferedRWPair(sio, sio)
374 return (sio, file)
375
376
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000377class MockedNNTPTestsMixin:
378 # Override in derived classes
379 handler_class = None
380
381 def setUp(self):
382 super().setUp()
383 self.make_server()
384
385 def tearDown(self):
386 super().tearDown()
387 del self.server
388
389 def make_server(self, *args, **kwargs):
390 self.handler = self.handler_class()
Serhiy Storchaka52027c32015-03-21 09:40:26 +0200391 self.sio, file = make_mock_file(self.handler)
Antoine Pitroua5785b12010-09-29 16:19:50 +0000392 self.server = nntplib._NNTPBase(file, 'test.server', *args, **kwargs)
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000393 return self.server
394
395
Antoine Pitrou71135622012-02-14 23:29:34 +0100396class MockedNNTPWithReaderModeMixin(MockedNNTPTestsMixin):
397 def setUp(self):
398 super().setUp()
399 self.make_server(readermode=True)
400
401
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000402class NNTPv1Handler:
403 """A handler for RFC 977"""
404
405 welcome = "200 NNTP mock server"
406
407 def start(self, readline, push_data):
408 self.in_body = False
409 self.allow_posting = True
410 self._readline = readline
411 self._push_data = push_data
Antoine Pitrou54411c12012-02-12 19:14:17 +0100412 self._logged_in = False
413 self._user_sent = False
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000414 # Our welcome
415 self.handle_welcome()
416
417 def _decode(self, data):
418 return str(data, "utf-8", "surrogateescape")
419
420 def process_pending(self):
421 if self.in_body:
422 while True:
423 line = self._readline()
424 if not line:
425 return
426 self.body.append(line)
427 if line == b".\r\n":
428 break
429 try:
430 meth, tokens = self.body_callback
431 meth(*tokens, body=self.body)
432 finally:
433 self.body_callback = None
434 self.body = None
435 self.in_body = False
436 while True:
437 line = self._decode(self._readline())
438 if not line:
439 return
440 if not line.endswith("\r\n"):
441 raise ValueError("line doesn't end with \\r\\n: {!r}".format(line))
442 line = line[:-2]
443 cmd, *tokens = line.split()
444 #meth = getattr(self.handler, "handle_" + cmd.upper(), None)
445 meth = getattr(self, "handle_" + cmd.upper(), None)
446 if meth is None:
447 self.handle_unknown()
448 else:
449 try:
450 meth(*tokens)
451 except Exception as e:
452 raise ValueError("command failed: {!r}".format(line)) from e
453 else:
454 if self.in_body:
455 self.body_callback = meth, tokens
456 self.body = []
457
458 def expect_body(self):
459 """Flag that the client is expected to post a request body"""
460 self.in_body = True
461
462 def push_data(self, data):
463 """Push some binary data"""
464 self._push_data(data)
465
466 def push_lit(self, lit):
467 """Push a string literal"""
468 lit = textwrap.dedent(lit)
469 lit = "\r\n".join(lit.splitlines()) + "\r\n"
470 lit = lit.encode('utf-8')
471 self.push_data(lit)
472
473 def handle_unknown(self):
474 self.push_lit("500 What?")
475
476 def handle_welcome(self):
477 self.push_lit(self.welcome)
478
479 def handle_QUIT(self):
480 self.push_lit("205 Bye!")
481
482 def handle_DATE(self):
483 self.push_lit("111 20100914001155")
484
485 def handle_GROUP(self, group):
486 if group == "fr.comp.lang.python":
487 self.push_lit("211 486 761 1265 fr.comp.lang.python")
488 else:
489 self.push_lit("411 No such group {}".format(group))
490
491 def handle_HELP(self):
492 self.push_lit("""\
493 100 Legal commands
494 authinfo user Name|pass Password|generic <prog> <args>
495 date
496 help
497 Report problems to <root@example.org>
498 .""")
499
500 def handle_STAT(self, message_spec=None):
501 if message_spec is None:
502 self.push_lit("412 No newsgroup selected")
503 elif message_spec == "3000234":
504 self.push_lit("223 3000234 <45223423@example.com>")
505 elif message_spec == "<45223423@example.com>":
506 self.push_lit("223 0 <45223423@example.com>")
507 else:
508 self.push_lit("430 No Such Article Found")
509
510 def handle_NEXT(self):
511 self.push_lit("223 3000237 <668929@example.org> retrieved")
512
513 def handle_LAST(self):
514 self.push_lit("223 3000234 <45223423@example.com> retrieved")
515
516 def handle_LIST(self, action=None, param=None):
517 if action is None:
518 self.push_lit("""\
519 215 Newsgroups in form "group high low flags".
520 comp.lang.python 0000052340 0000002828 y
521 comp.lang.python.announce 0000001153 0000000993 m
522 free.it.comp.lang.python 0000000002 0000000002 y
523 fr.comp.lang.python 0000001254 0000000760 y
524 free.it.comp.lang.python.learner 0000000000 0000000001 y
525 tw.bbs.comp.lang.python 0000000304 0000000304 y
526 .""")
Antoine Pitrou08eeada2010-11-04 21:36:15 +0000527 elif action == "ACTIVE":
528 if param == "*distutils*":
529 self.push_lit("""\
530 215 Newsgroups in form "group high low flags"
531 gmane.comp.python.distutils.devel 0000014104 0000000001 m
532 gmane.comp.python.distutils.cvs 0000000000 0000000001 m
533 .""")
534 else:
535 self.push_lit("""\
536 215 Newsgroups in form "group high low flags"
537 .""")
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000538 elif action == "OVERVIEW.FMT":
539 self.push_lit("""\
540 215 Order of fields in overview database.
541 Subject:
542 From:
543 Date:
544 Message-ID:
545 References:
546 Bytes:
547 Lines:
548 Xref:full
549 .""")
550 elif action == "NEWSGROUPS":
551 assert param is not None
552 if param == "comp.lang.python":
553 self.push_lit("""\
554 215 Descriptions in form "group description".
555 comp.lang.python\tThe Python computer language.
556 .""")
557 elif param == "comp.lang.python*":
558 self.push_lit("""\
559 215 Descriptions in form "group description".
560 comp.lang.python.announce\tAnnouncements about the Python language. (Moderated)
561 comp.lang.python\tThe Python computer language.
562 .""")
563 else:
564 self.push_lit("""\
565 215 Descriptions in form "group description".
566 .""")
567 else:
568 self.push_lit('501 Unknown LIST keyword')
569
570 def handle_NEWNEWS(self, group, date_str, time_str):
571 # We hard code different return messages depending on passed
572 # argument and date syntax.
573 if (group == "comp.lang.python" and date_str == "20100913"
574 and time_str == "082004"):
575 # Date was passed in RFC 3977 format (NNTP "v2")
576 self.push_lit("""\
577 230 list of newsarticles (NNTP v2) created after Mon Sep 13 08:20:04 2010 follows
578 <a4929a40-6328-491a-aaaf-cb79ed7309a2@q2g2000vbk.googlegroups.com>
579 <f30c0419-f549-4218-848f-d7d0131da931@y3g2000vbm.googlegroups.com>
580 .""")
581 elif (group == "comp.lang.python" and date_str == "100913"
582 and time_str == "082004"):
583 # Date was passed in RFC 977 format (NNTP "v1")
584 self.push_lit("""\
585 230 list of newsarticles (NNTP v1) created after Mon Sep 13 08:20:04 2010 follows
586 <a4929a40-6328-491a-aaaf-cb79ed7309a2@q2g2000vbk.googlegroups.com>
587 <f30c0419-f549-4218-848f-d7d0131da931@y3g2000vbm.googlegroups.com>
588 .""")
Georg Brandl28e78412013-10-27 07:29:47 +0100589 elif (group == 'comp.lang.python' and
590 date_str in ('20100101', '100101') and
591 time_str == '090000'):
592 self.push_lit('too long line' * 3000 +
593 '\n.')
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000594 else:
595 self.push_lit("""\
596 230 An empty list of newsarticles follows
597 .""")
598 # (Note for experiments: many servers disable NEWNEWS.
599 # As of this writing, sicinfo3.epfl.ch doesn't.)
600
601 def handle_XOVER(self, message_spec):
602 if message_spec == "57-59":
603 self.push_lit(
604 "224 Overview information for 57-58 follows\n"
605 "57\tRe: ANN: New Plone book with strong Python (and Zope) themes throughout"
606 "\tDoug Hellmann <doug.hellmann-Re5JQEeQqe8AvxtiuMwx3w@public.gmane.org>"
607 "\tSat, 19 Jun 2010 18:04:08 -0400"
608 "\t<4FD05F05-F98B-44DC-8111-C6009C925F0C@gmail.com>"
609 "\t<hvalf7$ort$1@dough.gmane.org>\t7103\t16"
610 "\tXref: news.gmane.org gmane.comp.python.authors:57"
611 "\n"
612 "58\tLooking for a few good bloggers"
613 "\tDoug Hellmann <doug.hellmann-Re5JQEeQqe8AvxtiuMwx3w@public.gmane.org>"
614 "\tThu, 22 Jul 2010 09:14:14 -0400"
615 "\t<A29863FA-F388-40C3-AA25-0FD06B09B5BF@gmail.com>"
616 "\t\t6683\t16"
Antoine Pitrou4103bc02010-11-03 18:18:43 +0000617 "\t"
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000618 "\n"
Martin Panter6245cb32016-04-15 02:14:19 +0000619 # A UTF-8 overview line from fr.comp.lang.python
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000620 "59\tRe: Message d'erreur incompréhensible (par moi)"
621 "\tEric Brunel <eric.brunel@pragmadev.nospam.com>"
622 "\tWed, 15 Sep 2010 18:09:15 +0200"
623 "\t<eric.brunel-2B8B56.18091515092010@news.wanadoo.fr>"
624 "\t<4c90ec87$0$32425$ba4acef3@reader.news.orange.fr>\t1641\t27"
625 "\tXref: saria.nerim.net fr.comp.lang.python:1265"
626 "\n"
627 ".\n")
628 else:
629 self.push_lit("""\
630 224 No articles
631 .""")
632
633 def handle_POST(self, *, body=None):
634 if body is None:
635 if self.allow_posting:
636 self.push_lit("340 Input article; end with <CR-LF>.<CR-LF>")
637 self.expect_body()
638 else:
639 self.push_lit("440 Posting not permitted")
640 else:
641 assert self.allow_posting
642 self.push_lit("240 Article received OK")
643 self.posted_body = body
644
645 def handle_IHAVE(self, message_id, *, body=None):
646 if body is None:
647 if (self.allow_posting and
648 message_id == "<i.am.an.article.you.will.want@example.com>"):
649 self.push_lit("335 Send it; end with <CR-LF>.<CR-LF>")
650 self.expect_body()
651 else:
652 self.push_lit("435 Article not wanted")
653 else:
654 assert self.allow_posting
655 self.push_lit("235 Article transferred OK")
656 self.posted_body = body
657
658 sample_head = """\
659 From: "Demo User" <nobody@example.net>
660 Subject: I am just a test article
661 Content-Type: text/plain; charset=UTF-8; format=flowed
662 Message-ID: <i.am.an.article.you.will.want@example.com>"""
663
664 sample_body = """\
665 This is just a test article.
666 ..Here is a dot-starting line.
667
668 -- Signed by Andr\xe9."""
669
670 sample_article = sample_head + "\n\n" + sample_body
671
672 def handle_ARTICLE(self, message_spec=None):
673 if message_spec is None:
674 self.push_lit("220 3000237 <45223423@example.com>")
675 elif message_spec == "<45223423@example.com>":
676 self.push_lit("220 0 <45223423@example.com>")
677 elif message_spec == "3000234":
678 self.push_lit("220 3000234 <45223423@example.com>")
679 else:
680 self.push_lit("430 No Such Article Found")
681 return
682 self.push_lit(self.sample_article)
683 self.push_lit(".")
684
685 def handle_HEAD(self, message_spec=None):
686 if message_spec is None:
687 self.push_lit("221 3000237 <45223423@example.com>")
688 elif message_spec == "<45223423@example.com>":
689 self.push_lit("221 0 <45223423@example.com>")
690 elif message_spec == "3000234":
691 self.push_lit("221 3000234 <45223423@example.com>")
692 else:
693 self.push_lit("430 No Such Article Found")
694 return
695 self.push_lit(self.sample_head)
696 self.push_lit(".")
697
698 def handle_BODY(self, message_spec=None):
699 if message_spec is None:
700 self.push_lit("222 3000237 <45223423@example.com>")
701 elif message_spec == "<45223423@example.com>":
702 self.push_lit("222 0 <45223423@example.com>")
703 elif message_spec == "3000234":
704 self.push_lit("222 3000234 <45223423@example.com>")
705 else:
706 self.push_lit("430 No Such Article Found")
707 return
708 self.push_lit(self.sample_body)
709 self.push_lit(".")
710
Antoine Pitrou54411c12012-02-12 19:14:17 +0100711 def handle_AUTHINFO(self, cred_type, data):
712 if self._logged_in:
713 self.push_lit('502 Already Logged In')
714 elif cred_type == 'user':
715 if self._user_sent:
716 self.push_lit('482 User Credential Already Sent')
717 else:
718 self.push_lit('381 Password Required')
719 self._user_sent = True
720 elif cred_type == 'pass':
721 self.push_lit('281 Login Successful')
722 self._logged_in = True
723 else:
724 raise Exception('Unknown cred type {}'.format(cred_type))
725
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000726
727class NNTPv2Handler(NNTPv1Handler):
728 """A handler for RFC 3977 (NNTP "v2")"""
729
730 def handle_CAPABILITIES(self):
Antoine Pitrou54411c12012-02-12 19:14:17 +0100731 fmt = """\
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000732 101 Capability list:
Antoine Pitrouf80b3f72010-11-02 22:31:52 +0000733 VERSION 2 3
Antoine Pitrou54411c12012-02-12 19:14:17 +0100734 IMPLEMENTATION INN 2.5.1{}
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000735 HDR
736 LIST ACTIVE ACTIVE.TIMES DISTRIB.PATS HEADERS NEWSGROUPS OVERVIEW.FMT
737 OVER
738 POST
739 READER
Antoine Pitrou54411c12012-02-12 19:14:17 +0100740 ."""
741
742 if not self._logged_in:
743 self.push_lit(fmt.format('\n AUTHINFO USER'))
744 else:
745 self.push_lit(fmt.format(''))
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000746
Antoine Pitrou71135622012-02-14 23:29:34 +0100747 def handle_MODE(self, _):
748 raise Exception('MODE READER sent despite READER has been advertised')
749
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000750 def handle_OVER(self, message_spec=None):
751 return self.handle_XOVER(message_spec)
752
753
Antoine Pitrou54411c12012-02-12 19:14:17 +0100754class CapsAfterLoginNNTPv2Handler(NNTPv2Handler):
755 """A handler that allows CAPABILITIES only after login"""
756
757 def handle_CAPABILITIES(self):
758 if not self._logged_in:
759 self.push_lit('480 You must log in.')
760 else:
761 super().handle_CAPABILITIES()
762
763
Antoine Pitrou71135622012-02-14 23:29:34 +0100764class ModeSwitchingNNTPv2Handler(NNTPv2Handler):
765 """A server that starts in transit mode"""
766
767 def __init__(self):
768 self._switched = False
769
770 def handle_CAPABILITIES(self):
771 fmt = """\
772 101 Capability list:
773 VERSION 2 3
774 IMPLEMENTATION INN 2.5.1
775 HDR
776 LIST ACTIVE ACTIVE.TIMES DISTRIB.PATS HEADERS NEWSGROUPS OVERVIEW.FMT
777 OVER
778 POST
779 {}READER
780 ."""
781 if self._switched:
782 self.push_lit(fmt.format(''))
783 else:
784 self.push_lit(fmt.format('MODE-'))
785
786 def handle_MODE(self, what):
787 assert not self._switched and what == 'reader'
788 self._switched = True
789 self.push_lit('200 Posting allowed')
790
791
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000792class NNTPv1v2TestsMixin:
793
794 def setUp(self):
795 super().setUp()
796
797 def test_welcome(self):
798 self.assertEqual(self.server.welcome, self.handler.welcome)
799
Antoine Pitrou54411c12012-02-12 19:14:17 +0100800 def test_authinfo(self):
801 if self.nntp_version == 2:
802 self.assertIn('AUTHINFO', self.server._caps)
803 self.server.login('testuser', 'testpw')
804 # if AUTHINFO is gone from _caps we also know that getcapabilities()
805 # has been called after login as it should
806 self.assertNotIn('AUTHINFO', self.server._caps)
807
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000808 def test_date(self):
809 resp, date = self.server.date()
810 self.assertEqual(resp, "111 20100914001155")
811 self.assertEqual(date, datetime.datetime(2010, 9, 14, 0, 11, 55))
812
813 def test_quit(self):
814 self.assertFalse(self.sio.closed)
815 resp = self.server.quit()
816 self.assertEqual(resp, "205 Bye!")
817 self.assertTrue(self.sio.closed)
818
819 def test_help(self):
820 resp, help = self.server.help()
821 self.assertEqual(resp, "100 Legal commands")
822 self.assertEqual(help, [
823 ' authinfo user Name|pass Password|generic <prog> <args>',
824 ' date',
825 ' help',
826 'Report problems to <root@example.org>',
827 ])
828
829 def test_list(self):
830 resp, groups = self.server.list()
831 self.assertEqual(len(groups), 6)
832 g = groups[1]
833 self.assertEqual(g,
834 GroupInfo("comp.lang.python.announce", "0000001153",
835 "0000000993", "m"))
Antoine Pitrou08eeada2010-11-04 21:36:15 +0000836 resp, groups = self.server.list("*distutils*")
837 self.assertEqual(len(groups), 2)
838 g = groups[0]
839 self.assertEqual(g,
840 GroupInfo("gmane.comp.python.distutils.devel", "0000014104",
841 "0000000001", "m"))
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000842
843 def test_stat(self):
844 resp, art_num, message_id = self.server.stat(3000234)
845 self.assertEqual(resp, "223 3000234 <45223423@example.com>")
846 self.assertEqual(art_num, 3000234)
847 self.assertEqual(message_id, "<45223423@example.com>")
848 resp, art_num, message_id = self.server.stat("<45223423@example.com>")
849 self.assertEqual(resp, "223 0 <45223423@example.com>")
850 self.assertEqual(art_num, 0)
851 self.assertEqual(message_id, "<45223423@example.com>")
852 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
853 self.server.stat("<non.existent.id>")
854 self.assertEqual(cm.exception.response, "430 No Such Article Found")
855 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
856 self.server.stat()
857 self.assertEqual(cm.exception.response, "412 No newsgroup selected")
858
859 def test_next(self):
860 resp, art_num, message_id = self.server.next()
861 self.assertEqual(resp, "223 3000237 <668929@example.org> retrieved")
862 self.assertEqual(art_num, 3000237)
863 self.assertEqual(message_id, "<668929@example.org>")
864
865 def test_last(self):
866 resp, art_num, message_id = self.server.last()
867 self.assertEqual(resp, "223 3000234 <45223423@example.com> retrieved")
868 self.assertEqual(art_num, 3000234)
869 self.assertEqual(message_id, "<45223423@example.com>")
870
871 def test_description(self):
872 desc = self.server.description("comp.lang.python")
873 self.assertEqual(desc, "The Python computer language.")
874 desc = self.server.description("comp.lang.pythonx")
875 self.assertEqual(desc, "")
876
877 def test_descriptions(self):
878 resp, groups = self.server.descriptions("comp.lang.python")
879 self.assertEqual(resp, '215 Descriptions in form "group description".')
880 self.assertEqual(groups, {
881 "comp.lang.python": "The Python computer language.",
882 })
883 resp, groups = self.server.descriptions("comp.lang.python*")
884 self.assertEqual(groups, {
885 "comp.lang.python": "The Python computer language.",
886 "comp.lang.python.announce": "Announcements about the Python language. (Moderated)",
887 })
888 resp, groups = self.server.descriptions("comp.lang.pythonx")
889 self.assertEqual(groups, {})
890
891 def test_group(self):
892 resp, count, first, last, group = self.server.group("fr.comp.lang.python")
893 self.assertTrue(resp.startswith("211 "), resp)
894 self.assertEqual(first, 761)
895 self.assertEqual(last, 1265)
896 self.assertEqual(count, 486)
897 self.assertEqual(group, "fr.comp.lang.python")
898 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
899 self.server.group("comp.lang.python.devel")
900 exc = cm.exception
901 self.assertTrue(exc.response.startswith("411 No such group"),
902 exc.response)
903
904 def test_newnews(self):
905 # NEWNEWS comp.lang.python [20]100913 082004
906 dt = datetime.datetime(2010, 9, 13, 8, 20, 4)
907 resp, ids = self.server.newnews("comp.lang.python", dt)
908 expected = (
909 "230 list of newsarticles (NNTP v{0}) "
910 "created after Mon Sep 13 08:20:04 2010 follows"
911 ).format(self.nntp_version)
912 self.assertEqual(resp, expected)
913 self.assertEqual(ids, [
914 "<a4929a40-6328-491a-aaaf-cb79ed7309a2@q2g2000vbk.googlegroups.com>",
915 "<f30c0419-f549-4218-848f-d7d0131da931@y3g2000vbm.googlegroups.com>",
916 ])
917 # NEWNEWS fr.comp.lang.python [20]100913 082004
918 dt = datetime.datetime(2010, 9, 13, 8, 20, 4)
919 resp, ids = self.server.newnews("fr.comp.lang.python", dt)
920 self.assertEqual(resp, "230 An empty list of newsarticles follows")
921 self.assertEqual(ids, [])
922
923 def _check_article_body(self, lines):
924 self.assertEqual(len(lines), 4)
Marc-André Lemburg8f36af72011-02-25 15:42:01 +0000925 self.assertEqual(lines[-1].decode('utf-8'), "-- Signed by André.")
Antoine Pitrou69ab9512010-09-29 15:03:40 +0000926 self.assertEqual(lines[-2], b"")
927 self.assertEqual(lines[-3], b".Here is a dot-starting line.")
928 self.assertEqual(lines[-4], b"This is just a test article.")
929
930 def _check_article_head(self, lines):
931 self.assertEqual(len(lines), 4)
932 self.assertEqual(lines[0], b'From: "Demo User" <nobody@example.net>')
933 self.assertEqual(lines[3], b"Message-ID: <i.am.an.article.you.will.want@example.com>")
934
935 def _check_article_data(self, lines):
936 self.assertEqual(len(lines), 9)
937 self._check_article_head(lines[:4])
938 self._check_article_body(lines[-4:])
939 self.assertEqual(lines[4], b"")
940
941 def test_article(self):
942 # ARTICLE
943 resp, info = self.server.article()
944 self.assertEqual(resp, "220 3000237 <45223423@example.com>")
945 art_num, message_id, lines = info
946 self.assertEqual(art_num, 3000237)
947 self.assertEqual(message_id, "<45223423@example.com>")
948 self._check_article_data(lines)
949 # ARTICLE num
950 resp, info = self.server.article(3000234)
951 self.assertEqual(resp, "220 3000234 <45223423@example.com>")
952 art_num, message_id, lines = info
953 self.assertEqual(art_num, 3000234)
954 self.assertEqual(message_id, "<45223423@example.com>")
955 self._check_article_data(lines)
956 # ARTICLE id
957 resp, info = self.server.article("<45223423@example.com>")
958 self.assertEqual(resp, "220 0 <45223423@example.com>")
959 art_num, message_id, lines = info
960 self.assertEqual(art_num, 0)
961 self.assertEqual(message_id, "<45223423@example.com>")
962 self._check_article_data(lines)
963 # Non-existent id
964 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
965 self.server.article("<non-existent@example.com>")
966 self.assertEqual(cm.exception.response, "430 No Such Article Found")
967
968 def test_article_file(self):
969 # With a "file" argument
970 f = io.BytesIO()
971 resp, info = self.server.article(file=f)
972 self.assertEqual(resp, "220 3000237 <45223423@example.com>")
973 art_num, message_id, lines = info
974 self.assertEqual(art_num, 3000237)
975 self.assertEqual(message_id, "<45223423@example.com>")
976 self.assertEqual(lines, [])
977 data = f.getvalue()
978 self.assertTrue(data.startswith(
979 b'From: "Demo User" <nobody@example.net>\r\n'
980 b'Subject: I am just a test article\r\n'
981 ), ascii(data))
982 self.assertTrue(data.endswith(
983 b'This is just a test article.\r\n'
984 b'.Here is a dot-starting line.\r\n'
985 b'\r\n'
986 b'-- Signed by Andr\xc3\xa9.\r\n'
987 ), ascii(data))
988
989 def test_head(self):
990 # HEAD
991 resp, info = self.server.head()
992 self.assertEqual(resp, "221 3000237 <45223423@example.com>")
993 art_num, message_id, lines = info
994 self.assertEqual(art_num, 3000237)
995 self.assertEqual(message_id, "<45223423@example.com>")
996 self._check_article_head(lines)
997 # HEAD num
998 resp, info = self.server.head(3000234)
999 self.assertEqual(resp, "221 3000234 <45223423@example.com>")
1000 art_num, message_id, lines = info
1001 self.assertEqual(art_num, 3000234)
1002 self.assertEqual(message_id, "<45223423@example.com>")
1003 self._check_article_head(lines)
1004 # HEAD id
1005 resp, info = self.server.head("<45223423@example.com>")
1006 self.assertEqual(resp, "221 0 <45223423@example.com>")
1007 art_num, message_id, lines = info
1008 self.assertEqual(art_num, 0)
1009 self.assertEqual(message_id, "<45223423@example.com>")
1010 self._check_article_head(lines)
1011 # Non-existent id
1012 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
1013 self.server.head("<non-existent@example.com>")
1014 self.assertEqual(cm.exception.response, "430 No Such Article Found")
1015
Antoine Pitrou2640b522012-02-15 18:53:18 +01001016 def test_head_file(self):
1017 f = io.BytesIO()
1018 resp, info = self.server.head(file=f)
1019 self.assertEqual(resp, "221 3000237 <45223423@example.com>")
1020 art_num, message_id, lines = info
1021 self.assertEqual(art_num, 3000237)
1022 self.assertEqual(message_id, "<45223423@example.com>")
1023 self.assertEqual(lines, [])
1024 data = f.getvalue()
1025 self.assertTrue(data.startswith(
1026 b'From: "Demo User" <nobody@example.net>\r\n'
1027 b'Subject: I am just a test article\r\n'
1028 ), ascii(data))
1029 self.assertFalse(data.endswith(
1030 b'This is just a test article.\r\n'
1031 b'.Here is a dot-starting line.\r\n'
1032 b'\r\n'
1033 b'-- Signed by Andr\xc3\xa9.\r\n'
1034 ), ascii(data))
1035
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001036 def test_body(self):
1037 # BODY
1038 resp, info = self.server.body()
1039 self.assertEqual(resp, "222 3000237 <45223423@example.com>")
1040 art_num, message_id, lines = info
1041 self.assertEqual(art_num, 3000237)
1042 self.assertEqual(message_id, "<45223423@example.com>")
1043 self._check_article_body(lines)
1044 # BODY num
1045 resp, info = self.server.body(3000234)
1046 self.assertEqual(resp, "222 3000234 <45223423@example.com>")
1047 art_num, message_id, lines = info
1048 self.assertEqual(art_num, 3000234)
1049 self.assertEqual(message_id, "<45223423@example.com>")
1050 self._check_article_body(lines)
1051 # BODY id
1052 resp, info = self.server.body("<45223423@example.com>")
1053 self.assertEqual(resp, "222 0 <45223423@example.com>")
1054 art_num, message_id, lines = info
1055 self.assertEqual(art_num, 0)
1056 self.assertEqual(message_id, "<45223423@example.com>")
1057 self._check_article_body(lines)
1058 # Non-existent id
1059 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
1060 self.server.body("<non-existent@example.com>")
1061 self.assertEqual(cm.exception.response, "430 No Such Article Found")
1062
Antoine Pitrou2640b522012-02-15 18:53:18 +01001063 def test_body_file(self):
1064 f = io.BytesIO()
1065 resp, info = self.server.body(file=f)
1066 self.assertEqual(resp, "222 3000237 <45223423@example.com>")
1067 art_num, message_id, lines = info
1068 self.assertEqual(art_num, 3000237)
1069 self.assertEqual(message_id, "<45223423@example.com>")
1070 self.assertEqual(lines, [])
1071 data = f.getvalue()
1072 self.assertFalse(data.startswith(
1073 b'From: "Demo User" <nobody@example.net>\r\n'
1074 b'Subject: I am just a test article\r\n'
1075 ), ascii(data))
1076 self.assertTrue(data.endswith(
1077 b'This is just a test article.\r\n'
1078 b'.Here is a dot-starting line.\r\n'
1079 b'\r\n'
1080 b'-- Signed by Andr\xc3\xa9.\r\n'
1081 ), ascii(data))
1082
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001083 def check_over_xover_resp(self, resp, overviews):
1084 self.assertTrue(resp.startswith("224 "), resp)
1085 self.assertEqual(len(overviews), 3)
1086 art_num, over = overviews[0]
1087 self.assertEqual(art_num, 57)
1088 self.assertEqual(over, {
1089 "from": "Doug Hellmann <doug.hellmann-Re5JQEeQqe8AvxtiuMwx3w@public.gmane.org>",
1090 "subject": "Re: ANN: New Plone book with strong Python (and Zope) themes throughout",
1091 "date": "Sat, 19 Jun 2010 18:04:08 -0400",
1092 "message-id": "<4FD05F05-F98B-44DC-8111-C6009C925F0C@gmail.com>",
1093 "references": "<hvalf7$ort$1@dough.gmane.org>",
1094 ":bytes": "7103",
1095 ":lines": "16",
1096 "xref": "news.gmane.org gmane.comp.python.authors:57"
1097 })
Antoine Pitrou4103bc02010-11-03 18:18:43 +00001098 art_num, over = overviews[1]
1099 self.assertEqual(over["xref"], None)
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001100 art_num, over = overviews[2]
1101 self.assertEqual(over["subject"],
1102 "Re: Message d'erreur incompréhensible (par moi)")
1103
1104 def test_xover(self):
1105 resp, overviews = self.server.xover(57, 59)
1106 self.check_over_xover_resp(resp, overviews)
1107
1108 def test_over(self):
1109 # In NNTP "v1", this will fallback on XOVER
1110 resp, overviews = self.server.over((57, 59))
1111 self.check_over_xover_resp(resp, overviews)
1112
1113 sample_post = (
1114 b'From: "Demo User" <nobody@example.net>\r\n'
1115 b'Subject: I am just a test article\r\n'
1116 b'Content-Type: text/plain; charset=UTF-8; format=flowed\r\n'
1117 b'Message-ID: <i.am.an.article.you.will.want@example.com>\r\n'
1118 b'\r\n'
1119 b'This is just a test article.\r\n'
1120 b'.Here is a dot-starting line.\r\n'
1121 b'\r\n'
1122 b'-- Signed by Andr\xc3\xa9.\r\n'
1123 )
1124
1125 def _check_posted_body(self):
1126 # Check the raw body as received by the server
1127 lines = self.handler.posted_body
1128 # One additional line for the "." terminator
1129 self.assertEqual(len(lines), 10)
1130 self.assertEqual(lines[-1], b'.\r\n')
1131 self.assertEqual(lines[-2], b'-- Signed by Andr\xc3\xa9.\r\n')
1132 self.assertEqual(lines[-3], b'\r\n')
1133 self.assertEqual(lines[-4], b'..Here is a dot-starting line.\r\n')
1134 self.assertEqual(lines[0], b'From: "Demo User" <nobody@example.net>\r\n')
1135
1136 def _check_post_ihave_sub(self, func, *args, file_factory):
1137 # First the prepared post with CRLF endings
1138 post = self.sample_post
1139 func_args = args + (file_factory(post),)
1140 self.handler.posted_body = None
1141 resp = func(*func_args)
1142 self._check_posted_body()
1143 # Then the same post with "normal" line endings - they should be
1144 # converted by NNTP.post and NNTP.ihave.
1145 post = self.sample_post.replace(b"\r\n", b"\n")
1146 func_args = args + (file_factory(post),)
1147 self.handler.posted_body = None
1148 resp = func(*func_args)
1149 self._check_posted_body()
1150 return resp
1151
1152 def check_post_ihave(self, func, success_resp, *args):
1153 # With a bytes object
1154 resp = self._check_post_ihave_sub(func, *args, file_factory=bytes)
1155 self.assertEqual(resp, success_resp)
1156 # With a bytearray object
1157 resp = self._check_post_ihave_sub(func, *args, file_factory=bytearray)
1158 self.assertEqual(resp, success_resp)
1159 # With a file object
1160 resp = self._check_post_ihave_sub(func, *args, file_factory=io.BytesIO)
1161 self.assertEqual(resp, success_resp)
1162 # With an iterable of terminated lines
1163 def iterlines(b):
Ezio Melottid8b509b2011-09-28 17:37:55 +03001164 return iter(b.splitlines(keepends=True))
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001165 resp = self._check_post_ihave_sub(func, *args, file_factory=iterlines)
1166 self.assertEqual(resp, success_resp)
1167 # With an iterable of non-terminated lines
1168 def iterlines(b):
Ezio Melottid8b509b2011-09-28 17:37:55 +03001169 return iter(b.splitlines(keepends=False))
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001170 resp = self._check_post_ihave_sub(func, *args, file_factory=iterlines)
1171 self.assertEqual(resp, success_resp)
1172
1173 def test_post(self):
1174 self.check_post_ihave(self.server.post, "240 Article received OK")
1175 self.handler.allow_posting = False
1176 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
1177 self.server.post(self.sample_post)
1178 self.assertEqual(cm.exception.response,
1179 "440 Posting not permitted")
1180
1181 def test_ihave(self):
1182 self.check_post_ihave(self.server.ihave, "235 Article transferred OK",
1183 "<i.am.an.article.you.will.want@example.com>")
1184 with self.assertRaises(nntplib.NNTPTemporaryError) as cm:
1185 self.server.ihave("<another.message.id>", self.sample_post)
1186 self.assertEqual(cm.exception.response,
1187 "435 Article not wanted")
1188
Georg Brandl28e78412013-10-27 07:29:47 +01001189 def test_too_long_lines(self):
1190 dt = datetime.datetime(2010, 1, 1, 9, 0, 0)
1191 self.assertRaises(nntplib.NNTPDataError,
1192 self.server.newnews, "comp.lang.python", dt)
1193
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001194
1195class NNTPv1Tests(NNTPv1v2TestsMixin, MockedNNTPTestsMixin, unittest.TestCase):
1196 """Tests an NNTP v1 server (no capabilities)."""
1197
1198 nntp_version = 1
1199 handler_class = NNTPv1Handler
1200
1201 def test_caps(self):
1202 caps = self.server.getcapabilities()
1203 self.assertEqual(caps, {})
1204 self.assertEqual(self.server.nntp_version, 1)
Antoine Pitroua0781152010-11-05 19:16:37 +00001205 self.assertEqual(self.server.nntp_implementation, None)
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001206
1207
1208class NNTPv2Tests(NNTPv1v2TestsMixin, MockedNNTPTestsMixin, unittest.TestCase):
1209 """Tests an NNTP v2 server (with capabilities)."""
1210
1211 nntp_version = 2
1212 handler_class = NNTPv2Handler
1213
1214 def test_caps(self):
1215 caps = self.server.getcapabilities()
1216 self.assertEqual(caps, {
Antoine Pitrouf80b3f72010-11-02 22:31:52 +00001217 'VERSION': ['2', '3'],
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001218 'IMPLEMENTATION': ['INN', '2.5.1'],
1219 'AUTHINFO': ['USER'],
1220 'HDR': [],
1221 'LIST': ['ACTIVE', 'ACTIVE.TIMES', 'DISTRIB.PATS',
1222 'HEADERS', 'NEWSGROUPS', 'OVERVIEW.FMT'],
1223 'OVER': [],
1224 'POST': [],
1225 'READER': [],
1226 })
Antoine Pitrouf80b3f72010-11-02 22:31:52 +00001227 self.assertEqual(self.server.nntp_version, 3)
Antoine Pitroua0781152010-11-05 19:16:37 +00001228 self.assertEqual(self.server.nntp_implementation, 'INN 2.5.1')
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001229
1230
Antoine Pitrou54411c12012-02-12 19:14:17 +01001231class CapsAfterLoginNNTPv2Tests(MockedNNTPTestsMixin, unittest.TestCase):
1232 """Tests a probably NNTP v2 server with capabilities only after login."""
1233
1234 nntp_version = 2
1235 handler_class = CapsAfterLoginNNTPv2Handler
1236
1237 def test_caps_only_after_login(self):
1238 self.assertEqual(self.server._caps, {})
1239 self.server.login('testuser', 'testpw')
1240 self.assertIn('VERSION', self.server._caps)
1241
1242
Antoine Pitrou71135622012-02-14 23:29:34 +01001243class SendReaderNNTPv2Tests(MockedNNTPWithReaderModeMixin,
1244 unittest.TestCase):
1245 """Same tests as for v2 but we tell NTTP to send MODE READER to a server
1246 that isn't in READER mode by default."""
1247
1248 nntp_version = 2
1249 handler_class = ModeSwitchingNNTPv2Handler
1250
1251 def test_we_are_in_reader_mode_after_connect(self):
1252 self.assertIn('READER', self.server._caps)
1253
1254
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001255class MiscTests(unittest.TestCase):
1256
1257 def test_decode_header(self):
1258 def gives(a, b):
1259 self.assertEqual(nntplib.decode_header(a), b)
1260 gives("" , "")
1261 gives("a plain header", "a plain header")
1262 gives(" with extra spaces ", " with extra spaces ")
1263 gives("=?ISO-8859-15?Q?D=E9buter_en_Python?=", "Débuter en Python")
1264 gives("=?utf-8?q?Re=3A_=5Bsqlite=5D_probl=C3=A8me_avec_ORDER_BY_sur_des_cha?="
1265 " =?utf-8?q?=C3=AEnes_de_caract=C3=A8res_accentu=C3=A9es?=",
1266 "Re: [sqlite] problème avec ORDER BY sur des chaînes de caractères accentuées")
1267 gives("Re: =?UTF-8?B?cHJvYmzDqG1lIGRlIG1hdHJpY2U=?=",
1268 "Re: problème de matrice")
1269 # A natively utf-8 header (found in the real world!)
1270 gives("Re: Message d'erreur incompréhensible (par moi)",
1271 "Re: Message d'erreur incompréhensible (par moi)")
1272
1273 def test_parse_overview_fmt(self):
1274 # The minimal (default) response
1275 lines = ["Subject:", "From:", "Date:", "Message-ID:",
1276 "References:", ":bytes", ":lines"]
1277 self.assertEqual(nntplib._parse_overview_fmt(lines),
1278 ["subject", "from", "date", "message-id", "references",
1279 ":bytes", ":lines"])
1280 # The minimal response using alternative names
1281 lines = ["Subject:", "From:", "Date:", "Message-ID:",
1282 "References:", "Bytes:", "Lines:"]
1283 self.assertEqual(nntplib._parse_overview_fmt(lines),
1284 ["subject", "from", "date", "message-id", "references",
1285 ":bytes", ":lines"])
1286 # Variations in casing
1287 lines = ["subject:", "FROM:", "DaTe:", "message-ID:",
1288 "References:", "BYTES:", "Lines:"]
1289 self.assertEqual(nntplib._parse_overview_fmt(lines),
1290 ["subject", "from", "date", "message-id", "references",
1291 ":bytes", ":lines"])
1292 # First example from RFC 3977
1293 lines = ["Subject:", "From:", "Date:", "Message-ID:",
1294 "References:", ":bytes", ":lines", "Xref:full",
1295 "Distribution:full"]
1296 self.assertEqual(nntplib._parse_overview_fmt(lines),
1297 ["subject", "from", "date", "message-id", "references",
1298 ":bytes", ":lines", "xref", "distribution"])
1299 # Second example from RFC 3977
1300 lines = ["Subject:", "From:", "Date:", "Message-ID:",
1301 "References:", "Bytes:", "Lines:", "Xref:FULL",
1302 "Distribution:FULL"]
1303 self.assertEqual(nntplib._parse_overview_fmt(lines),
1304 ["subject", "from", "date", "message-id", "references",
1305 ":bytes", ":lines", "xref", "distribution"])
1306 # A classic response from INN
1307 lines = ["Subject:", "From:", "Date:", "Message-ID:",
1308 "References:", "Bytes:", "Lines:", "Xref:full"]
1309 self.assertEqual(nntplib._parse_overview_fmt(lines),
1310 ["subject", "from", "date", "message-id", "references",
1311 ":bytes", ":lines", "xref"])
1312
1313 def test_parse_overview(self):
1314 fmt = nntplib._DEFAULT_OVERVIEW_FMT + ["xref"]
1315 # First example from RFC 3977
1316 lines = [
1317 '3000234\tI am just a test article\t"Demo User" '
1318 '<nobody@example.com>\t6 Oct 1998 04:38:40 -0500\t'
1319 '<45223423@example.com>\t<45454@example.net>\t1234\t'
1320 '17\tXref: news.example.com misc.test:3000363',
1321 ]
1322 overview = nntplib._parse_overview(lines, fmt)
1323 (art_num, fields), = overview
1324 self.assertEqual(art_num, 3000234)
1325 self.assertEqual(fields, {
1326 'subject': 'I am just a test article',
1327 'from': '"Demo User" <nobody@example.com>',
1328 'date': '6 Oct 1998 04:38:40 -0500',
1329 'message-id': '<45223423@example.com>',
1330 'references': '<45454@example.net>',
1331 ':bytes': '1234',
1332 ':lines': '17',
1333 'xref': 'news.example.com misc.test:3000363',
1334 })
Antoine Pitrou4103bc02010-11-03 18:18:43 +00001335 # Second example; here the "Xref" field is totally absent (including
1336 # the header name) and comes out as None
1337 lines = [
1338 '3000234\tI am just a test article\t"Demo User" '
1339 '<nobody@example.com>\t6 Oct 1998 04:38:40 -0500\t'
1340 '<45223423@example.com>\t<45454@example.net>\t1234\t'
1341 '17\t\t',
1342 ]
1343 overview = nntplib._parse_overview(lines, fmt)
1344 (art_num, fields), = overview
1345 self.assertEqual(fields['xref'], None)
1346 # Third example; the "Xref" is an empty string, while "references"
1347 # is a single space.
1348 lines = [
1349 '3000234\tI am just a test article\t"Demo User" '
1350 '<nobody@example.com>\t6 Oct 1998 04:38:40 -0500\t'
1351 '<45223423@example.com>\t \t1234\t'
1352 '17\tXref: \t',
1353 ]
1354 overview = nntplib._parse_overview(lines, fmt)
1355 (art_num, fields), = overview
1356 self.assertEqual(fields['references'], ' ')
1357 self.assertEqual(fields['xref'], '')
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001358
1359 def test_parse_datetime(self):
1360 def gives(a, b, *c):
1361 self.assertEqual(nntplib._parse_datetime(a, b),
1362 datetime.datetime(*c))
1363 # Output of DATE command
1364 gives("19990623135624", None, 1999, 6, 23, 13, 56, 24)
1365 # Variations
1366 gives("19990623", "135624", 1999, 6, 23, 13, 56, 24)
1367 gives("990623", "135624", 1999, 6, 23, 13, 56, 24)
1368 gives("090623", "135624", 2009, 6, 23, 13, 56, 24)
1369
1370 def test_unparse_datetime(self):
1371 # Test non-legacy mode
1372 # 1) with a datetime
1373 def gives(y, M, d, h, m, s, date_str, time_str):
1374 dt = datetime.datetime(y, M, d, h, m, s)
1375 self.assertEqual(nntplib._unparse_datetime(dt),
1376 (date_str, time_str))
1377 self.assertEqual(nntplib._unparse_datetime(dt, False),
1378 (date_str, time_str))
1379 gives(1999, 6, 23, 13, 56, 24, "19990623", "135624")
1380 gives(2000, 6, 23, 13, 56, 24, "20000623", "135624")
1381 gives(2010, 6, 5, 1, 2, 3, "20100605", "010203")
1382 # 2) with a date
1383 def gives(y, M, d, date_str, time_str):
1384 dt = datetime.date(y, M, d)
1385 self.assertEqual(nntplib._unparse_datetime(dt),
1386 (date_str, time_str))
1387 self.assertEqual(nntplib._unparse_datetime(dt, False),
1388 (date_str, time_str))
1389 gives(1999, 6, 23, "19990623", "000000")
1390 gives(2000, 6, 23, "20000623", "000000")
1391 gives(2010, 6, 5, "20100605", "000000")
1392
1393 def test_unparse_datetime_legacy(self):
1394 # Test legacy mode (RFC 977)
1395 # 1) with a datetime
1396 def gives(y, M, d, h, m, s, date_str, time_str):
1397 dt = datetime.datetime(y, M, d, h, m, s)
1398 self.assertEqual(nntplib._unparse_datetime(dt, True),
1399 (date_str, time_str))
1400 gives(1999, 6, 23, 13, 56, 24, "990623", "135624")
1401 gives(2000, 6, 23, 13, 56, 24, "000623", "135624")
1402 gives(2010, 6, 5, 1, 2, 3, "100605", "010203")
1403 # 2) with a date
1404 def gives(y, M, d, date_str, time_str):
1405 dt = datetime.date(y, M, d)
1406 self.assertEqual(nntplib._unparse_datetime(dt, True),
1407 (date_str, time_str))
1408 gives(1999, 6, 23, "990623", "000000")
1409 gives(2000, 6, 23, "000623", "000000")
1410 gives(2010, 6, 5, "100605", "000000")
1411
Serhiy Storchaka43767632013-11-03 21:31:38 +02001412 @unittest.skipUnless(ssl, 'requires SSL support')
1413 def test_ssl_support(self):
1414 self.assertTrue(hasattr(nntplib, 'NNTP_SSL'))
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001415
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001416
Berker Peksag96756b62014-09-20 08:53:05 +03001417class PublicAPITests(unittest.TestCase):
1418 """Ensures that the correct values are exposed in the public API."""
1419
1420 def test_module_all_attribute(self):
1421 self.assertTrue(hasattr(nntplib, '__all__'))
1422 target_api = ['NNTP', 'NNTPError', 'NNTPReplyError',
1423 'NNTPTemporaryError', 'NNTPPermanentError',
1424 'NNTPProtocolError', 'NNTPDataError', 'decode_header']
1425 if ssl is not None:
1426 target_api.append('NNTP_SSL')
1427 self.assertEqual(set(nntplib.__all__), set(target_api))
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001428
Serhiy Storchaka52027c32015-03-21 09:40:26 +02001429class MockSocketTests(unittest.TestCase):
1430 """Tests involving a mock socket object
1431
1432 Used where the _NNTPServerIO file object is not enough."""
1433
1434 nntp_class = nntplib.NNTP
1435
1436 def check_constructor_error_conditions(
1437 self, handler_class,
1438 expected_error_type, expected_error_msg,
1439 login=None, password=None):
1440
1441 class mock_socket_module:
1442 def create_connection(address, timeout):
1443 return MockSocket()
1444
1445 class MockSocket:
1446 def close(self):
1447 nonlocal socket_closed
1448 socket_closed = True
1449
1450 def makefile(socket, mode):
1451 handler = handler_class()
1452 _, file = make_mock_file(handler)
1453 files.append(file)
1454 return file
1455
1456 socket_closed = False
1457 files = []
1458 with patch('nntplib.socket', mock_socket_module), \
1459 self.assertRaisesRegex(expected_error_type, expected_error_msg):
1460 self.nntp_class('dummy', user=login, password=password)
1461 self.assertTrue(socket_closed)
1462 for f in files:
1463 self.assertTrue(f.closed)
1464
1465 def test_bad_welcome(self):
1466 #Test a bad welcome message
1467 class Handler(NNTPv1Handler):
1468 welcome = 'Bad Welcome'
1469 self.check_constructor_error_conditions(
1470 Handler, nntplib.NNTPProtocolError, Handler.welcome)
1471
1472 def test_service_temporarily_unavailable(self):
1473 #Test service temporarily unavailable
1474 class Handler(NNTPv1Handler):
Martin Pantereb995702016-07-28 01:11:04 +00001475 welcome = '400 Service temporarily unavailable'
Serhiy Storchaka52027c32015-03-21 09:40:26 +02001476 self.check_constructor_error_conditions(
1477 Handler, nntplib.NNTPTemporaryError, Handler.welcome)
1478
1479 def test_service_permanently_unavailable(self):
1480 #Test service permanently unavailable
1481 class Handler(NNTPv1Handler):
Martin Pantereb995702016-07-28 01:11:04 +00001482 welcome = '502 Service permanently unavailable'
Serhiy Storchaka52027c32015-03-21 09:40:26 +02001483 self.check_constructor_error_conditions(
1484 Handler, nntplib.NNTPPermanentError, Handler.welcome)
1485
1486 def test_bad_capabilities(self):
1487 #Test a bad capabilities response
1488 class Handler(NNTPv1Handler):
1489 def handle_CAPABILITIES(self):
1490 self.push_lit(capabilities_response)
1491 capabilities_response = '201 bad capability'
1492 self.check_constructor_error_conditions(
1493 Handler, nntplib.NNTPReplyError, capabilities_response)
1494
1495 def test_login_aborted(self):
1496 #Test a bad authinfo response
1497 login = 't@e.com'
1498 password = 'python'
1499 class Handler(NNTPv1Handler):
1500 def handle_AUTHINFO(self, *args):
1501 self.push_lit(authinfo_response)
1502 authinfo_response = '503 Mechanism not recognized'
1503 self.check_constructor_error_conditions(
1504 Handler, nntplib.NNTPPermanentError, authinfo_response,
1505 login, password)
1506
Serhiy Storchaka80774342015-04-03 15:02:20 +03001507class bypass_context:
1508 """Bypass encryption and actual SSL module"""
1509 def wrap_socket(sock, **args):
1510 return sock
1511
1512@unittest.skipUnless(ssl, 'requires SSL support')
1513class MockSslTests(MockSocketTests):
1514 @staticmethod
1515 def nntp_class(*pos, **kw):
1516 return nntplib.NNTP_SSL(*pos, ssl_context=bypass_context, **kw)
Victor Stinner8c9bba02015-04-03 11:06:40 +02001517
Martin Panter8f19e8e2016-01-19 01:10:58 +00001518@unittest.skipUnless(threading, 'requires multithreading')
1519class LocalServerTests(unittest.TestCase):
1520 def setUp(self):
1521 sock = socket.socket()
1522 port = support.bind_port(sock)
1523 sock.listen()
1524 self.background = threading.Thread(
1525 target=self.run_server, args=(sock,))
1526 self.background.start()
1527 self.addCleanup(self.background.join)
1528
1529 self.nntp = NNTP(support.HOST, port, usenetrc=False).__enter__()
1530 self.addCleanup(self.nntp.__exit__, None, None, None)
1531
1532 def run_server(self, sock):
1533 # Could be generalized to handle more commands in separate methods
1534 with sock:
1535 [client, _] = sock.accept()
1536 with contextlib.ExitStack() as cleanup:
1537 cleanup.enter_context(client)
1538 reader = cleanup.enter_context(client.makefile('rb'))
1539 client.sendall(b'200 Server ready\r\n')
1540 while True:
1541 cmd = reader.readline()
1542 if cmd == b'CAPABILITIES\r\n':
1543 client.sendall(
1544 b'101 Capability list:\r\n'
1545 b'VERSION 2\r\n'
1546 b'STARTTLS\r\n'
1547 b'.\r\n'
1548 )
1549 elif cmd == b'STARTTLS\r\n':
1550 reader.close()
1551 client.sendall(b'382 Begin TLS negotiation now\r\n')
Christian Heimesd0486372016-09-10 23:23:33 +02001552 context = ssl.SSLContext()
1553 context.load_cert_chain(certfile)
1554 client = context.wrap_socket(
1555 client, server_side=True)
Martin Panter8f19e8e2016-01-19 01:10:58 +00001556 cleanup.enter_context(client)
1557 reader = cleanup.enter_context(client.makefile('rb'))
1558 elif cmd == b'QUIT\r\n':
1559 client.sendall(b'205 Bye!\r\n')
1560 break
1561 else:
1562 raise ValueError('Unexpected command {!r}'.format(cmd))
1563
1564 @unittest.skipUnless(ssl, 'requires SSL support')
1565 def test_starttls(self):
1566 file = self.nntp.file
1567 sock = self.nntp.sock
1568 self.nntp.starttls()
1569 # Check that the socket and internal pseudo-file really were
1570 # changed.
1571 self.assertNotEqual(file, self.nntp.file)
1572 self.assertNotEqual(sock, self.nntp.sock)
1573 # Check that the new socket really is an SSL one
1574 self.assertIsInstance(self.nntp.sock, ssl.SSLSocket)
1575 # Check that trying starttls when it's already active fails.
1576 self.assertRaises(ValueError, self.nntp.starttls)
1577
Serhiy Storchaka52027c32015-03-21 09:40:26 +02001578
Antoine Pitrou69ab9512010-09-29 15:03:40 +00001579if __name__ == "__main__":
Berker Peksag96756b62014-09-20 08:53:05 +03001580 unittest.main()